Compare commits
No commits in common. "master" and "menft" have entirely different histories.
@ -60,7 +60,7 @@
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.15.3</version>
|
||||
<version>1.14.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Eclipse Paho MQTT 5.0 客户端 -->
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
package xyz.playedu.common.util;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.google.gson.Gson;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public class RichTextToJsonConverter {
|
||||
|
||||
// 雪花ID生成器相关常量
|
||||
private static final long START_TIMESTAMP = 1609459200000L; // 2021-01-01 00:00:00
|
||||
private static final long DATA_CENTER_ID_BITS = 5L;
|
||||
private static final long WORKER_ID_BITS = 5L;
|
||||
private static final long SEQUENCE_BITS = 12L;
|
||||
|
||||
private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS);
|
||||
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
|
||||
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
|
||||
|
||||
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
|
||||
private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
|
||||
private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS;
|
||||
|
||||
private static final long dataCenterId = 1L;
|
||||
private static final long workerId = 1L;
|
||||
|
||||
private static final AtomicLong lastTimestamp = new AtomicLong(-1L);
|
||||
private static final AtomicLong sequence = new AtomicLong(0L);
|
||||
|
||||
public static Map<String, Object> convertToStructuredJson(String html) {
|
||||
Document doc = Jsoup.parse(html);
|
||||
|
||||
List<Map<String, Object>> elements = new ArrayList<>();
|
||||
|
||||
for (Element element : doc.body().children()) {
|
||||
Map<String, Object> node = parseElement(element);
|
||||
elements.add(node);
|
||||
}
|
||||
|
||||
return Collections.singletonMap("content", elements);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为HTML文档中的所有元素添加唯一ID属性
|
||||
* @param doc HTML文档对象
|
||||
*/
|
||||
private static String addIdAttributes(String html) {
|
||||
Document doc = Jsoup.parse(html);
|
||||
// 获取body内的所有元素
|
||||
Elements allElements = doc.body().select("*");
|
||||
for (Element element : allElements) {
|
||||
// 为每个元素设置唯一雪花ID
|
||||
element.attr("id", "elem_" + nextId());
|
||||
}
|
||||
return doc.html();
|
||||
}
|
||||
|
||||
/**
|
||||
* 雪花ID生成算法实现
|
||||
* @return 唯一的雪花ID
|
||||
*/
|
||||
private static synchronized long nextId() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
if (timestamp < lastTimestamp.get()) {
|
||||
throw new RuntimeException("Clock moved backwards. Refusing to generate id for "
|
||||
+ (lastTimestamp.get() - timestamp) + " milliseconds");
|
||||
}
|
||||
|
||||
if (timestamp == lastTimestamp.get()) {
|
||||
long currentSequence = sequence.incrementAndGet();
|
||||
if (currentSequence > MAX_SEQUENCE) {
|
||||
timestamp = tilNextMillis(timestamp);
|
||||
sequence.set(0L);
|
||||
}
|
||||
} else {
|
||||
sequence.set(0L);
|
||||
}
|
||||
|
||||
lastTimestamp.set(timestamp);
|
||||
|
||||
return ((timestamp - START_TIMESTAMP) << TIMESTAMP_LEFT_SHIFT)
|
||||
| (dataCenterId << DATA_CENTER_ID_SHIFT)
|
||||
| (workerId << WORKER_ID_SHIFT)
|
||||
| sequence.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待下一毫秒
|
||||
* @param lastTimestamp 上次时间戳
|
||||
* @return 下一毫秒时间戳
|
||||
*/
|
||||
private static long tilNextMillis(long lastTimestamp) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
while (timestamp <= lastTimestamp) {
|
||||
timestamp = System.currentTimeMillis();
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
private static Map<String, Object> parseElement(Element element) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("type", element.tagName());
|
||||
result.put("text", element.text());
|
||||
result.put("html", element.html());
|
||||
|
||||
// 提取元素的ID属性
|
||||
String elementId = element.attr("id");
|
||||
if (!elementId.isEmpty()) {
|
||||
result.put("id", elementId);
|
||||
}
|
||||
|
||||
// 记录位置信息(需结合渲染时的实际字符偏移)
|
||||
result.put("position", calculatePosition(element));
|
||||
|
||||
if (!element.children().isEmpty()) {
|
||||
List<Map<String, Object>> children = new ArrayList<>();
|
||||
for (Element child : element.children()) {
|
||||
children.add(parseElement(child));
|
||||
}
|
||||
result.put("children", children);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Integer> calculatePosition(Element element) {
|
||||
// 这里可以基于 DOM 的 textContent 和 offset 来计算字符位置
|
||||
// 实际项目中可能需要配合前端渲染时的字符索引
|
||||
Map<String, Integer> pos = new HashMap<>();
|
||||
pos.put("charStart", 0); // 示例值,实际需动态计算
|
||||
pos.put("charEnd", element.text().length());
|
||||
return pos;
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 测试数据:模拟教材中的富文本内容
|
||||
String html = "<h1><strong style=\"background-color: rgb(255, 255, 255);\">丹青绘峥嵘 星火传精神 | 北京恒爱慈助公益基金会参加“人民必胜”主题党日活动</strong></h1><p>2025年正值中国人民抗日战争暨世界反法西斯战争胜利80周年。9月3日,盛大阅兵仪式的震撼与感动仍在心中激荡,次日(9月4日),北京市扶贫济困领域基金会第三联合党委组织参观“人民必胜——纪念中国人民抗日战争暨世界反法西斯战争胜利80周年美术作品展”,又将这份爱国激情再度升华。于无声画作里听见历史的怒吼,于厚重色彩间感触民族的血脉,这次主题党日活动完成了一场从视觉直达心灵的爱国崇高接力。</p><p><br></p><p><img src=\"/cmsProject/profile/upload/2025/11/13/4 北京恒爱慈助公益基金会参加“人民必胜”主题党日活动_20251113151000A009.jpg\"></p><p><br></p><p>北京恒爱慈助公益基金会理事长胡忠信先生和秘书长王萌女士参与了此次活动,他们怀着无比崇敬与激动的心情,在记录重大战役的恢弘画作前驻足凝思,透过那斑斓的色彩和刚劲的线条,仿佛看到硝烟弥漫的战场,听到震耳欲聋的枪炮声,感受到将士们浴血奋战、视死如归的壮志豪情。</p><p><br></p><p><img src=\"/cmsProject/profile/upload/2025/11/13/图片1_20251113151126A010.png\"></p><p>在一件件栩栩如生的雕塑前,大家轻声交流,眼神中满是感动与敬仰,那一幕幕军民携手、共克时艰的温暖场景,如同冬日里的暖阳,照亮了每一个人的心灵。一件件充满力量感的艺术作品,宛如一把把钥匙,打开了历史的大门,让抗战时期的苦难历程与不屈精神穿越时空,直抵内心,现场氛围庄严肃穆。</p><p><img src=\"/cmsProject/profile/upload/2025/11/13/图片2_20251113151141A011.png\"></p><p><br></p><p>铭记历史、缅怀先烈、珍爱和平、开创未来,不是空洞的口号,需要我们用实际行动去弘扬、去传承。胡理事长表示:抗战精神中“天下兴亡、匹夫有责”的爱国情怀,“百折不挠、坚韧不拔”的必胜信念,正是新时代公益人需要传承的精神内核。我们要用每一次真诚援助传递温暖,用每一份爱心善举诠释责任,书写公益慈善壮丽篇章,让抗战精神在新时代熠熠生辉!</p>";
|
||||
String withIdHtml = addIdAttributes(html);
|
||||
System.out.println(withIdHtml);
|
||||
// 调用转换方法
|
||||
Map<String, Object> result = convertToStructuredJson(withIdHtml);
|
||||
|
||||
// 使用 FastJSON 打印结果
|
||||
System.out.println(JSON.toJSONString(result, true));
|
||||
}
|
||||
|
||||
}
|
||||
@ -104,7 +104,7 @@ export function GetResourceListApi(
|
||||
|
||||
//删除+ 批量删除
|
||||
export function DelResourceItemApi(idList: string) {
|
||||
return client.destroy(`/backend/v1/jc/resource/delIds?idList=${idList}`);
|
||||
return client.destroy(`/backend/v1/jc/softwareInfo?idList=${idList}`);
|
||||
}
|
||||
|
||||
//根据id查详情
|
||||
@ -146,6 +146,34 @@ export function UpdateDetailApi(
|
||||
});
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
export function getSoftwareClassApi() {
|
||||
return client.get(`/backend/v1/softwareClass`, {});
|
||||
}
|
||||
// 删除分类
|
||||
export function deleteSoftwareClassApi(idList: any[]) {
|
||||
return client.destroy(`/backend/v1/softwareClass?idList=${idList}`);
|
||||
}
|
||||
|
||||
// 编辑分类
|
||||
export function updateSortClassApi(id: number, className: any, parentId: any, sort: any) {
|
||||
return client.put(`/backend/v1/softwareClass`, {
|
||||
id,
|
||||
className,
|
||||
parentId,
|
||||
sort,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取绑定的学校信息
|
||||
export function getAssignedSchoolApi(softId: number | string) {
|
||||
return client.get(`/backend/v1/softwareInfo/getAllocateInfo`, { softId });
|
||||
}
|
||||
// 进行学校分配
|
||||
export function assignedSchoolApi(softId: number, tenantIds: string) {
|
||||
return client.post(`/backend/v1/softwareInfo/allocateSoft`, { softId, tenantIds });
|
||||
}
|
||||
|
||||
/*
|
||||
* Chapter相关信息
|
||||
* */
|
||||
@ -153,13 +181,6 @@ export function UpdateDetailApi(
|
||||
export function GetChapterListApi(params: any) {
|
||||
return client.get('/backend/v1/jc/chapter/index', params);
|
||||
}
|
||||
export function GetChapterTreeApi(params: any) {
|
||||
return client.get(`/backend/v1/jc/chapter/tree`, params);
|
||||
}
|
||||
export function GetChapterDetailApi(id: any, bookId: number) {
|
||||
return client.get(`/backend/v1/jc/chapter/${id}`, { bookId });
|
||||
}
|
||||
|
||||
// 包含嵌套的
|
||||
export function DropDiffClassApi(id: number, parent_id: number, ids: number[], book_id: number) {
|
||||
return client.put(`/backend/v1/jc/chapter/update/parent`, {
|
||||
@ -177,12 +198,12 @@ export function DropSameClassApi(ids: number[], book_id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function GetPreDestroyChapterApi(id: number, bookId: number) {
|
||||
return client.get(`/backend/v1/jc/chapter/${id}/destroy`, { bookId });
|
||||
export function checkDestroy(id: number) {
|
||||
return client.get(`/backend/v1/department/${id}/destroy`, {});
|
||||
}
|
||||
|
||||
export function DestroyChapterApi(id: number, bookId: number) {
|
||||
return client.destroy(`/backend/v1/jc/chapter/${id}?bookId=${bookId}`);
|
||||
export function DestroyChapterApi(id: number) {
|
||||
return client.destroy(`/backend/v1/department/${id}`);
|
||||
}
|
||||
|
||||
export function CreateChapterApi(name: string, parentId: number, sort: number, bookId: number) {
|
||||
@ -200,7 +221,7 @@ export function EditChapterApi(
|
||||
sort: number,
|
||||
bookId: number
|
||||
) {
|
||||
return client.put(`/backend/v1/jc/chapter/${id}`, {
|
||||
return client.post(`/backend/v1/jc/chapter/${id}`, {
|
||||
name,
|
||||
parentId,
|
||||
sort,
|
||||
@ -208,26 +229,6 @@ export function EditChapterApi(
|
||||
});
|
||||
}
|
||||
|
||||
/*chapterContent*/
|
||||
export function SaveChapterContentApi(
|
||||
chapterId: number,
|
||||
content: string,
|
||||
resourceIds: any[],
|
||||
knowledgeIds: any[],
|
||||
highlight: string
|
||||
) {
|
||||
return client.post(`/backend/v1/jc/chapter-content/${chapterId}`, {
|
||||
content,
|
||||
resourceIds,
|
||||
knowledgeIds,
|
||||
highlight,
|
||||
});
|
||||
}
|
||||
|
||||
export function GetChapterContentApi(chapterId: number, bookId: number) {
|
||||
return client.get(`/backend/v1/jc/chapter-content/${chapterId}`, { bookId });
|
||||
}
|
||||
|
||||
/*
|
||||
* Knowledge 知识点
|
||||
* */
|
||||
@ -250,45 +251,3 @@ export function getKnowledgeByCodesApi(codes: string) {
|
||||
/*
|
||||
* resource List
|
||||
* */
|
||||
|
||||
export function AddResourceItemApi(
|
||||
bookId: number,
|
||||
name: string,
|
||||
knowledgeCode: string,
|
||||
txtDesc: string,
|
||||
extension: string,
|
||||
size: number,
|
||||
path: string
|
||||
) {
|
||||
return client.post(`/jc/resource`, {
|
||||
bookId,
|
||||
name,
|
||||
knowledgeCode,
|
||||
txtDesc,
|
||||
extension,
|
||||
size,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
export function UpdateResourceItemApi(
|
||||
editId: number,
|
||||
bookId: number,
|
||||
name: string,
|
||||
knowledgeCode: string,
|
||||
txtDesc: string,
|
||||
extension: string,
|
||||
size: number,
|
||||
path: string
|
||||
) {
|
||||
return client.put(`/jc/resource`, {
|
||||
id: editId,
|
||||
bookId,
|
||||
name,
|
||||
knowledgeCode,
|
||||
txtDesc,
|
||||
extension,
|
||||
size,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
@ -446,7 +446,7 @@ export const AddQuestion = (props: PropsInterface) => {
|
||||
<Typography.Text>知识点</Typography.Text>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
placeholder={selectedTextbookId ? '请选择知识点' : '请先选择教材'}
|
||||
placeholder={selectedTextbookId ? "请选择知识点" : "请先选择教材"}
|
||||
value={knowledgeCode || undefined}
|
||||
disabled={!selectedTextbookId}
|
||||
loading={knowledgeLoading}
|
||||
|
||||
@ -78,14 +78,7 @@
|
||||
"uploadDrop": "请将视频文件拖入此处上传",
|
||||
"myDevice": "选择文件",
|
||||
"uploadError": "当前文件不可上传",
|
||||
"exportPaper": "导出答卷",
|
||||
"uploadCancelled": "上传已取消",
|
||||
"uploadInProgress": "文件上传中",
|
||||
"waitForUploadComplete": "请等待当前文件上传完成",
|
||||
"cancel": "取消",
|
||||
"uploading": "上传中",
|
||||
"uploadSuccess": "上传成功",
|
||||
"uploadFailed": "上传失败"
|
||||
"exportPaper": "导出答卷"
|
||||
},
|
||||
"error": {
|
||||
"text1": "无权限操作",
|
||||
@ -2052,12 +2045,7 @@
|
||||
"parent": "所属章节",
|
||||
"parentTip": "请选择所属章节",
|
||||
"tips": "注意:章节目录不可超过三级!",
|
||||
"tips3": "注意:章节目录超过三级,请调整!",
|
||||
"delText": "即将删除此章节目录及内容,且无法恢复,确认删除?",
|
||||
"unbindText1": "此章节下包含",
|
||||
"unbindText2": "个子章节",
|
||||
"unbindText3": "请先删除子章节后再删除此项。"
|
||||
|
||||
"tips3": "注意:章节目录超过三级,请调整!"
|
||||
},
|
||||
"resource": {
|
||||
"pageTitle": "资源管理",
|
||||
@ -2076,15 +2064,11 @@
|
||||
"typePlaceholder": "请选择资源类型",
|
||||
"desc": "资源描述",
|
||||
"descPlaceholder": "请输入资源描述",
|
||||
"knowledgeId": "所属知识点",
|
||||
"knowledgePlaceholder": "请选择所属知识点",
|
||||
"chapter": "所属章节",
|
||||
"chapterPlaceholder": "请选择所属章节",
|
||||
"uploadPlaceholder": "请上传文件",
|
||||
"uploadTips": "点击或拖拽文件到此处上传",
|
||||
"uploadTips2": "支持视频、图片、文档、音频等常见格式",
|
||||
"btnSave": "保存内容",
|
||||
"btnEdit": "编辑内容",
|
||||
"btnPreview": "预览",
|
||||
"knowledge": "知识图谱",
|
||||
"typeList": {
|
||||
@ -2094,12 +2078,8 @@
|
||||
"doc": "文档",
|
||||
"audio": "音频",
|
||||
"other": "其他"
|
||||
},
|
||||
"uploadLimitReached": "已达到上传限制",
|
||||
"uploadLimitReachedDesc": "无法上传更多文件",
|
||||
"singleFileLimit": "只能上传一个文件",
|
||||
"multiFileLimit": "最多只能上传 {{maxCount}} 个文件",
|
||||
"remainingCount": "还可上传 {{remaining}} 个文件(最多 {{maxCount}} 个)"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ export const DepartmentCreate: React.FC<PropInterface> = ({ open, onCancel }) =>
|
||||
|
||||
const checkArr = (departments: any[], id: number) => {
|
||||
const arr = [];
|
||||
for (let i = 0; i < departments[id]?.length; i++) {
|
||||
for (let i = 0; i < departments[id].length; i++) {
|
||||
if (!departments[departments[id][i].id]) {
|
||||
arr.push({
|
||||
label: departments[id][i].name,
|
||||
|
||||
@ -88,7 +88,7 @@ const DepartmentPage = () => {
|
||||
|
||||
const checkArr = (departments: DepartmentsBoxModel, id: number) => {
|
||||
const arr = [];
|
||||
for (let i = 0; i < departments[id]?.length; i++) {
|
||||
for (let i = 0; i < departments[id].length; i++) {
|
||||
if (!departments[departments[id][i].id]) {
|
||||
arr.push({
|
||||
title: (
|
||||
|
||||
@ -59,21 +59,18 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
|
||||
},
|
||||
});
|
||||
// 加载教材列表作为级联选择器第一级
|
||||
textbook
|
||||
.getTextbookSelectListApi()
|
||||
.then((res: any) => {
|
||||
if (res.data && Array.isArray(res.data)) {
|
||||
const options: CascaderOption[] = res.data.map((item: any) => ({
|
||||
value: item.id,
|
||||
label: item.title,
|
||||
isLeaf: false, // 表示有子节点
|
||||
}));
|
||||
setCascaderOptions(options);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('加载教材列表失败:', err);
|
||||
});
|
||||
textbook.getTextbookSelectListApi().then((res: any) => {
|
||||
if (res.data && Array.isArray(res.data)) {
|
||||
const options: CascaderOption[] = res.data.map((item: any) => ({
|
||||
value: item.id,
|
||||
label: item.title,
|
||||
isLeaf: false, // 表示有子节点
|
||||
}));
|
||||
setCascaderOptions(options);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('加载教材列表失败:', err);
|
||||
});
|
||||
setInit(false);
|
||||
}
|
||||
}, [form, open, id]);
|
||||
@ -84,27 +81,24 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
|
||||
targetOption.loading = true;
|
||||
|
||||
// 加载该教材下的知识点
|
||||
textbook
|
||||
.getKnowledgeListApi(targetOption.value as number)
|
||||
.then((res: any) => {
|
||||
targetOption.loading = false;
|
||||
if (res.data && Array.isArray(res.data)) {
|
||||
targetOption.children = res.data.map((item: any) => ({
|
||||
value: item.knowledgeCode || item.knowledge_code,
|
||||
label: item.name,
|
||||
isLeaf: true, // 知识点是叶子节点
|
||||
}));
|
||||
} else {
|
||||
targetOption.children = [];
|
||||
}
|
||||
setCascaderOptions([...cascaderOptions]);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('加载知识点失败:', err);
|
||||
targetOption.loading = false;
|
||||
textbook.getKnowledgeListApi(targetOption.value as number).then((res: any) => {
|
||||
targetOption.loading = false;
|
||||
if (res.data && Array.isArray(res.data)) {
|
||||
targetOption.children = res.data.map((item: any) => ({
|
||||
value: item.knowledgeCode || item.knowledge_code,
|
||||
label: item.name,
|
||||
isLeaf: true, // 知识点是叶子节点
|
||||
}));
|
||||
} else {
|
||||
targetOption.children = [];
|
||||
setCascaderOptions([...cascaderOptions]);
|
||||
});
|
||||
}
|
||||
setCascaderOptions([...cascaderOptions]);
|
||||
}).catch((err) => {
|
||||
console.error('加载知识点失败:', err);
|
||||
targetOption.loading = false;
|
||||
targetOption.children = [];
|
||||
setCascaderOptions([...cascaderOptions]);
|
||||
});
|
||||
};
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
@ -358,13 +352,11 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
question
|
||||
.questionStore(id, params, values.level, Number(type), knowledgeCode)
|
||||
.then((res: any) => {
|
||||
setLoading(false);
|
||||
message.success(t('commen.saveSuccess'));
|
||||
onCancel();
|
||||
});
|
||||
question.questionStore(id, params, values.level, Number(type), knowledgeCode).then((res: any) => {
|
||||
setLoading(false);
|
||||
message.success(t('commen.saveSuccess'));
|
||||
onCancel();
|
||||
});
|
||||
};
|
||||
|
||||
const onFinishFailed = (errorInfo: any) => {
|
||||
@ -483,7 +475,10 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="关联知识点" name="knowledge_cascader">
|
||||
<Form.Item
|
||||
label="关联知识点"
|
||||
name="knowledge_cascader"
|
||||
>
|
||||
<Cascader
|
||||
options={cascaderOptions}
|
||||
loadData={loadKnowledgeData as CascaderProps<CascaderOption>['loadData']}
|
||||
@ -496,9 +491,7 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
|
||||
filter: (inputValue: string, path: CascaderOption[]) =>
|
||||
path.some(
|
||||
(option) =>
|
||||
(option.label as string)
|
||||
.toLowerCase()
|
||||
.indexOf(inputValue.toLowerCase()) > -1
|
||||
(option.label as string).toLowerCase().indexOf(inputValue.toLowerCase()) > -1
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -4,38 +4,18 @@ import React, { useEffect, useState } from 'react';
|
||||
import { ChapterTree } from './compenents/chapterTree';
|
||||
import { BackBartment } from '../../compenents';
|
||||
import styles from './chapter.module.less';
|
||||
import {
|
||||
GetChapterContentApi,
|
||||
GetChapterDetailApi,
|
||||
GetChapterListApi,
|
||||
GetChapterTreeApi,
|
||||
SaveChapterContentApi,
|
||||
} from '../../api/textbook';
|
||||
import { GetChapterListApi } from '../../api/textbook';
|
||||
import TextbookEditor from './compenents/TextEditor/TextbookEditor';
|
||||
import { Empty, Spin } from 'antd';
|
||||
|
||||
export interface ChapterItemModel {
|
||||
bookId: number;
|
||||
chapterCode: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
level: null | number;
|
||||
name: string;
|
||||
sort: number;
|
||||
created_at: string;
|
||||
id: number;
|
||||
tenantId: number;
|
||||
updateTime: string;
|
||||
updater: string;
|
||||
parentId: number;
|
||||
}
|
||||
export interface ChapterTreeItem {
|
||||
bookId: number;
|
||||
chapterCode: string;
|
||||
name: string;
|
||||
from_scene: number;
|
||||
parent_chain: string;
|
||||
parent_id: number;
|
||||
sort: number;
|
||||
id: number;
|
||||
parentId: number;
|
||||
children: ChapterTreeItem[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChaptersBoxModel {
|
||||
@ -49,84 +29,35 @@ export interface Option {
|
||||
children?: Option[];
|
||||
}
|
||||
|
||||
export interface ChapterContent {
|
||||
title?: any;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
const ChapterManagementPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const [selectedChapter, setSelectedChapter] = useState<any>(null);
|
||||
const [contentChapterName, setContentChapterName] = useState<any>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const title = searchParams.get('title');
|
||||
const { bookId } = params;
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [isContentLoading, setIsContentLoading] = useState<boolean>(false);
|
||||
const [chapterListData, setChapterListData] = useState<ChaptersBoxModel>([]);
|
||||
const [treeData, setTreeData] = useState<ChaptersBoxModel>([]);
|
||||
const [selectedChapterId, setSelectedChapterId] = useState<string>();
|
||||
const [parentTitle, setParentTitle] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
getChapterData();
|
||||
}, [bookId]);
|
||||
|
||||
useEffect(() => {
|
||||
getChapterDetail();
|
||||
getChapterParentDetail();
|
||||
}, [selectedChapter, bookId]);
|
||||
|
||||
const getChapterData = () => {
|
||||
setLoading(true);
|
||||
GetChapterListApi({ bookId: bookId }).then((res: any) => {
|
||||
const resData: ChaptersBoxModel = res.data.chapters;
|
||||
setChapterListData(resData);
|
||||
setTreeData(resData);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
getChapterData();
|
||||
getChapterDetail();
|
||||
getChapterParentDetail();
|
||||
};
|
||||
|
||||
const getChapterParentDetail = () => {
|
||||
if (selectedChapter) {
|
||||
if (selectedChapter?.parentId === 0) {
|
||||
setParentTitle('根目录');
|
||||
} else {
|
||||
GetChapterDetailApi(selectedChapter?.parentId, Number(bookId)).then((res: any) => {
|
||||
const resData: ChapterItemModel = res.data;
|
||||
setParentTitle(resData.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getChapterDetail = () => {
|
||||
if (selectedChapter) {
|
||||
GetChapterDetailApi(selectedChapter?.id, Number(bookId)).then((res: any) => {
|
||||
const resData: ChapterItemModel = res.data;
|
||||
setContentChapterName(resData.name);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = (data: string) => {
|
||||
console.log(data, 'string');
|
||||
SaveChapterContentApi(selectedChapter?.id, data, [], [], '').then((res: any) => {
|
||||
console.log(res, 'res');
|
||||
});
|
||||
const onSave = () => {
|
||||
console.log(selectedChapterId);
|
||||
};
|
||||
const onContentChange = () => {
|
||||
console.log(selectedChapterId);
|
||||
};
|
||||
const handleSelectItem = (data: any) => {
|
||||
setSelectedChapter(data);
|
||||
setSelectedChapterId(data?.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles['chapter-box']}>
|
||||
@ -137,34 +68,25 @@ const ChapterManagementPage = () => {
|
||||
<div className={styles['left-box']}>
|
||||
<ChapterTree
|
||||
selectedId={selectedChapterId}
|
||||
onSelecet={handleSelectItem}
|
||||
bookId={Number(bookId)}
|
||||
isLoading={loading}
|
||||
title={title}
|
||||
chapterData={chapterListData}
|
||||
refreshTreeData={refreshData}
|
||||
chapterTreeData={treeData}
|
||||
refreshTreeData={getChapterData}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['right-box']}>
|
||||
{selectedChapter ? (
|
||||
<TextbookEditor
|
||||
isLoading={isContentLoading}
|
||||
chapterId={selectedChapter?.id}
|
||||
parentTitle={parentTitle || '根目录'}
|
||||
chapterTitle={contentChapterName || selectedChapter?.name}
|
||||
onSave={onSave}
|
||||
bookId={Number(bookId)}
|
||||
onContentChange={onContentChange}
|
||||
></TextbookEditor>
|
||||
) : (
|
||||
<div className="enhanced-textbook-editor empty-state">
|
||||
<div className="empty-content">
|
||||
<div className="empty-icon"></div>
|
||||
<h3>请选择章节</h3>
|
||||
<p>点击左侧目录,选择一个章节进行查看</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*{selectedChapter ? (*/}
|
||||
<TextbookEditor
|
||||
chapterId={selectedChapter?.id || 22}
|
||||
chapterTitle={selectedChapter?.name || '测试数据'}
|
||||
initialContent="请编写内容"
|
||||
onSave={onSave}
|
||||
onContentChange={onContentChange}
|
||||
></TextbookEditor>
|
||||
{/* : (
|
||||
<div>点击左侧目录选择</div>
|
||||
)}*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,515 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||
import { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
import './indesx.module.less';
|
||||
import { Progress, Spin } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
dataURLtoBlob,
|
||||
extractVideoThumbnail,
|
||||
generateDefaultVideoPoster,
|
||||
} from '../../../../utils';
|
||||
|
||||
// 自定义上传配置接口
|
||||
interface UploadConfig {
|
||||
getToken: () => string;
|
||||
getTenant: () => string;
|
||||
uploadUrl: string;
|
||||
}
|
||||
|
||||
interface WangEditorProps {
|
||||
value?: string;
|
||||
editor: IDomEditor | null;
|
||||
onChange?: (html: string) => void;
|
||||
onCreated?: (editor: IDomEditor) => void;
|
||||
defaultEditorConfig?: Partial<IEditorConfig>;
|
||||
toolbarConfig?: Partial<IToolbarConfig>;
|
||||
readonly?: boolean;
|
||||
height?: number | string;
|
||||
// 上传相关配置
|
||||
uploadConfig: UploadConfig;
|
||||
}
|
||||
|
||||
const EditorComponent: React.FC<WangEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onCreated,
|
||||
defaultEditorConfig = {},
|
||||
toolbarConfig,
|
||||
readonly = false,
|
||||
height = 550,
|
||||
editor,
|
||||
uploadConfig,
|
||||
}) => {
|
||||
const { uploadUrl, getToken, getTenant } = uploadConfig;
|
||||
|
||||
const [uploadState, setUploadState] = useState<{
|
||||
visible: boolean;
|
||||
progress: number;
|
||||
filename: string;
|
||||
}>({
|
||||
visible: false,
|
||||
progress: 0,
|
||||
filename: '',
|
||||
});
|
||||
|
||||
// 自定义上传IMG
|
||||
const customUploadImage = async (file: File, insertFn: any) => {
|
||||
if (!uploadConfig) {
|
||||
throw new Error('请配置上传参数');
|
||||
}
|
||||
|
||||
try {
|
||||
const { getToken, getTenant, uploadUrl } = uploadConfig;
|
||||
|
||||
let fullUploadUrl = uploadUrl;
|
||||
if (!fullUploadUrl.startsWith('http')) {
|
||||
fullUploadUrl =
|
||||
window.location.origin +
|
||||
(fullUploadUrl.startsWith('/') ? fullUploadUrl : '/' + fullUploadUrl);
|
||||
}
|
||||
fullUploadUrl = fullUploadUrl.replace(/\/+$/, '');
|
||||
|
||||
const apiUrl = `${fullUploadUrl}/backend/v1/localUpload/upload`;
|
||||
|
||||
// 2. 准备FormData
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = getToken();
|
||||
const tenantId = getTenant();
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'tenant-id': tenantId,
|
||||
// 注意:不要设置 Content-Type,让浏览器自动设置
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('上传响应:', result);
|
||||
|
||||
if (!response.ok || result.code !== 0) {
|
||||
throw new Error(result.message || `上传失败 (${response.status})`);
|
||||
}
|
||||
|
||||
// 插入图片到编辑器
|
||||
const imageData = result.data;
|
||||
if (!imageData) {
|
||||
throw new Error('服务器返回数据格式错误');
|
||||
}
|
||||
|
||||
let imageUrl = imageData.url;
|
||||
|
||||
// 如果还是没有URL,尝试其他可能的字段
|
||||
if (!imageUrl && typeof imageData === 'string') {
|
||||
imageUrl = imageData;
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
console.error('服务器返回数据:', result);
|
||||
throw new Error('未找到图片URL字段');
|
||||
}
|
||||
|
||||
// 确保URL是完整路径
|
||||
if (
|
||||
!imageUrl.startsWith('http') &&
|
||||
!imageUrl.startsWith('//') &&
|
||||
!imageUrl.startsWith('data:')
|
||||
) {
|
||||
if (imageUrl.startsWith('/')) {
|
||||
imageUrl = window.location.origin + imageUrl;
|
||||
} else {
|
||||
imageUrl = fullUploadUrl + '/' + imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
insertFn(imageUrl, imageData.newFileName || file.name, imageUrl);
|
||||
} catch (error) {
|
||||
let errorMessage = '上传失败';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
alert(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义视频上传VIDEO
|
||||
const customUploadVideo = async (file: File, insertFn: any) => {
|
||||
if (!uploadConfig) {
|
||||
throw new Error('请配置上传参数');
|
||||
}
|
||||
// 1. 先提取视频封面
|
||||
let videoPoster: string;
|
||||
try {
|
||||
videoPoster = await extractVideoThumbnail(file);
|
||||
} catch (thumbnailError) {
|
||||
videoPoster = generateDefaultVideoPoster();
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadState({
|
||||
visible: true,
|
||||
progress: 0,
|
||||
filename: file.name,
|
||||
});
|
||||
let fullUploadUrl = uploadUrl;
|
||||
if (!fullUploadUrl.startsWith('http')) {
|
||||
fullUploadUrl =
|
||||
window.location.origin +
|
||||
(fullUploadUrl.startsWith('/') ? fullUploadUrl : '/' + fullUploadUrl);
|
||||
}
|
||||
|
||||
fullUploadUrl = fullUploadUrl.replace(/\/+$/, '');
|
||||
|
||||
const apiUrl = `${fullUploadUrl}/backend/v1/localUpload/upload`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// 如果需要区分视频上传,可以添加额外参数
|
||||
formData.append('file_type', 'video');
|
||||
try {
|
||||
if (videoPoster.startsWith('data:image')) {
|
||||
const posterBlob = dataURLtoBlob(videoPoster);
|
||||
const posterFile = new File([posterBlob], `poster_${Date.now()}.jpg`, {
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
formData.append('poster', posterFile);
|
||||
console.log('封面文件已添加到上传');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('封面文件转换失败,跳过封面上传:', error);
|
||||
}
|
||||
formData.append('file_type', 'video');
|
||||
formData.append('filename', file.name);
|
||||
formData.append('filesize', file.size.toString());
|
||||
formData.append('filetype', file.type);
|
||||
|
||||
// 3. 准备请求头
|
||||
const token = getToken();
|
||||
const tenantId = getTenant();
|
||||
|
||||
// 4. 创建XMLHttpRequest以支持进度显示
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadState((prev) => ({
|
||||
...prev,
|
||||
progress: percent,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
setUploadState({
|
||||
visible: false,
|
||||
progress: 0,
|
||||
filename: '',
|
||||
});
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
if (result.code !== 0) {
|
||||
reject(new Error(result.message || `上传失败 (${xhr.status})`));
|
||||
return;
|
||||
}
|
||||
|
||||
const videoData = result.data;
|
||||
if (!videoData) {
|
||||
reject(new Error('服务器返回数据格式错误'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取视频URL
|
||||
let videoUrl = videoData.url || videoData.path || videoData.src || videoData.videoUrl;
|
||||
|
||||
// 如果还是没有URL,尝试其他可能的字段
|
||||
if (!videoUrl && typeof videoData === 'string') {
|
||||
videoUrl = videoData;
|
||||
}
|
||||
|
||||
if (!videoUrl) {
|
||||
console.error('服务器返回数据:', result);
|
||||
reject(new Error('未找到视频URL字段'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!videoUrl.startsWith('http') &&
|
||||
!videoUrl.startsWith('//') &&
|
||||
!videoUrl.startsWith('data:')
|
||||
) {
|
||||
if (videoUrl.startsWith('/')) {
|
||||
videoUrl = window.location.origin + videoUrl;
|
||||
} else {
|
||||
videoUrl = fullUploadUrl + '/' + videoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const poster = videoData.poster || videoPoster;
|
||||
const videoInfo = {
|
||||
src: videoUrl,
|
||||
poster: poster, // 视频封面(如果有)
|
||||
alt: videoData.newFileName || videoData.name || file.name || '视频',
|
||||
width: videoData.width || 'auto',
|
||||
height: videoData.height || 'auto',
|
||||
};
|
||||
insertFn(videoUrl, poster);
|
||||
resolve(videoInfo);
|
||||
} catch (parseError) {
|
||||
console.error('解析响应失败:', parseError);
|
||||
reject(new Error('解析服务器响应失败'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`上传失败 (${xhr.status})`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
setUploadState({
|
||||
visible: false,
|
||||
progress: 0,
|
||||
filename: '',
|
||||
});
|
||||
reject(new Error('网络错误,上传失败'));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
reject(new Error('上传被取消'));
|
||||
};
|
||||
|
||||
// 5. 发送请求
|
||||
xhr.open('POST', apiUrl, true);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('tenant-id', tenantId);
|
||||
xhr.send(formData);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('视频上传失败:', error);
|
||||
|
||||
// 给用户友好的错误提示
|
||||
let errorMessage = '视频上传失败';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
// 可以在这里显示错误提示
|
||||
alert(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: '请输入内容...',
|
||||
readOnly: readonly,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
customUpload: uploadConfig ? customUploadImage : undefined,
|
||||
allowedFileTypes: ['image/*'],
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
onProgress: (progress: number) => {
|
||||
console.log('上传进度:', progress + '%');
|
||||
},
|
||||
onError: (error: Error, file: File) => {
|
||||
console.error('上传错误:', error, file);
|
||||
},
|
||||
customValidate: (file: File) => {
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return '请上传图片文件 (JPEG, PNG, GIF, WebP)';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
base64LimitSize: 100 * 1024, // 100KB以下转base64
|
||||
},
|
||||
uploadVideo: {
|
||||
customUpload: uploadConfig ? customUploadVideo : undefined,
|
||||
allowedFileTypes: ['video/*'],
|
||||
maxFileSize: 500 * 1024 * 1024, // 500MB
|
||||
timeout: 300 * 1000, // 5分钟
|
||||
onProgress: (progress: number) => {
|
||||
console.log('视频上传进度:', progress + '%');
|
||||
},
|
||||
onError: (error: Error, file: File) => {
|
||||
console.error('视频上传错误:', error, file);
|
||||
alert(`视频上传失败: ${error.message}`);
|
||||
},
|
||||
customValidate: (file: File) => {
|
||||
// 支持的视频格式
|
||||
const validVideoTypes = [
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/ogg',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
'video/x-msvideo',
|
||||
'video/x-flv',
|
||||
'video/x-matroska',
|
||||
];
|
||||
|
||||
if (!validVideoTypes.includes(file.type)) {
|
||||
return '请上传支持的视频格式 (MP4, MPEG, OGG, WebM, MOV, AVI, FLV, MKV)';
|
||||
}
|
||||
// 检查文件大小(这里设为 500MB)
|
||||
const maxSize = 500 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
const maxSizeInMB = (maxSize / (1024 * 1024)).toFixed(0);
|
||||
return `视频文件过大 (${sizeInMB}MB),最大支持 ${maxSizeInMB}MB`;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 合并配置
|
||||
const mergedEditorConfig = { ...defaultEditorConfig, ...editorConfig };
|
||||
|
||||
// 深度合并 MENU_CONF,确保自定义上传配置不被覆盖
|
||||
if (editorConfig.MENU_CONF) {
|
||||
mergedEditorConfig.MENU_CONF = {
|
||||
...defaultEditorConfig.MENU_CONF,
|
||||
...editorConfig.MENU_CONF,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.localStorage.removeItem('html');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="editor-container">
|
||||
{/* 上传进度组件 */}
|
||||
<Spin
|
||||
spinning={uploadState.visible}
|
||||
size="large"
|
||||
fullscreen={uploadState.visible}
|
||||
tip={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
{/* 自定义旋转图标 */}
|
||||
<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
maxWidth: 400,
|
||||
wordBreak: 'break-word',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{uploadState.filename.length > 30
|
||||
? uploadState.filename.substring(0, 30) + '...'
|
||||
: uploadState.filename}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{ width: 300 }}>
|
||||
<Progress
|
||||
percent={uploadState.progress}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
strokeWidth={6}
|
||||
strokeLinecap="round"
|
||||
trailColor="#f0f0f0"
|
||||
format={(percent) => (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: percent === 100 ? '#52c41a' : '#1890ff',
|
||||
}}
|
||||
>
|
||||
{percent}%
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{uploadState.progress === 100 ? (
|
||||
<>
|
||||
<span style={{ color: '#52c41a' }}>✓</span>
|
||||
<span>上传完成,正在处理...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: '#fa8c16' }}>⏳</span>
|
||||
<span>上传中,请稍候...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
style={{ fontSize: 48 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: 40,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
defaultConfig={toolbarConfig}
|
||||
mode="default"
|
||||
style={{ borderBottom: '1px solid #ccc', backgroundColor: '#eee' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: height,
|
||||
overflowY: 'hidden',
|
||||
paddingBottom: 15,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
defaultConfig={mergedEditorConfig}
|
||||
value={value}
|
||||
onCreated={onCreated}
|
||||
onChange={(editor) => {
|
||||
const currentHtml = editor.getHtml();
|
||||
onChange?.(currentHtml);
|
||||
window.localStorage.setItem('html', currentHtml);
|
||||
}}
|
||||
mode="default"
|
||||
style={{
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorComponent;
|
||||
@ -1,11 +0,0 @@
|
||||
.editor-container {
|
||||
border: none;
|
||||
|
||||
.w-e-bar {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.w-e-text-container {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, message, Spin, Select, SelectProps } from 'antd';
|
||||
import { Modal, Form, Input, message, Space, Spin, type FormProps, Select } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import Dragger from 'antd/es/upload/Dragger';
|
||||
import TextArea from 'antd/es/input/TextArea';
|
||||
import {
|
||||
AddResourceItemApi,
|
||||
GetDetailApi,
|
||||
getKnowledgeListApi,
|
||||
UpdateResourceItemApi,
|
||||
} from '../../../../api/textbook';
|
||||
import { DraggerUpload } from '../Upload/DraggerUpload';
|
||||
|
||||
interface ModalPropsType {
|
||||
isOpen: boolean;
|
||||
@ -16,190 +11,87 @@ interface ModalPropsType {
|
||||
onCancel: () => void;
|
||||
isEdit: boolean;
|
||||
resourceId: number;
|
||||
typeOptions: any;
|
||||
}
|
||||
|
||||
const defaultData = [
|
||||
{
|
||||
createTime: '2025-11-29 16:37:08',
|
||||
updateTime: '2025-12-02 15:42:09',
|
||||
creator: null,
|
||||
updater: null,
|
||||
tenantId: '-1',
|
||||
id: 1,
|
||||
bookId: 46,
|
||||
parentId: 0,
|
||||
name: '高等数学基础',
|
||||
knowledgeCode: 'MATH001',
|
||||
desc: '高等数学的基础知识点,包含极限、导数、积分等',
|
||||
level: 1,
|
||||
type: '基础',
|
||||
isReal: '1',
|
||||
orderNum: 1,
|
||||
extraJson: null,
|
||||
children: null,
|
||||
},
|
||||
{
|
||||
createTime: '2025-11-29 16:37:08',
|
||||
updateTime: '2025-12-02 15:42:09',
|
||||
creator: null,
|
||||
updater: null,
|
||||
tenantId: '-1',
|
||||
id: 2,
|
||||
bookId: 46,
|
||||
parentId: 1,
|
||||
name: '极限与连续',
|
||||
knowledgeCode: 'MATH001001',
|
||||
desc: '函数极限的定义与性质,连续函数的特征',
|
||||
level: 2,
|
||||
type: '基础',
|
||||
isReal: '1',
|
||||
orderNum: 1,
|
||||
extraJson: null,
|
||||
children: null,
|
||||
},
|
||||
{
|
||||
createTime: '2025-11-29 16:37:08',
|
||||
updateTime: '2025-12-02 15:42:09',
|
||||
creator: null,
|
||||
updater: null,
|
||||
tenantId: '-1',
|
||||
id: 3,
|
||||
bookId: 46,
|
||||
parentId: 1,
|
||||
name: '导数与微分',
|
||||
knowledgeCode: 'MATH001002',
|
||||
desc: '导数的定义、计算方法及应用',
|
||||
level: 2,
|
||||
type: '基础',
|
||||
isReal: '1',
|
||||
orderNum: 2,
|
||||
extraJson: null,
|
||||
children: null,
|
||||
},
|
||||
{
|
||||
createTime: '2025-11-29 16:37:08',
|
||||
updateTime: '2025-12-02 15:42:09',
|
||||
creator: null,
|
||||
updater: null,
|
||||
tenantId: '-1',
|
||||
id: 4,
|
||||
bookId: 46,
|
||||
parentId: 1,
|
||||
name: '积分学',
|
||||
knowledgeCode: 'MATH001003',
|
||||
desc: '不定积分与定积分的计算方法',
|
||||
level: 2,
|
||||
type: '基础',
|
||||
isReal: '1',
|
||||
orderNum: 3,
|
||||
extraJson: null,
|
||||
children: null,
|
||||
},
|
||||
];
|
||||
const CreateResourceModal = (props: ModalPropsType) => {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, onCancel, bookId, resourceId, isEdit } = props;
|
||||
const { isOpen, onCancel, bookId, resourceId, isEdit, typeOptions } = props;
|
||||
const [form] = Form.useForm();
|
||||
const [spinInit, setSpinInit] = useState<boolean>(false);
|
||||
const [knowledgeOption, setKnowledgeOption] = useState<
|
||||
{
|
||||
label: string;
|
||||
value: number;
|
||||
}[]
|
||||
>([]);
|
||||
const [fileSize, setFileSize] = useState<number>(0);
|
||||
const [fileName, setFileName] = useState<string>('');
|
||||
const [filePath, setFilePath] = useState<string>('');
|
||||
const [fileExtension, setExtension] = useState<string>('');
|
||||
const [knowledgeCode, setKnowledgeCode] = useState<string>('');
|
||||
const [spinInit, setSpinInit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSpinInit(true);
|
||||
if (isEdit && resourceId) {
|
||||
getDetail();
|
||||
} else {
|
||||
form.resetFields();
|
||||
setSpinInit(false);
|
||||
}
|
||||
}, [isEdit, resourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
getKnowledgeOptionData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
form.resetFields();
|
||||
setFileName('');
|
||||
setFilePath('');
|
||||
setFileSize(0);
|
||||
setKnowledgeCode('');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getKnowledgeOptionData = () => {
|
||||
getKnowledgeListApi(bookId).then((res: any) => {
|
||||
const resData: any = res.data;
|
||||
const knowledgeOption: { label: string; value: number }[] = defaultData.map(
|
||||
(item: { name: string; id: number }) => ({
|
||||
label: `${item.name}`, // 如:"极限与连续 (基础)" (${item.type})
|
||||
value: item.id,
|
||||
})
|
||||
);
|
||||
setKnowledgeOption(knowledgeOption);
|
||||
});
|
||||
};
|
||||
}, [form, isEdit]);
|
||||
|
||||
const getDetail = () => {
|
||||
GetDetailApi(resourceId)
|
||||
.then((res: any) => {
|
||||
if (!res || !res.data) {
|
||||
message.error('获取详情失败');
|
||||
setSpinInit(false);
|
||||
return;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
message.error('获取详情失败');
|
||||
setSpinInit(false);
|
||||
console.error('获取详情失败:', err);
|
||||
});
|
||||
/* if () {
|
||||
form.setFieldsValue({
|
||||
total_progress: hour.duration,
|
||||
});
|
||||
}*/
|
||||
setSpinInit(false);
|
||||
};
|
||||
|
||||
const onFinish = (values: any) => {
|
||||
const { name = '', txtDesc = '', knowledgeCode = '' } = values;
|
||||
console.log('表单提交:', values);
|
||||
|
||||
const { name = '', desc = '', chapterId = '', type = '' } = values;
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
UpdateResourceItemApi(
|
||||
resourceId,
|
||||
bookId,
|
||||
name,
|
||||
knowledgeCode,
|
||||
txtDesc,
|
||||
fileExtension,
|
||||
fileSize,
|
||||
filePath
|
||||
/*UpdateTextbookApi(
|
||||
editId,
|
||||
values.title,
|
||||
thumb,
|
||||
values.short_desc,
|
||||
values.author,
|
||||
values.major,
|
||||
dep_ids,
|
||||
group_ids,
|
||||
user_ids,
|
||||
values.publish_time,
|
||||
values.publish_unit,
|
||||
values.create_time,
|
||||
values.isbn
|
||||
)
|
||||
.then((res: any) => {
|
||||
setSpinInit(false);
|
||||
setLoading(false);
|
||||
message.success(t('commen.saveSuccess'));
|
||||
onCancel();
|
||||
})
|
||||
.catch((e) => {
|
||||
setSpinInit(false);
|
||||
});
|
||||
setLoading(false);
|
||||
});*/
|
||||
} else {
|
||||
console.log(bookId, name, knowledgeCode, txtDesc, fileExtension, fileSize, filePath);
|
||||
AddResourceItemApi(bookId, name, knowledgeCode, txtDesc, fileExtension, fileSize, filePath)
|
||||
/* UpdateTextbookApi(
|
||||
editId,
|
||||
values.title,
|
||||
thumb,
|
||||
values.short_desc,
|
||||
values.author,
|
||||
values.major,
|
||||
dep_ids,
|
||||
group_ids,
|
||||
user_ids,
|
||||
values.publish_time,
|
||||
values.publish_unit,
|
||||
values.create_time,
|
||||
values.isbn
|
||||
)
|
||||
.then((res: any) => {
|
||||
setSpinInit(false);
|
||||
setLoading(false);
|
||||
message.success(t('commen.saveSuccess'));
|
||||
onCancel();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e, 'error');
|
||||
setSpinInit(false);
|
||||
});
|
||||
setLoading(false);
|
||||
});*/
|
||||
}
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
@ -207,14 +99,20 @@ const CreateResourceModal = (props: ModalPropsType) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (e: any) => {
|
||||
console.log(e, '>>.select e');
|
||||
setKnowledgeCode(e.join(','));
|
||||
};
|
||||
|
||||
const onFinishFailed = (errorInfo: any) => {
|
||||
console.log('Failed:', errorInfo);
|
||||
};
|
||||
//弹窗确认
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await form.validateFields(); // 手动触发表单验证
|
||||
await onFinish(form.getFieldsValue()); // 调用 onFinish 并传递当前表单的值
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
} finally {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@ -247,75 +145,45 @@ const CreateResourceModal = (props: ModalPropsType) => {
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="txtDesc"
|
||||
name="name"
|
||||
label={t('textbook.resource.title1')}
|
||||
rules={[{ required: true, message: t('textbook.resource.title1Placeholder') }]}
|
||||
>
|
||||
<Input allowClear placeholder={t('textbook.resource.title1Placeholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="desc"
|
||||
label={t('textbook.resource.desc')}
|
||||
rules={[{ required: true, message: t('textbook.resource.descPlaceholder') }]}
|
||||
>
|
||||
<TextArea allowClear placeholder={t('textbook.resource.descPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="path"
|
||||
label={t('textbook.resource.upload')}
|
||||
rules={[{ required: true, message: t('textbook.resource.uploadPlaceholder') }]}
|
||||
name="chapterId"
|
||||
label={t('textbook.resource.chapter')}
|
||||
rules={[{ required: true, message: t('textbook.resource.chapterPlaceholder') }]}
|
||||
>
|
||||
<DraggerUpload
|
||||
currentCount={0}
|
||||
maxCount={1}
|
||||
onUploadSuccess={(fileInfo: {
|
||||
type: any;
|
||||
fileExtension: any;
|
||||
fileSize: any;
|
||||
filePath: any;
|
||||
fileName: any;
|
||||
}) => {
|
||||
// 类型: fileInfo.type, // "image/jpeg"
|
||||
// 扩展名: fileInfo.fileExtension, // "jpg"
|
||||
// 大小: fileInfo.fileSize, // 102400
|
||||
// 路径: fileInfo.filePath, // "/uploads/2024/01/abc123.jpg"
|
||||
// 文件名: fileInfo.fileName, // "example.jpg"
|
||||
form.setFieldsValue({
|
||||
name: fileInfo.fileName,
|
||||
path: fileInfo.filePath,
|
||||
});
|
||||
setFileSize(fileInfo.fileSize);
|
||||
setFileName(fileInfo.fileName);
|
||||
setFilePath(fileInfo.filePath);
|
||||
setExtension(fileInfo.fileExtension);
|
||||
}}
|
||||
onUploadError={(error) => {
|
||||
console.error('上传失败:', error);
|
||||
}}
|
||||
<Input allowClear placeholder={t('textbook.resource.chapterPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label={t('textbook.resource.type')}
|
||||
rules={[{ required: true, message: t('textbook.resource.typePlaceholder') }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('textbook.resource.typePlaceholder')}
|
||||
options={typeOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{fileName && (
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('textbook.resource.title1')}
|
||||
rules={[{ required: true, message: t('textbook.resource.title1Placeholder') }]}
|
||||
>
|
||||
<Input allowClear placeholder={t('textbook.resource.title1Placeholder')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="knowledge"
|
||||
label={t('textbook.resource.knowledgeId')}
|
||||
rules={[{ required: true, message: t('textbook.resource.knowledgePlaceholder') }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t('textbook.resource.knowledgePlaceholder')}
|
||||
defaultValue={knowledgeCode ? [knowledgeCode] : []}
|
||||
onChange={handleSelectChange}
|
||||
style={{ width: '100%' }}
|
||||
options={knowledgeOption}
|
||||
showSearch
|
||||
//@ts-ignore
|
||||
filterOption={(input: string, option: { label: string; value: number }) =>
|
||||
(option?.label ?? '')?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Dragger {...props}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">{t('textbook.resource.uploadTips')}</p>
|
||||
<p className="ant-upload-hint">{t('textbook.resource.uploadTips2')}</p>
|
||||
</Dragger>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Empty } from 'antd';
|
||||
|
||||
interface ContentPreviewProps {
|
||||
content?: string;
|
||||
height?: number | string;
|
||||
backgroundColor?: string;
|
||||
emptyText?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
const ContentPreview: React.FC<ContentPreviewProps> = ({
|
||||
content,
|
||||
height = 590,
|
||||
backgroundColor = '#fafafa',
|
||||
emptyText = '暂无内容',
|
||||
emptyDescription = '请在编辑器中添加内容',
|
||||
}) => {
|
||||
const hasContent = content && content !== '<p><br></p>' && content !== '<p></p>';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
borderRadius: '6px',
|
||||
padding: '20px',
|
||||
boxSizing: 'border-box',
|
||||
backgroundColor,
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
{hasContent ? (
|
||||
<div style={{ width: '100%' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
<Empty description={false} />
|
||||
</div>
|
||||
<div style={{ marginBottom: '8px', fontWeight: 500 }}>{emptyText}</div>
|
||||
<div style={{ fontSize: '14px', color: '#bfbfbf' }}>{emptyDescription}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentPreview;
|
||||
@ -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,252 @@
|
||||
// components/EnhancedToolbar.tsx
|
||||
import React from 'react';
|
||||
import { Editor, useEditorState } from '@tiptap/react';
|
||||
import { EditorState } from '../../../../types/editor';
|
||||
import {
|
||||
AlignCenterOutlined,
|
||||
AlignLeftOutlined,
|
||||
AlignRightOutlined,
|
||||
BoldOutlined,
|
||||
ItalicOutlined,
|
||||
LineOutlined,
|
||||
MenuOutlined,
|
||||
OrderedListOutlined,
|
||||
StrikethroughOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
|
||||
interface EnhancedToolbarProps {
|
||||
editor: Editor;
|
||||
onFileUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
const EnhancedToolbar: React.FC<EnhancedToolbarProps> = ({ editor, onFileUpload }) => {
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx): EditorState => {
|
||||
// 添加需要的功能
|
||||
return {
|
||||
isBold: ctx.editor.isActive('bold') ?? false,
|
||||
canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
|
||||
isItalic: ctx.editor.isActive('italic') ?? false,
|
||||
canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
|
||||
isStrike: ctx.editor.isActive('strike') ?? false,
|
||||
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
|
||||
isCode: ctx.editor.isActive('code') ?? false,
|
||||
canCode: ctx.editor.can().chain().toggleCode().run() ?? false,
|
||||
canClearMarks: ctx.editor.can().chain().unsetAllMarks().run() ?? false,
|
||||
isParagraph: ctx.editor.isActive('paragraph') ?? false,
|
||||
isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
|
||||
isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
|
||||
isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
|
||||
isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
|
||||
isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
|
||||
isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
|
||||
isBulletList: ctx.editor.isActive('bulletList') ?? false,
|
||||
isOrderedList: ctx.editor.isActive('orderedList') ?? false,
|
||||
isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
|
||||
isBlockquote: ctx.editor.isActive('blockquote') ?? false,
|
||||
canUndo: ctx.editor.can().chain().undo().run() ?? false,
|
||||
canRedo: ctx.editor.can().chain().redo().run() ?? false,
|
||||
isHighlight: ctx.editor.isActive('highlight') ?? false,
|
||||
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
|
||||
isTextAlignLeft: ctx.editor.isActive({ textAlign: 'left' }) ?? false,
|
||||
isTextAlignCenter: ctx.editor.isActive({ textAlign: 'center' }) ?? false,
|
||||
isTextAlignRight: ctx.editor.isActive({ textAlign: 'right' }) ?? false,
|
||||
isTextAlignJustify: ctx.editor.isActive({ textAlign: 'justify' }) ?? false,
|
||||
};
|
||||
},
|
||||
});
|
||||
// 编辑顶部按钮
|
||||
const toolbarSections = [
|
||||
{
|
||||
name: 'format',
|
||||
buttons: [
|
||||
{
|
||||
icon: <BoldOutlined />,
|
||||
title: '粗体',
|
||||
action: () => editor.chain().focus().toggleBold().run(),
|
||||
disabled: !editorState.canBold,
|
||||
active: editorState.isBold,
|
||||
},
|
||||
{
|
||||
icon: <ItalicOutlined />,
|
||||
title: '斜体',
|
||||
action: () => editor.chain().focus().toggleItalic().run(),
|
||||
disabled: !editorState.canItalic,
|
||||
active: editorState.isItalic,
|
||||
},
|
||||
{
|
||||
icon: <StrikethroughOutlined />,
|
||||
title: '删除线',
|
||||
action: () => editor.chain().focus().toggleStrike().run(),
|
||||
disabled: !editorState.canStrike,
|
||||
active: editorState.isStrike,
|
||||
},
|
||||
/*{
|
||||
icon: '</>',
|
||||
title: '行内代码',
|
||||
action: () => editor.chain().focus().toggleCode().run(),
|
||||
disabled: !editorState.canCode,
|
||||
active: editorState.isCode,
|
||||
},*/
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'heading',
|
||||
buttons: [
|
||||
/*{
|
||||
icon: '正文',
|
||||
title: '正文',
|
||||
action: () => editor.chain().focus().setParagraph().run(),
|
||||
disabled: false,
|
||||
active: editorState.isParagraph,
|
||||
},*/
|
||||
{
|
||||
icon: 'H1',
|
||||
title: '标题1',
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
disabled: false,
|
||||
active: editorState.isHeading1,
|
||||
},
|
||||
{
|
||||
icon: 'H2',
|
||||
title: '标题2',
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
disabled: false,
|
||||
active: editorState.isHeading2,
|
||||
},
|
||||
{
|
||||
icon: 'H3',
|
||||
title: '标题3',
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
disabled: false,
|
||||
active: editorState.isHeading3,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'alignment',
|
||||
buttons: [
|
||||
{
|
||||
icon: <AlignLeftOutlined />,
|
||||
title: '左对齐',
|
||||
action: () => editor.chain().focus().setTextAlign('left').run(),
|
||||
disabled: false,
|
||||
active: editorState.isTextAlignLeft,
|
||||
},
|
||||
{
|
||||
icon: <AlignCenterOutlined />,
|
||||
title: '居中对齐',
|
||||
action: () => editor.chain().focus().setTextAlign('center').run(),
|
||||
disabled: false,
|
||||
active: editorState.isTextAlignCenter,
|
||||
},
|
||||
{
|
||||
icon: <AlignRightOutlined />,
|
||||
title: '右对齐',
|
||||
action: () => editor.chain().focus().setTextAlign('right').run(),
|
||||
disabled: false,
|
||||
active: editorState.isTextAlignRight,
|
||||
},
|
||||
{
|
||||
icon: <MenuOutlined />,
|
||||
title: '两端对齐',
|
||||
action: () => editor.chain().focus().setTextAlign('justify').run(),
|
||||
active: editorState.isTextAlignJustify,
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
buttons: [
|
||||
{
|
||||
icon: <UnorderedListOutlined />,
|
||||
title: '无序列表',
|
||||
action: () => editor.chain().focus().toggleBulletList().run(),
|
||||
disabled: false,
|
||||
active: editorState.isBulletList,
|
||||
},
|
||||
{
|
||||
icon: <OrderedListOutlined />,
|
||||
title: '有序列表',
|
||||
action: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
active: editorState.isOrderedList,
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'block',
|
||||
buttons: [
|
||||
{
|
||||
icon: '❝',
|
||||
title: '引用块',
|
||||
action: () => editor.chain().focus().toggleBlockquote().run(),
|
||||
active: editorState.isBlockquote,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: '</>',
|
||||
title: '代码块',
|
||||
action: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||
active: editorState.isCodeBlock,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: <LineOutlined />,
|
||||
title: '分割线',
|
||||
action: () => editor.chain().focus().setHorizontalRule().run(),
|
||||
disabled: false,
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
buttons: [
|
||||
{
|
||||
icon: '↶',
|
||||
title: '撤销',
|
||||
action: () => editor.chain().focus().undo().run(),
|
||||
disabled: !editorState.canUndo,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
icon: '↷',
|
||||
title: '重做',
|
||||
action: () => editor.chain().focus().redo().run(),
|
||||
disabled: !editorState.canRedo,
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="enhanced-toolbar">
|
||||
{toolbarSections.map((section, sectionIndex) => (
|
||||
<div key={section.name} className="toolbar-section">
|
||||
{sectionIndex > 0 && <div className="section-divider" />}
|
||||
<div className="section-buttons">
|
||||
{section?.buttons?.map((button, buttonIndex) => (
|
||||
<Button
|
||||
key={`${section.name}-${buttonIndex}`}
|
||||
onClick={button.action}
|
||||
disabled={button?.disabled || false}
|
||||
className={`toolbar-button ${button?.active ? 'is-active' : ''}`}
|
||||
title={button.title}
|
||||
>
|
||||
{button.icon}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedToolbar;
|
||||
@ -82,7 +82,7 @@
|
||||
|
||||
/*编辑区域*/
|
||||
.editor-main-box{
|
||||
background: #ffffff;
|
||||
background: #8F8F8F;
|
||||
position: relative;
|
||||
margin: 20px;
|
||||
height: 590px;
|
||||
|
||||
@ -1,55 +1,53 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { EditorProps } from '../../../../types/editor';
|
||||
import './TextbookEditor.less';
|
||||
import { Button, Drawer, Empty, Modal, Spin } from 'antd';
|
||||
import { EditFilled, EyeFilled, ForkOutlined, SaveFilled } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { EyeFilled, ForkOutlined, SaveFilled } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
import { IDomEditor, IToolbarConfig } from '@wangeditor/editor';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||
import { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import { DomEditor } from '@wangeditor/editor';
|
||||
import { GetChapterContentApi } from '../../../../api/textbook';
|
||||
import EditorComponent from '../EditorCompenent/EditorCompenent';
|
||||
import ContentPreview from './ContentPreview';
|
||||
import { getTenant, getToken } from '../../../../utils';
|
||||
import config from '../../../../js/config';
|
||||
|
||||
const TextbookEditor: React.FC<EditorProps> = ({
|
||||
chapterId,
|
||||
parentTitle,
|
||||
chapterTitle,
|
||||
initialContent = '',
|
||||
onSave,
|
||||
bookId,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
// editor 实例
|
||||
const [editor, setEditor] = useState<IDomEditor | null>(null);
|
||||
const [editor, setEditor] = useState<IDomEditor | null>(null); // TS 语法
|
||||
|
||||
// 编辑器内容
|
||||
const [html, setHtml] = useState<string>();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [isPreviewModalShow, setIsPreviewModalShow] = useState<boolean>(false);
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {};
|
||||
toolbarConfig.excludeKeys = ['fullScreen', 'emotion']; //移除不想要的fullScreen
|
||||
const [html, setHtml] = useState('<p>hello</p>');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => {
|
||||
getContent();
|
||||
}, 2000);
|
||||
if (editor && initialContent !== editor.getHtml()) {
|
||||
editor.insertText(
|
||||
// 添加内容
|
||||
initialContent ||
|
||||
`
|
||||
<h2>${chapterTitle}</h2>
|
||||
<p>开始编写本章节的内容...</p>
|
||||
`
|
||||
);
|
||||
setIsEditing(false);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [chapterTitle, chapterId]);
|
||||
// 更新统计
|
||||
// const text = editor.getText();
|
||||
// setWordCount(text.split(/\s+/).filter((word) => word.length > 0).length);
|
||||
}
|
||||
}, [initialContent, editor, chapterTitle]);
|
||||
|
||||
const getContent = () => {
|
||||
GetChapterContentApi(Number(chapterId), bookId).then((res: any) => {
|
||||
setHtml(res?.data?.content || null);
|
||||
setLoading(false);
|
||||
});
|
||||
// 工具栏配置
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {}; // TS 语法
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: '请输入内容...',
|
||||
};
|
||||
// 更新统计
|
||||
// const text = editor.getText();
|
||||
// setWordCount(text.split(/\s+/).filter((word) => word.length > 0).length);
|
||||
// }
|
||||
|
||||
/*仅用于本地开发 查看按钮*/
|
||||
if (editor) {
|
||||
@ -58,22 +56,24 @@ const TextbookEditor: React.FC<EditorProps> = ({
|
||||
console.log(curToolbarConfig?.toolbarKeys);
|
||||
}
|
||||
|
||||
toolbarConfig.excludeKeys = ['fullScreen']; //移除不想要的fullScreen
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editor == null) return;
|
||||
editor.destroy();
|
||||
setEditor(null);
|
||||
window.localStorage.removeItem('html');
|
||||
};
|
||||
}, [chapterId, chapterTitle, editor]);
|
||||
}, [chapterId, initialContent, chapterTitle, editor]);
|
||||
|
||||
// 手动保存
|
||||
const handleManualSave = useCallback(() => {
|
||||
if (editor) {
|
||||
onSave(html);
|
||||
if (editor && isEditing) {
|
||||
onSave(chapterId, html);
|
||||
// setLastSaved(new Date());
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [editor, chapterId, isEditing, onSave, html]);
|
||||
}, [editor, chapterId, isEditing, onSave]);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date: Date | null): string => {
|
||||
@ -86,123 +86,69 @@ const TextbookEditor: React.FC<EditorProps> = ({
|
||||
|
||||
// 预览
|
||||
const handlePreview = () => {
|
||||
setIsPreviewModalShow(true);
|
||||
alert('preview');
|
||||
};
|
||||
|
||||
// 在编辑器创建完成后关闭 loading
|
||||
const handleEditorCreated = (editorInstance: any) => {
|
||||
setEditor(editorInstance);
|
||||
};
|
||||
|
||||
const handleContentChange = useCallback((newHtml: string) => {
|
||||
setHtml(newHtml);
|
||||
window.localStorage.setItem('html', newHtml);
|
||||
}, []);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="enhanced-textbook-editor">
|
||||
{loading ? (
|
||||
<div style={{ width: '100%', height: 600, textAlign: 'center', lineHeight: '600px' }}>
|
||||
<Spin size="large" tip="Loading..." style={{ color: '#1890ff' }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="editor-header">
|
||||
<div className="header-left">
|
||||
<h2 className="chapter-title">{chapterTitle}</h2>
|
||||
<span className="chapter-id">所属章节: {parentTitle}</span>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<Button
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={handlePreview}
|
||||
disabled={!html || html == '<p><br></p>' || html == '<p></p>'}
|
||||
>
|
||||
<EyeFilled />
|
||||
{t('textbook.resource.btnPreview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={() => {
|
||||
alert('知识图谱');
|
||||
}}
|
||||
disabled={true}
|
||||
>
|
||||
<ForkOutlined />
|
||||
{t('textbook.resource.knowledge')}
|
||||
</Button>
|
||||
|
||||
{isEditing ? (
|
||||
<Button type="primary" style={{ marginRight: 5 }} onClick={handleManualSave}>
|
||||
<SaveFilled />
|
||||
{t('textbook.resource.btnSave')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginRight: 5 }}
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<EditFilled />
|
||||
{t('textbook.resource.btnEdit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-main-box">
|
||||
{isEditing ? (
|
||||
<EditorComponent
|
||||
editor={editor}
|
||||
toolbarConfig={toolbarConfig}
|
||||
value={html}
|
||||
onChange={handleContentChange}
|
||||
onCreated={handleEditorCreated}
|
||||
height={550}
|
||||
uploadConfig={{
|
||||
getToken: () => getToken(),
|
||||
getTenant: () => getTenant(),
|
||||
uploadUrl: config.app_url,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ContentPreview
|
||||
content={html}
|
||||
height={550}
|
||||
emptyText="暂无内容"
|
||||
emptyDescription="请在编辑器中添加内容"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="enhanced-textbook-editor">
|
||||
{/* 编辑器头部 */}
|
||||
<div className="editor-header">
|
||||
<div className="header-left">
|
||||
<h2 className="chapter-title">{chapterTitle}</h2>
|
||||
<span className="chapter-id">所属章节: {chapterId}</span>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<Button style={{ marginRight: 10 }} onClick={handlePreview}>
|
||||
<EyeFilled />
|
||||
{t('textbook.resource.btnPreview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={() => {
|
||||
alert('知识图谱');
|
||||
}}
|
||||
disabled={true}
|
||||
>
|
||||
<ForkOutlined />
|
||||
{t('textbook.resource.knowledge')}
|
||||
</Button>
|
||||
<Button type="primary" style={{ marginRight: 5 }} onClick={handleManualSave}>
|
||||
<SaveFilled />
|
||||
{t('textbook.resource.btnSave')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Drawer
|
||||
key={'previewModal'}
|
||||
title={t('textbook.resource.btnPreview')}
|
||||
onClose={() => setIsPreviewModalShow(false)}
|
||||
width={1000}
|
||||
open={isPreviewModalShow}
|
||||
footer={null}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 'auto',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
margin: '10px',
|
||||
width: 'auto',
|
||||
backgroundColor: '#fafafa',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
// @ts-ignore
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
|
||||
<div className="editor-main-box">
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
defaultConfig={toolbarConfig}
|
||||
mode="default"
|
||||
style={{ borderBottom: '1px solid #ccc' }}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
<Editor
|
||||
defaultConfig={editorConfig}
|
||||
value={html}
|
||||
onCreated={setEditor}
|
||||
onChange={(editor) => setHtml(editor.getHtml())}
|
||||
mode="default"
|
||||
style={{ height: '550px', overflowY: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,397 +0,0 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Upload, message, Progress, List, Button, Space } from 'antd';
|
||||
import { InboxOutlined, CloseOutlined, FileOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { formatFileSize, getTenant, getToken } from '../../../../utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import config from '../../../../js/config';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface IProps {
|
||||
typeId?: number; // 1:视频, 2:图片, 3:文档, 4:音频, 5:其他
|
||||
currentCount?: number;
|
||||
maxCount?: number;
|
||||
onUploadSuccess?: (fileInfo: {
|
||||
type: string;
|
||||
fileExtension: string;
|
||||
fileSize: number;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
}) => void;
|
||||
onUploadError?: (error: any) => void;
|
||||
}
|
||||
|
||||
interface UploadFileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
progress: number;
|
||||
status: 'uploading' | 'success' | 'error' | 'cancelled';
|
||||
file: File;
|
||||
xhr?: XMLHttpRequest;
|
||||
}
|
||||
|
||||
// 根据类型ID获取对应的文件类型配置
|
||||
// const getFileTypeConfig = (typeId: number) => {
|
||||
// const configs = {
|
||||
// 1: {
|
||||
// // 视频
|
||||
// accept: '.mp4,.mov,.avi,.wmv,.flv,.rmvb',
|
||||
// },
|
||||
// 2: {
|
||||
// // 图片
|
||||
// accept: '.png,.jpeg,.jpg,.gif',
|
||||
// },
|
||||
// 3: {
|
||||
// // 文档
|
||||
// accept: '.pdf,.doc,.docx,.ppt,.pptx',
|
||||
// },
|
||||
// 4: {
|
||||
// // 音频
|
||||
// accept: '.mp3,.aac,.wma,.wav',
|
||||
// },
|
||||
// 5: {
|
||||
// // 其他
|
||||
// accept: '*',
|
||||
// },
|
||||
// };
|
||||
//
|
||||
// return configs[typeId as keyof typeof configs] || configs[5];
|
||||
// };
|
||||
|
||||
// 根据类型ID获取模块类型
|
||||
// const getModuleType = (typeId: number): string => {
|
||||
// const types = { 1: 'VIDEO', 2: 'IMAGE', 3: 'DOCUMENT', 4: 'AUDIO', 5: 'OTHER' };
|
||||
// return types[typeId as keyof typeof types] || 'OTHER';
|
||||
// };
|
||||
|
||||
export const DraggerUpload = (props: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([]);
|
||||
const xhrRef = useRef<Map<string, XMLHttpRequest>>(new Map());
|
||||
|
||||
const { typeId, currentCount = 0, maxCount = 1, onUploadSuccess, onUploadError } = props;
|
||||
// const fileTypeConfig = getFileTypeConfig(typeId);
|
||||
const isLimitReached = maxCount > 0 && currentCount >= maxCount;
|
||||
|
||||
// 获取文件扩展名
|
||||
const getFileExtension = (fileName: string): string => {
|
||||
return fileName.split('.').pop()?.toLowerCase() || '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUploadFiles([]);
|
||||
// 清理所有正在进行的上传
|
||||
xhrRef.current.forEach((xhr) => {
|
||||
xhr.abort();
|
||||
});
|
||||
xhrRef.current.clear();
|
||||
}, [typeId]);
|
||||
|
||||
// 检查文件类型(仅对其他类型做限制)
|
||||
const checkFileType = (file: File): boolean => {
|
||||
if (typeId && typeId !== 5) return true;
|
||||
const extension = getFileExtension(file.name);
|
||||
const restrictedExtensions = [
|
||||
'mp4',
|
||||
'mov',
|
||||
'avi',
|
||||
'wmv',
|
||||
'flv',
|
||||
'rmvb',
|
||||
'mp3',
|
||||
'aac',
|
||||
'wma',
|
||||
'wav',
|
||||
'png',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'gif',
|
||||
'docx',
|
||||
'pptx',
|
||||
'pdf',
|
||||
'doc',
|
||||
];
|
||||
|
||||
if (extension && restrictedExtensions.includes(extension)) {
|
||||
message.error(t('textbook.resource.restrictedFileType'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 更新文件上传进度
|
||||
const updateFileProgress = (
|
||||
fileId: string,
|
||||
progress: number,
|
||||
status: UploadFileItem['status'] = 'uploading'
|
||||
) => {
|
||||
setUploadFiles((prev) =>
|
||||
prev.map((item) => (item.id === fileId ? { ...item, progress, status } : item))
|
||||
);
|
||||
};
|
||||
|
||||
// 移除上传文件
|
||||
const removeUploadFile = (fileId: string) => {
|
||||
const fileItem = uploadFiles.find((item) => item.id === fileId);
|
||||
|
||||
// 如果文件正在上传,先取消上传
|
||||
if (fileItem?.status === 'uploading') {
|
||||
cancelUpload(fileId);
|
||||
}
|
||||
|
||||
setUploadFiles((prev) => prev.filter((item) => item.id !== fileId));
|
||||
xhrRef.current.delete(fileId);
|
||||
};
|
||||
|
||||
// 取消上传
|
||||
const cancelUpload = (fileId: string) => {
|
||||
const xhr = xhrRef.current.get(fileId);
|
||||
if (xhr) {
|
||||
xhr.abort();
|
||||
xhrRef.current.delete(fileId);
|
||||
updateFileProgress(fileId, 0, 'cancelled');
|
||||
message.info(t('commen.uploadCancelled'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文件上传
|
||||
const handleCustomRequest: UploadProps['customRequest'] = async (options) => {
|
||||
const { file, onSuccess, onError, onProgress } = options;
|
||||
|
||||
const fileId = Math.random().toString(36).substr(2, 9);
|
||||
const uploadFile: UploadFileItem = {
|
||||
id: fileId,
|
||||
name: (file as File).name,
|
||||
size: (file as File).size,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
file: file as File,
|
||||
};
|
||||
|
||||
setUploadFiles((prev) => [...prev, uploadFile]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as File);
|
||||
// formData.append('module', getModuleType(typeId));
|
||||
formData.append('duration', '0');
|
||||
|
||||
let appUrl = config.app_url.replace(/\/+$/, '');
|
||||
if (!appUrl.startsWith('http')) {
|
||||
appUrl = `${window.location.protocol}//${window.location.host}${appUrl.startsWith('/') ? appUrl : '/' + appUrl}`;
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhrRef.current.set(fileId, xhr);
|
||||
|
||||
// 监听上传进度
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percent = (event.loaded / event.total) * 100;
|
||||
updateFileProgress(fileId, percent);
|
||||
onProgress?.({ percent });
|
||||
}
|
||||
});
|
||||
|
||||
// 监听完成
|
||||
xhr.addEventListener('load', () => {
|
||||
xhrRef.current.delete(fileId);
|
||||
|
||||
if (xhr.status === 200) {
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
response = xhr.responseText;
|
||||
}
|
||||
|
||||
updateFileProgress(fileId, 100, 'success');
|
||||
onSuccess?.(response);
|
||||
message.success(t('commen.uploadSuccess'));
|
||||
|
||||
// 构造返回的文件信息
|
||||
const fileInfo = {
|
||||
type: (file as File).type,
|
||||
fileExtension: getFileExtension((file as File).name),
|
||||
fileSize: (file as File).size,
|
||||
filePath: response?.data?.path || response?.path || '', // 根据实际接口响应调整
|
||||
fileName: (file as File).name,
|
||||
// 如果需要,还可以返回原始响应
|
||||
rawResponse: response,
|
||||
};
|
||||
|
||||
onUploadSuccess?.(fileInfo);
|
||||
} else {
|
||||
throw new Error(`Upload failed with status ${xhr.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听错误
|
||||
xhr.addEventListener('error', () => {
|
||||
xhrRef.current.delete(fileId);
|
||||
|
||||
// 如果是取消操作,不显示错误消息
|
||||
if (xhr.status === 0 && xhr.readyState === 0) {
|
||||
return; // 取消操作,不处理
|
||||
}
|
||||
|
||||
updateFileProgress(fileId, 0, 'error');
|
||||
const error = new Error('Upload failed');
|
||||
onError?.(error);
|
||||
onUploadError?.(error);
|
||||
message.error(t('commen.uploadFailed'));
|
||||
});
|
||||
|
||||
// 监听取消
|
||||
xhr.addEventListener('abort', () => {
|
||||
xhrRef.current.delete(fileId);
|
||||
updateFileProgress(fileId, 0, 'cancelled');
|
||||
// message.info(t('commen.uploadCancelled'));
|
||||
});
|
||||
|
||||
xhr.open('POST', `${appUrl}/backend/v1/localUpload/upload`);
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + getToken());
|
||||
xhr.setRequestHeader('tenant-id', getTenant());
|
||||
xhr.send(formData);
|
||||
} catch (error) {
|
||||
xhrRef.current.delete(fileId);
|
||||
updateFileProgress(fileId, 0, 'error');
|
||||
onError?.(error as Error);
|
||||
onUploadError?.(error);
|
||||
message.error(t('commen.uploadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
name: 'file',
|
||||
multiple: false,
|
||||
// accept: fileTypeConfig.accept,
|
||||
customRequest: handleCustomRequest,
|
||||
showUploadList: false,
|
||||
disabled: isLimitReached || uploadFiles.some((file) => file.status === 'uploading'),
|
||||
beforeUpload: (file) => {
|
||||
if (isLimitReached) {
|
||||
message.error(t('textbook.resource.uploadLimitReached'));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果已经有文件在上传,不允许上传新文件
|
||||
if (uploadFiles.some((f) => f.status === 'uploading')) {
|
||||
message.error(t('commen.uploadInProgress'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeId) {
|
||||
checkFileType(file);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// 获取状态对应的文本和颜色
|
||||
const getStatusInfo = (status: UploadFileItem['status']) => {
|
||||
switch (status) {
|
||||
case 'uploading':
|
||||
return { text: t('commen.uploading'), color: '#1890ff' };
|
||||
case 'success':
|
||||
return { text: t('commen.uploadSuccess'), color: '#52c41a' };
|
||||
case 'error':
|
||||
return { text: t('commen.uploadFailed'), color: '#ff4d4f' };
|
||||
case 'cancelled':
|
||||
return { text: t('commen.uploadCancelled'), color: '#faad14' };
|
||||
default:
|
||||
return { text: '', color: '#000' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dragger {...uploadProps} disabled={uploadFiles.length > 0}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ color: isLimitReached ? '#d9d9d9' : undefined }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{isLimitReached
|
||||
? t('textbook.resource.uploadLimitReached')
|
||||
: uploadFiles.some((f) => f.status === 'uploading')
|
||||
? t('commen.uploadInProgress')
|
||||
: t('textbook.resource.uploadTips')}
|
||||
</p>
|
||||
<p className="ant-upload-hint" style={{ color: isLimitReached ? '#999' : undefined }}>
|
||||
{isLimitReached
|
||||
? t('textbook.resource.uploadLimitReachedDesc')
|
||||
: uploadFiles.some((f) => f.status === 'uploading')
|
||||
? t('commen.waitForUploadComplete')
|
||||
: t('textbook.resource.uploadTips2')}
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* 上传文件列表 */}
|
||||
{uploadFiles.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={uploadFiles}
|
||||
renderItem={(item) => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
return (
|
||||
<List.Item>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Space
|
||||
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}
|
||||
>
|
||||
<Space>
|
||||
<div className={'d-flex'}>
|
||||
<FileOutlined />
|
||||
<div className={'w-174px mr-5 '}>{item.name}</div>
|
||||
<div className={'mr-5 '} style={{ color: '#999', fontSize: '12px' }}>
|
||||
({formatFileSize(item.size)})
|
||||
</div>
|
||||
<div
|
||||
className={'mr-5 '}
|
||||
style={{ color: statusInfo.color, fontSize: '12px' }}
|
||||
>
|
||||
{statusInfo.text}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
<Space>
|
||||
{item.status === 'uploading' && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => cancelUpload(item.id)}
|
||||
>
|
||||
{t('commen.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
size="small"
|
||||
onClick={() => removeUploadFile(item.id)}
|
||||
disabled={item.status === 'uploading'}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
{item.status === 'uploading' && (
|
||||
<Progress
|
||||
percent={item.progress}
|
||||
status="active"
|
||||
size="small"
|
||||
showInfo={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,20 +1,44 @@
|
||||
import { Modal, Form, Input, message, Spin, Cascader } from 'antd';
|
||||
import { Modal, Form, Input, Select, message, Spin, Cascader } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CreateChapterApi, EditChapterApi, GetChapterListApi } from '../../../api/textbook';
|
||||
import {
|
||||
addSortClassApi,
|
||||
getSoftwareClassApi,
|
||||
updateSortClassApi,
|
||||
} from '../../virtual/api/virtual';
|
||||
import { CreateChapterApi, EditChapterApi } from '../../../api/textbook';
|
||||
|
||||
interface Option {
|
||||
value: string | number;
|
||||
label: string;
|
||||
children?: Option[];
|
||||
}
|
||||
interface ChapterItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
level: number;
|
||||
parent_chain: string;
|
||||
parentId: string;
|
||||
sort: number;
|
||||
children?: ChapterItem[];
|
||||
}
|
||||
|
||||
interface ChapterItemModel {
|
||||
created_at: string;
|
||||
id: number;
|
||||
name: string;
|
||||
from_scene: number;
|
||||
parent_chain: string;
|
||||
parentId: number;
|
||||
sort: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ChapterModalProps {
|
||||
visible: boolean;
|
||||
bookId: number;
|
||||
isEdit: boolean;
|
||||
editData?: any;
|
||||
parentData?: any;
|
||||
onCancel: () => void;
|
||||
confirmLoading?: boolean;
|
||||
onSuccess: () => void;
|
||||
@ -25,7 +49,6 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
bookId,
|
||||
isEdit,
|
||||
editData,
|
||||
parentData,
|
||||
onCancel,
|
||||
confirmLoading = false,
|
||||
onSuccess,
|
||||
@ -33,169 +56,32 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [init, setInit] = useState(true);
|
||||
const [chapters, setChapters] = useState<any>([]);
|
||||
|
||||
const [parentId, setParentId] = useState<number>(0);
|
||||
const [editingId, setEditingId] = useState<number>(0); // 存储正在编辑的ID
|
||||
|
||||
// 根据 parentData 生成固定的章节目录选项
|
||||
const generateFixedChapterOptions = (parentData: any): Option[] => {
|
||||
if (!parentData) {
|
||||
return [
|
||||
{
|
||||
label: t('commen.levelCate'),
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
const fixedOptions: Option[] = [
|
||||
{
|
||||
label: parentData.name,
|
||||
value: parentData.id,
|
||||
},
|
||||
];
|
||||
|
||||
fixedOptions.unshift({
|
||||
label: t('commen.levelCate'),
|
||||
value: 0,
|
||||
});
|
||||
return fixedOptions;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
form.resetFields();
|
||||
if (parentData && parentData.id) {
|
||||
setParentId(parentData.id);
|
||||
}
|
||||
// 如果是编辑模式,设置编辑ID
|
||||
setInit(true);
|
||||
if (isEdit && editData) {
|
||||
setEditingId(editData.id);
|
||||
}
|
||||
getChapterParams();
|
||||
}, [parentData, isEdit, editData]);
|
||||
|
||||
const checkArr = (
|
||||
categories: any[],
|
||||
id: number,
|
||||
currentLevel: number = 1,
|
||||
maxLevel: number = 2,
|
||||
editingId?: number // 添加编辑ID参数
|
||||
) => {
|
||||
const arr: { label: any; value: any; children?: Option[] }[] = [];
|
||||
|
||||
if (currentLevel > maxLevel) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
for (let i = 0; i < categories[id].length; i++) {
|
||||
const currentItem = categories[id][i];
|
||||
const currentItemId = currentItem.id;
|
||||
|
||||
// 排除编辑数据本身
|
||||
if (editingId && currentItemId === editingId) {
|
||||
continue;
|
||||
}
|
||||
// 检查当前项是否包含编辑数据(避免选择子集)
|
||||
const isEditingDataChild = editingId && hasRealChild(categories, currentItemId, editingId);
|
||||
|
||||
if (!categories[currentItemId] || currentLevel === maxLevel || isEditingDataChild) {
|
||||
arr.push({
|
||||
label: currentItem.name,
|
||||
value: currentItemId,
|
||||
});
|
||||
} else {
|
||||
const new_arr: Option[] = checkArr(
|
||||
categories,
|
||||
currentItemId,
|
||||
currentLevel + 1,
|
||||
maxLevel,
|
||||
editingId
|
||||
);
|
||||
arr.push({
|
||||
label: currentItem.name,
|
||||
value: currentItemId,
|
||||
children: new_arr,
|
||||
});
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
const hasRealChild = (categories: any[], parentId: number, targetId: number): boolean => {
|
||||
if (!categories[parentId]) return false;
|
||||
|
||||
for (let i = 0; i < categories[parentId].length; i++) {
|
||||
const childId = categories[parentId][i].id;
|
||||
|
||||
// 跳过直接匹配(编辑数据本身)
|
||||
if (childId === targetId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查子节点是否匹配
|
||||
if (childId === targetId) {
|
||||
return true;
|
||||
}
|
||||
// 递归检查孙节点
|
||||
if (hasRealChild(categories, childId, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const getChapterParams = () => {
|
||||
// 使用固定的章节目录
|
||||
if (parentData) {
|
||||
const fixedOptions = generateFixedChapterOptions(parentData);
|
||||
setChapters(fixedOptions);
|
||||
form.setFieldsValue(editData);
|
||||
} else {
|
||||
form.resetFields();
|
||||
let defaultLevel = 1;
|
||||
form.setFieldsValue({
|
||||
parentId: parentData.id,
|
||||
level: defaultLevel,
|
||||
...editData,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
GetChapterListApi({ bookId }).then((res: any) => {
|
||||
const chapters = res.data.chapters;
|
||||
if (JSON.stringify(chapters) !== '{}') {
|
||||
const editingId = isEdit && editData ? editData.id : undefined;
|
||||
const new_arr: Option[] = checkArr(chapters, 0, 1, 2, editingId);
|
||||
new_arr.unshift({
|
||||
label: t('commen.levelCate'),
|
||||
value: 0,
|
||||
});
|
||||
setChapters(new_arr);
|
||||
|
||||
if (isEdit && editData) {
|
||||
const arr = editData.chapterCode.split(',');
|
||||
const p_arr: any[] = [];
|
||||
arr.map((num: any) => {
|
||||
p_arr.push(Number(num));
|
||||
});
|
||||
form.setFieldsValue({
|
||||
name: editData.name,
|
||||
parentId: p_arr,
|
||||
});
|
||||
setParentId(editData.parentId);
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
const new_arr: Option[] = [
|
||||
{
|
||||
label: t('commen.levelCate'),
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
setChapters(new_arr);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
setInit(false);
|
||||
getParams();
|
||||
}, [visible, isEdit, editData, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
onFinish(values);
|
||||
console.log(values, 'add values');
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
}
|
||||
@ -207,11 +93,9 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
// 使用固定的 parentId(来自 parentData)或表单中的值
|
||||
const finalParentId = parentData ? parentData.id : parentId || 0;
|
||||
|
||||
if (isEdit && editingId) {
|
||||
EditChapterApi(editingId, values.name, finalParentId, editData.sort, bookId)
|
||||
// 编辑模式:调用更新接口,保持自己的sort
|
||||
EditChapterApi(editingId, values.name, parentId || 0, editData.sort, bookId)
|
||||
.then((res: any) => {
|
||||
setLoading(false);
|
||||
message.success('更新成功');
|
||||
@ -225,7 +109,7 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
});
|
||||
} else {
|
||||
// 新建模式:调用新增接口
|
||||
CreateChapterApi(values.name, finalParentId, 0, bookId)
|
||||
CreateChapterApi(values.name, parentId || 0, 0, bookId)
|
||||
.then((res: any) => {
|
||||
setLoading(false);
|
||||
message.success('创建成功');
|
||||
@ -246,10 +130,6 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
};
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
// 如果有 parentData,不允许更改
|
||||
if (parentData) {
|
||||
return;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
let it = value[value.length - 1];
|
||||
setParentId(it);
|
||||
@ -257,7 +137,44 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
setParentId(0);
|
||||
}
|
||||
};
|
||||
|
||||
const checkArr = (categories: any[], id: number) => {
|
||||
const arr = [];
|
||||
for (let i = 0; i < categories[id].length; i++) {
|
||||
if (!categories[categories[id][i].id]) {
|
||||
arr.push({
|
||||
label: categories[id][i].className,
|
||||
value: categories[id][i].id,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
label: categories[id][i].className,
|
||||
value: categories[id][i].id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
const getParams = () => {
|
||||
getSoftwareClassApi().then((res: any) => {
|
||||
const chapters = res.data;
|
||||
if (JSON.stringify(chapters) !== '{}') {
|
||||
const new_arr: Option[] = checkArr(chapters, 0);
|
||||
new_arr.unshift({
|
||||
label: t('commen.levelCate'),
|
||||
value: 0,
|
||||
});
|
||||
setChapters(new_arr);
|
||||
} else {
|
||||
const new_arr: Option[] = [];
|
||||
new_arr.unshift({
|
||||
label: t('commen.levelCate'),
|
||||
value: 0,
|
||||
});
|
||||
setChapters(new_arr);
|
||||
}
|
||||
setInit(false);
|
||||
});
|
||||
};
|
||||
const displayRender = (label: any, selectedOptions: any) => {
|
||||
return label[label.length - 1];
|
||||
};
|
||||
@ -273,12 +190,12 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
confirmLoading={confirmLoading}
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
{loading && (
|
||||
{init && (
|
||||
<div className="float-left text-center mt-30">
|
||||
<Spin></Spin>
|
||||
</div>
|
||||
)}
|
||||
<div className="float-left mt-24" style={{ display: loading ? 'none' : 'block' }}>
|
||||
<div className="float-left mt-24" style={{ display: init ? 'none' : 'block' }}>
|
||||
<Form
|
||||
form={form}
|
||||
name="basic"
|
||||
@ -295,14 +212,14 @@ export const ChapterModal: React.FC<ChapterModalProps> = ({
|
||||
rules={[{ required: true, message: t('textbook.chapter.parentTip') }]}
|
||||
>
|
||||
<Cascader
|
||||
allowClear={!parentData}
|
||||
allowClear
|
||||
placeholder={t('textbook.chapter.parentTip')}
|
||||
onChange={handleChange}
|
||||
options={chapters}
|
||||
changeOnSelect
|
||||
expandTrigger="hover"
|
||||
expand-trigger="hover"
|
||||
displayRender={displayRender}
|
||||
disabled={!!parentData || (isEdit && editingId === 0)} // 有 parentData 时禁用选择
|
||||
disabled={isEdit && editingId === 0} // 禁止编辑根分类的父级
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Image, Tree, Modal, Form, message, Spin, Tooltip, Empty } from 'antd';
|
||||
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';
|
||||
@ -24,74 +24,77 @@ import type { DataNode, TreeProps } from 'antd/es/tree';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import textbook from '../index';
|
||||
import {
|
||||
CreateChapterApi,
|
||||
DestroyChapterApi,
|
||||
DropDiffClassApi,
|
||||
DropSameClassApi,
|
||||
GetPreDestroyChapterApi,
|
||||
} from '../../../api/textbook';
|
||||
import { ChapterItemModel, ChaptersBoxModel, ChapterTreeItem } from '../chapter';
|
||||
import { CreateChapterApi, DropDiffClassApi, DropSameClassApi } from '../../../api/textbook';
|
||||
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;
|
||||
sort?: number;
|
||||
rawData?: any;
|
||||
}
|
||||
|
||||
interface PropInterface {
|
||||
chapterData: ChaptersBoxModel;
|
||||
chapterTreeData: ChaptersBoxModel;
|
||||
isLoading: boolean;
|
||||
bookId: number;
|
||||
title: any;
|
||||
selected?: any;
|
||||
selectedId?: any;
|
||||
onSelecet?: any;
|
||||
refreshTreeData: () => void;
|
||||
}
|
||||
|
||||
export const ChapterTree = (props: PropInterface) => {
|
||||
const { chapterData, isLoading, selected, bookId, title, onSelecet, refreshTreeData } = props;
|
||||
const { chapterTreeData, isLoading, selected, bookId, title, refreshTreeData } = 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>(false);
|
||||
const [selectKey, setSelectKey] = useState<number[]>([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | number | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState<boolean>(false);
|
||||
const [isEdit, setIsEdit] = 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected && selected.length > 0) {
|
||||
setSelectKey(selected);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (JSON.stringify(chapterData) !== '{}') {
|
||||
const new_arr: Option[] = checkArr(chapterData, 0);
|
||||
setTreeData(new_arr);
|
||||
console.log(new_arr, 'new-arr');
|
||||
}
|
||||
}, 500);
|
||||
}, [chapterData]);
|
||||
|
||||
// 切换拖拽模式
|
||||
const toggleDragMode = () => {
|
||||
const newState = !dragEnabled;
|
||||
@ -248,13 +251,70 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 生成带序号的名称 - 需要更新以处理级别变化
|
||||
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.chapterCode ? item.chapterCode.split(',').length + 1 : 1;
|
||||
const level = item.parent_chain ? item.parent_chain.split(',').length + 1 : 1;
|
||||
const hasChildren = departments[item.id];
|
||||
|
||||
const name = (
|
||||
@ -273,7 +333,6 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
className="b-link c-red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
style={{ padding: '6px', marginRight: 5, minWidth: 'auto' }}
|
||||
>
|
||||
@ -302,6 +361,7 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hasChildren) {
|
||||
const new_arr: Option[] = checkArr(departments, item.id);
|
||||
arr.push({
|
||||
@ -309,42 +369,86 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
key: item.id,
|
||||
children: new_arr,
|
||||
level: level,
|
||||
rowData: item,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
title: name,
|
||||
key: item.id,
|
||||
level: level,
|
||||
rowData: item,
|
||||
});
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
const handlePreDeleteItem = () => {
|
||||
if (selectKey.length === 0) {
|
||||
const removeItem = (id: number, label: string) => {
|
||||
if (id === 0) {
|
||||
return;
|
||||
}
|
||||
GetPreDestroyChapterApi(selectKey[0], bookId).then((res: any) => {
|
||||
if (res.data.children && res.data.children.length === 0) {
|
||||
handleDeleteItem();
|
||||
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) {
|
||||
console.log(123);
|
||||
modal.warning({
|
||||
title: t('commen.confirmError'),
|
||||
centered: true,
|
||||
okText: t('commen.okText2'),
|
||||
content: (
|
||||
<p>
|
||||
{t('textbook.chapter.unbindText1')}
|
||||
{t('department.unbindText1', {
|
||||
// depName: depNameOBJ[systemLanguage][depDefaultName],
|
||||
})}
|
||||
<span className="c-red">
|
||||
({res.data.children.length}
|
||||
{t('textbook.chapter.unbindText2')})
|
||||
{t('department.unbindText2', {
|
||||
// depName: depNameOBJ[systemLanguage][depDefaultName],
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
,{t('textbook.chapter.unbindText3')}
|
||||
,{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>
|
||||
),
|
||||
});
|
||||
@ -352,19 +456,26 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
const handleDeleteItem = () => {
|
||||
|
||||
const resetData = () => {
|
||||
setTreeData([]);
|
||||
setRefresh(!refresh);
|
||||
};
|
||||
|
||||
const delUser = (id: any) => {
|
||||
confirm({
|
||||
title: t('commen.confirmError'),
|
||||
icon: <ExclamationCircleFilled />,
|
||||
content: t('textbook.chapter.delText'),
|
||||
content: t('department.delText', {
|
||||
// depName: depNameOBJ[systemLanguage][depDefaultName],
|
||||
}),
|
||||
centered: true,
|
||||
okText: t('commen.okText'),
|
||||
cancelText: t('commen.cancelText'),
|
||||
onOk() {
|
||||
DestroyChapterApi(selectKey[0], bookId).then(() => {
|
||||
department.destroyDepartment(id).then((res: any) => {
|
||||
message.success(t('commen.success'));
|
||||
setTreeData([]);
|
||||
refreshTreeData();
|
||||
resetData();
|
||||
});
|
||||
},
|
||||
onCancel() {
|
||||
@ -376,7 +487,6 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
// 打开添加章节弹窗
|
||||
const handleAdd = (parent?: ChapterItemModel) => {
|
||||
setParentChapter(parent || null);
|
||||
setIsEdit(false);
|
||||
setEditingChapter(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
@ -384,15 +494,32 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
// 打开编辑章节弹窗
|
||||
const handleEdit = (chapter: ChapterItemModel) => {
|
||||
console.log('编辑章节:', chapter);
|
||||
setIsEdit(true);
|
||||
/* const result = getDetailByClassId(treeListData, editSelectId);
|
||||
setEditSelectItem(result);*/
|
||||
setEditingChapter(chapter);
|
||||
setParentChapter(chapter);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 操作完成后清除选中ID
|
||||
const handleOperationComplete = () => {
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const onSelectTree = (selectedKeys: any, info: any) => {
|
||||
setSelectKey(selectedKeys);
|
||||
onSelecet(info.node.rowData);
|
||||
console.log(info);
|
||||
};
|
||||
|
||||
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 || '';
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
@ -402,9 +529,16 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
setParentChapter(null);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
/* setEditSelectItem(null);
|
||||
setEditSelectId(null);
|
||||
setIsEditClass(false);*/
|
||||
setModalVisible(false);
|
||||
refreshTreeData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chapterTree} id={'chapter-tree-container'}>
|
||||
{contextHolder}
|
||||
<div className={styles.chapterTitle}>
|
||||
<div>{title}</div>
|
||||
<div>
|
||||
@ -417,12 +551,21 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
>
|
||||
<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 || selectKey.length === 0}
|
||||
onClick={handlePreDeleteItem}
|
||||
disabled={dragEnabled}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: '20px' }} />
|
||||
</Button>
|
||||
@ -457,7 +600,7 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
)}
|
||||
{dragEnabled && <div className={'primary'}> {t('textbook.chapter.tips')}</div>}
|
||||
{isMoreThanThree && <div className={'primary'}> {t('textbook.chapter.tips3')}</div>}
|
||||
{!isLoading && treeData.length > 0 ? (
|
||||
{treeData.length > 0 && (
|
||||
<div className={`${styles[`bottom-tree-box`]}`}>
|
||||
<Tree
|
||||
draggable={
|
||||
@ -490,8 +633,6 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty style={{ marginTop: 40 }}></Empty>
|
||||
)}
|
||||
</div>
|
||||
{modalVisible && !dragEnabled && (
|
||||
@ -500,7 +641,6 @@ export const ChapterTree = (props: PropInterface) => {
|
||||
isEdit={isEdit}
|
||||
editData={editingChapter}
|
||||
bookId={bookId}
|
||||
parentData={parentChapter}
|
||||
onCancel={handleModalCancel}
|
||||
confirmLoading={submitLoading}
|
||||
onSuccess={refreshTreeData}
|
||||
|
||||
@ -25,15 +25,14 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
.btns{
|
||||
width: 56%;
|
||||
width: 70%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: end;
|
||||
align-items: baseline;
|
||||
}
|
||||
.types{
|
||||
min-width: 475px;
|
||||
width: 44%;
|
||||
width: 30%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Image, Input, message, Modal, Radio, Space, Table, TableProps, Tag } from 'antd';
|
||||
import { Button, Input, message, Modal, Radio, Space, Table, TableProps } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { GetResourceListApi, DelResourceItemApi, GetDetailApi } from '../../api/textbook';
|
||||
import styles from './resource.module.less';
|
||||
@ -7,54 +7,28 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import LoadingPage from '../loading';
|
||||
import { BackBartment } from '../../compenents';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const MusicSvg: React.FC = () => (
|
||||
<svg
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
>
|
||||
<path
|
||||
d="M320 192 1024 0 1024 64 1024 192 1024 736C1024 824.352 923.712 896 800 896 676.288 896 576 824.352 576 736 576 647.648 676.288 576 800 576 834.368 576 866.912 581.536 896 591.392L896 261.824 448 384 448 864C448 952.352 347.712 1024 224 1024 100.288 1024 0 952.352 0 864 0 775.648 100.288 704 224 704 258.368 704 290.912 709.536 320 719.392L320 384 320 192Z"
|
||||
fill="#628eee"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FileTextFilled,
|
||||
FileTextTwoTone,
|
||||
FileUnknownTwoTone,
|
||||
PlayCircleOutlined,
|
||||
UploadOutlined,
|
||||
VideoCameraFilled,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import CreateResourceModal from './compenents/Resource/CreateResourceModal';
|
||||
import { dateFormatNoTime, formatFileSize } from '../../utils';
|
||||
import defaultThumb1 from '../../assets/thumb/thumb1.png';
|
||||
|
||||
interface ResourceBase {
|
||||
id: string; // 资源id
|
||||
typeId?: number | string; // 分类Id
|
||||
id: number | null | string | undefined; // 资源id
|
||||
typeId: number; // 分类Id
|
||||
bookId: 0;
|
||||
chapterId?: 0;
|
||||
chapterTitle?: string;
|
||||
chapterId: 0;
|
||||
name: string;
|
||||
type: string;
|
||||
ext?: string;
|
||||
ext: string;
|
||||
size: 0;
|
||||
duration?: 0;
|
||||
duration: 0;
|
||||
url: string;
|
||||
cover?: string;
|
||||
status?: 0;
|
||||
creator?: string;
|
||||
updater?: string;
|
||||
cover: string;
|
||||
status: 0;
|
||||
creator: string;
|
||||
updater: string;
|
||||
createTime: string;
|
||||
updateTime?: string;
|
||||
tenantId?: string;
|
||||
updateTime: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
// 列表展示
|
||||
@ -77,8 +51,6 @@ const ResourcePage = () => {
|
||||
const { bookId } = useParams();
|
||||
const [isEdit, setIsEdit] = useState(false); // 是否编辑模式
|
||||
const [editId, setEditId] = useState<number>(0);
|
||||
const [itemDetailData, setItemDetailData] = useState<ResourceBase>();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState<boolean>(false);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@ -104,218 +76,136 @@ const ResourcePage = () => {
|
||||
{ label: t('textbook.resource.typeList.audio'), value: 4, color: '#f3e8ff' },
|
||||
{ label: t('textbook.resource.typeList.other'), value: 5, color: '#f3f4f6' },
|
||||
];
|
||||
const bgColors = ['#dbeafe', '#dcfce7', '#fef9c3', '#f3e8ff', '#f3f4f6'];
|
||||
const fontColors = ['#4649c1', '#1a6838', '#98692b', '#6e26aa', '#434b57'];
|
||||
|
||||
// 排序字段/**/
|
||||
/* name
|
||||
size createTime这三个*/
|
||||
// 列表数据
|
||||
const demoData = [
|
||||
{
|
||||
key: '1',
|
||||
id: '1',
|
||||
name: '课程介绍视频.mp4',
|
||||
chapterTitle: '第1章 课程概述 - 1.1 课程简介',
|
||||
type: '视频',
|
||||
typeId: '1',
|
||||
createTime: '2024-01-15',
|
||||
size: 12500000, // 用于排序的数值
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
id: '2',
|
||||
|
||||
name: '课程大纲图.jpg',
|
||||
chapterTitle: '第1章 课程概述 - 1.1 课程简介',
|
||||
type: '图片',
|
||||
typeId: '2',
|
||||
url: '',
|
||||
createTime: '2024-01-10',
|
||||
size: 2400000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: '3',
|
||||
name: '第一套练习题.pdf',
|
||||
chapterTitle: '第1章 课程概述',
|
||||
type: '文档',
|
||||
typeId: '3',
|
||||
|
||||
createTime: '2024-01-12',
|
||||
size: 1800000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: '4',
|
||||
name: '课程导入音频.mp3',
|
||||
chapterTitle: '第1章 课程概述 - 1.1 课程简介',
|
||||
type: '音频',
|
||||
typeId: '4',
|
||||
|
||||
createTime: '2024-01-14',
|
||||
size: 5200000,
|
||||
},
|
||||
];
|
||||
|
||||
const columns: TableProps<ResourceBase>['columns'] = [
|
||||
{
|
||||
title: t('textbook.resource.title1'),
|
||||
dataIndex: 'name',
|
||||
dataIndex: 'softNo',
|
||||
align: 'left',
|
||||
width: 500,
|
||||
width: 300,
|
||||
sorter: true,
|
||||
render: (name: string, record) => (
|
||||
<div className="d-flex">
|
||||
<div
|
||||
style={{
|
||||
width: 58,
|
||||
height: 58,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '6px',
|
||||
background: '#f3f4f6',
|
||||
textAlign: 'center',
|
||||
marginRight: 15,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{record.typeId == '1' && (
|
||||
<VideoCameraFilled style={{ fontSize: 30, color: '#628eee' }} />
|
||||
)}{' '}
|
||||
{record.typeId == '2' && (
|
||||
<Image
|
||||
src={record.url}
|
||||
width={58}
|
||||
height={58}
|
||||
preview={false}
|
||||
fallback={defaultThumb1}
|
||||
/>
|
||||
)}
|
||||
{record.typeId == '3' && <FileTextFilled style={{ fontSize: 30, color: '#628eee' }} />}
|
||||
{record.typeId == '4' && <MusicSvg />}
|
||||
{record.typeId == '5' && (
|
||||
<FileUnknownTwoTone style={{ fontSize: 30, color: '#628eee' }} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '14px', marginBottom: '4px' }}>{name}</div>
|
||||
<div style={{ color: '#666', fontSize: '12px' }}>{record?.chapterTitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('textbook.resource.title2'),
|
||||
dataIndex: 'typeId',
|
||||
key: 'typeId',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (typeId: number, record) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
width: 60,
|
||||
height: 30,
|
||||
lineHeight: '30px',
|
||||
backgroundColor: bgColors[Number(typeId) - 1],
|
||||
color: fontColors[Number(typeId) - 1],
|
||||
}}
|
||||
>
|
||||
{record.type}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
dataIndex: 'softwareName',
|
||||
align: 'left',
|
||||
},
|
||||
|
||||
{
|
||||
title: t('textbook.resource.title3'),
|
||||
dataIndex: 'size',
|
||||
dataIndex: 'company',
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
key: 'size',
|
||||
width: 150,
|
||||
align: 'center',
|
||||
render: (size) => <span style={{ fontWeight: 500 }}>{formatFileSize(size)}</span>,
|
||||
},
|
||||
{
|
||||
title: t('textbook.resource.title4'),
|
||||
dataIndex: 'version',
|
||||
align: 'left',
|
||||
ellipsis: true,
|
||||
width: 180,
|
||||
width: 140,
|
||||
sorter: true,
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
render: (createTime: string) => {
|
||||
return <span style={{ color: 'gray' }}>{dateFormatNoTime(createTime)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('textbook.resource.title5'),
|
||||
align: 'center',
|
||||
width: 240,
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Space
|
||||
size="middle"
|
||||
style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'center' }}
|
||||
// align: 'center',
|
||||
render: (_, record) => (
|
||||
<Space
|
||||
size="middle"
|
||||
style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'center' }}
|
||||
>
|
||||
<a
|
||||
key="pre"
|
||||
onClick={() => {
|
||||
console.log('预览');
|
||||
}}
|
||||
>
|
||||
<a
|
||||
key="pre"
|
||||
onClick={() => {
|
||||
console.log('预览');
|
||||
}}
|
||||
>
|
||||
{t('commen.preview')}
|
||||
</a>
|
||||
<a
|
||||
key={'edit'}
|
||||
onClick={() => {
|
||||
setEditId(Number(record.id));
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('commen.edit')}
|
||||
</a>
|
||||
<a key={'del'} className="b-link c-red" onClick={() => showDeleteConfirm(record.id)}>
|
||||
{/*<DeleteOutlined />*/}
|
||||
{t('commen.del')}
|
||||
</a>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
{/*<EyeOutlined />*/}
|
||||
{t('commen.preview')}
|
||||
</a>
|
||||
<a
|
||||
key={'edit'}
|
||||
//@ts-ignore
|
||||
onClick={() => {
|
||||
getDetail(Number(record.id));
|
||||
}}
|
||||
>
|
||||
{t('commen.edit')}
|
||||
</a>
|
||||
<a
|
||||
key={'del'}
|
||||
className="b-link c-red"
|
||||
onClick={() => showDeleteConfirm(Number(record.id))}
|
||||
>
|
||||
{/*<DeleteOutlined />*/}
|
||||
{t('commen.del')}
|
||||
</a>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const getResourceList = () => {
|
||||
GetResourceListApi(page, pageSize, type, sortOrder, sortField, searchData)
|
||||
.then((res: any) => {
|
||||
// @ts-ignore
|
||||
setResource(demoData);
|
||||
setResourceTotal(demoData.length);
|
||||
// @ts-ignore
|
||||
const getResourceList = async () => {
|
||||
try {
|
||||
const res = (await GetResourceListApi(
|
||||
page,
|
||||
pageSize,
|
||||
type,
|
||||
searchData,
|
||||
sortOrder,
|
||||
sortField
|
||||
)) as ResourceResData;
|
||||
if (res && 'data' in res && 'records' in res.data) {
|
||||
setResource(res.data.records);
|
||||
setResourceTotal(res.data.total);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false);
|
||||
setResource([]);
|
||||
});
|
||||
} else {
|
||||
console.warn('接口返回数据结构异常:', res);
|
||||
setResource([]); // 设置为空数组防止崩溃
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetVirtualList = () => {
|
||||
GetResourceListApi(1, 10, null, null, null, null)
|
||||
.then((res: any) => {
|
||||
const resetVirtualList = async () => {
|
||||
try {
|
||||
const res = (await GetResourceListApi(1, 10, null, null, null, null)) as ResourceResData;
|
||||
if (res && 'data' in res && 'records' in res.data) {
|
||||
setResource(res.data.records || []);
|
||||
setResourceTotal(res.data.total);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('接口返回数据结构异常:', error);
|
||||
setResource([]);
|
||||
});
|
||||
} else {
|
||||
console.warn('接口返回数据结构异常:', res);
|
||||
setResource([]); // 设置为空数组防止崩溃
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const showDeleteConfirm = (id: string) => {
|
||||
const getDetail = async (id: number) => {
|
||||
try {
|
||||
const res = (await GetDetailApi(id)) as VirtualResDetail | undefined;
|
||||
if (!res || !res.data) {
|
||||
message.error('获取详情失败');
|
||||
return;
|
||||
}
|
||||
setIsEdit(true);
|
||||
//@ts-ignore
|
||||
setItemDetailData(res.data);
|
||||
setSelectedId(res.data.id);
|
||||
// 打开弹窗
|
||||
setIsAddModalOpen(true);
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
setPageLoading(false);
|
||||
console.error('获取详情失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const showDeleteConfirm = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setModalVisible(true);
|
||||
};
|
||||
@ -323,33 +213,31 @@ const ResourcePage = () => {
|
||||
const handleDeleteItem = async () => {
|
||||
if (selectedId === null) return;
|
||||
setConfirmLoading(true);
|
||||
DelResourceItemApi(selectedId.toString())
|
||||
.then(() => {
|
||||
message.success('删除成功');
|
||||
getResourceList();
|
||||
setConfirmLoading(false);
|
||||
setModalVisible(false);
|
||||
setSelectedId(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setConfirmLoading(false);
|
||||
setModalVisible(false);
|
||||
setSelectedId(null);
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', err);
|
||||
});
|
||||
try {
|
||||
await DelResourceItemApi(selectedId.toString());
|
||||
message.success('删除成功');
|
||||
// @ts-ignore
|
||||
await getVirtualList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
setModalVisible(false);
|
||||
setSelectedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
//弹窗取消
|
||||
|
||||
const showAddSoftModal = () => {
|
||||
setIsEdit(false);
|
||||
setSelectedId(null);
|
||||
setIsEdit(false); // 设置为新增模式
|
||||
setSelectedId(null); // 清除选中 ID
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCancelDeleteItem = () => {
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCancelDeleteItems = () => {
|
||||
setIsConfirmModalOpen(false);
|
||||
};
|
||||
@ -359,8 +247,6 @@ const ResourcePage = () => {
|
||||
setType(0);
|
||||
setSelectedIdList([]);
|
||||
setSearchData('');
|
||||
setSortOrder('');
|
||||
setSortField('');
|
||||
resetVirtualList();
|
||||
};
|
||||
// 批量删除
|
||||
@ -368,25 +254,22 @@ const ResourcePage = () => {
|
||||
toDeleteSoftwareList();
|
||||
};
|
||||
|
||||
const toDeleteSoftwareList = () => {
|
||||
const selectedIdListString = selectedIdList.join(',');
|
||||
DelResourceItemApi(selectedIdListString)
|
||||
.then(() => {
|
||||
message.success('删除成功');
|
||||
setConfirmLoading(false);
|
||||
setIsConfirmModalOpen(false);
|
||||
setSelectedId(null);
|
||||
setSelectedIdList([]);
|
||||
getResourceList();
|
||||
})
|
||||
.catch((err) => {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', err);
|
||||
setConfirmLoading(false);
|
||||
setIsConfirmModalOpen(false);
|
||||
setSelectedId(null);
|
||||
setSelectedIdList([]);
|
||||
});
|
||||
const toDeleteSoftwareList = async () => {
|
||||
try {
|
||||
const selectedIdListString = selectedIdList.join(',');
|
||||
const res = await DelResourceItemApi(selectedIdListString);
|
||||
message.success('删除成功');
|
||||
// @ts-ignore
|
||||
await getVirtualList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
setIsConfirmModalOpen(false);
|
||||
setSelectedId(null);
|
||||
setSelectedIdList([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCancel = () => {
|
||||
@ -399,7 +282,7 @@ const ResourcePage = () => {
|
||||
useEffect(() => {
|
||||
setResource([]);
|
||||
getResourceList();
|
||||
}, [refresh, page, pageSize, type, sortOrder, sortField]);
|
||||
}, [refresh, page, pageSize, type, searchData]);
|
||||
|
||||
const onSelectChange = (newSelectedRowKeys: any[]) => {
|
||||
setSelectedIdList(newSelectedRowKeys);
|
||||
@ -411,21 +294,26 @@ const ResourcePage = () => {
|
||||
onChange: onSelectChange,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getResourceList();
|
||||
}, [page, pageSize, type, sortOrder, sortField, searchData]);
|
||||
|
||||
const handleTableChange = (
|
||||
pagination: { current: number; pageSize: number },
|
||||
filters: any,
|
||||
sorter: { field: string; order: string }
|
||||
) => {
|
||||
// 统一处理分页
|
||||
// if (pagination.current !== page || pagination.pageSize !== pageSize) {
|
||||
setPage(pagination.current);
|
||||
setPageSize(pagination.pageSize);
|
||||
// }
|
||||
// 处理排序
|
||||
if (sorter && sorter.field) {
|
||||
const sField = sorter.field as string;
|
||||
const sOrder = sorter.order === 'ascend' ? 'asc' : sorter.order === 'descend' ? 'desc' : '';
|
||||
const sortField = sorter.field as string;
|
||||
const sortOrder =
|
||||
sorter.order === 'ascend' ? 'asc' : sorter.order === 'descend' ? 'desc' : '';
|
||||
console.log('排序字段:', sortField, '排序方向:', sortOrder);
|
||||
setSortField(sField);
|
||||
setSortOrder(sOrder);
|
||||
}
|
||||
};
|
||||
|
||||
@ -475,7 +363,7 @@ const ResourcePage = () => {
|
||||
<div className={styles.btns}>
|
||||
<Input
|
||||
placeholder={t('textbook.resource.searchPlaceholder')}
|
||||
style={{ marginRight: 15, width: 220 }}
|
||||
style={{ marginRight: 15, width: 360 }}
|
||||
value={searchData}
|
||||
allowClear
|
||||
onChange={(e) => setSearchData(e.target.value)}
|
||||
@ -517,8 +405,8 @@ const ResourcePage = () => {
|
||||
current: page,
|
||||
total: resourceTotal,
|
||||
showSizeChanger: true,
|
||||
align: 'end',
|
||||
showTotal: () => `共 ${resourceTotal} 条记录`,
|
||||
align: 'start',
|
||||
showTotal: (total) => `共 ${resourceTotal} 条记录`,
|
||||
}}
|
||||
// @ts-ignore
|
||||
onChange={handleTableChange}
|
||||
@ -532,6 +420,7 @@ const ResourcePage = () => {
|
||||
onCancel={handleAddCancel}
|
||||
resourceId={editId}
|
||||
bookId={bookId}
|
||||
typeOptions={TypeOptions}
|
||||
></CreateResourceModal>
|
||||
)}
|
||||
|
||||
@ -543,7 +432,7 @@ const ResourcePage = () => {
|
||||
onCancel={handleCancelDeleteItem}
|
||||
confirmLoading={confirmLoading}
|
||||
>
|
||||
<p>确定要删除这条信息吗?</p>
|
||||
<p>确定要删除这个软件吗?</p>
|
||||
</Modal>
|
||||
{/*多个删除确认*/}
|
||||
<Modal
|
||||
@ -553,7 +442,7 @@ const ResourcePage = () => {
|
||||
onCancel={handleCancelDeleteItems}
|
||||
confirmLoading={confirmLoading}
|
||||
>
|
||||
<p>确定要删除这些信息吗?</p>
|
||||
<p>确定要删除这些软件吗?</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -9,13 +9,10 @@ export interface Chapter {
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
parentTitle: string;
|
||||
bookId: number;
|
||||
isLoading: boolean;
|
||||
chapterId: string;
|
||||
chapterTitle: string;
|
||||
initialContent?: string;
|
||||
onSave: (data: any) => void;
|
||||
onSave: (chapterId: string, content: string) => void;
|
||||
onContentChange: (chapterId: string, content: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@ -197,227 +197,3 @@ export function returnAppUrl(url: string): string {
|
||||
export function blobValidate(data: any) {
|
||||
return data.type !== 'application/json';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从视频文件提取第一帧作为封面
|
||||
* @param file 视频文件
|
||||
* @param time 截取的时间点(秒),默认0.1秒
|
||||
* @param quality 封面质量(0-1),默认0.8
|
||||
* @returns Promise<string> base64格式的图片
|
||||
*/
|
||||
export const extractVideoThumbnail = (
|
||||
file: File,
|
||||
time: number = 0.1,
|
||||
quality: number = 0.8
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('video/')) {
|
||||
reject(new Error('文件不是视频格式'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建视频元素
|
||||
const video = document.createElement('video');
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
reject(new Error('无法创建canvas上下文'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建对象URL
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
// 配置视频
|
||||
video.src = url;
|
||||
video.muted = true; // 静音以避免自动播放限制
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'metadata';
|
||||
|
||||
// 事件监听器
|
||||
const handleError = (error?: Error) => {
|
||||
cleanup();
|
||||
reject(error || new Error('无法读取视频文件'));
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
video.removeEventListener('loadeddata', onLoaded);
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
video.removeEventListener('error', onError);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
handleError(new Error('视频加载失败'));
|
||||
};
|
||||
|
||||
const onLoaded = () => {
|
||||
// 检查视频尺寸
|
||||
if (video.videoWidth === 0 || video.videoHeight === 0) {
|
||||
handleError(new Error('视频尺寸无效'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置canvas尺寸
|
||||
// 限制最大尺寸,避免生成过大的图片
|
||||
const maxWidth = 800;
|
||||
const maxHeight = 450;
|
||||
|
||||
let width = video.videoWidth;
|
||||
let height = video.videoHeight;
|
||||
|
||||
// 按比例缩放
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width = Math.floor(width * ratio);
|
||||
height = Math.floor(height * ratio);
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 尝试截取指定时间点的帧
|
||||
video.currentTime = Math.min(time, video.duration || 1);
|
||||
};
|
||||
|
||||
const onSeeked = () => {
|
||||
try {
|
||||
// 绘制视频帧到canvas
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 将canvas转为base64
|
||||
const thumbnail = canvas.toDataURL('image/jpeg', quality);
|
||||
|
||||
// 检查生成的图片是否有效
|
||||
if (!thumbnail || thumbnail.length < 100) {
|
||||
handleError(new Error('生成封面失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(thumbnail);
|
||||
handleSuccess();
|
||||
} catch (error) {
|
||||
handleError(error instanceof Error ? error : new Error('生成封面失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 绑定事件
|
||||
video.addEventListener('loadeddata', onLoaded);
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
video.addEventListener('error', onError);
|
||||
|
||||
// 设置超时处理
|
||||
const timeout = setTimeout(() => {
|
||||
handleError(new Error('生成封面超时'));
|
||||
}, 10000); // 10秒超时
|
||||
|
||||
// 清理超时
|
||||
const originalResolve = resolve;
|
||||
const originalReject = reject;
|
||||
|
||||
resolve = ((...args) => {
|
||||
clearTimeout(timeout);
|
||||
originalResolve(...args);
|
||||
}) as typeof resolve;
|
||||
|
||||
reject = ((...args) => {
|
||||
clearTimeout(timeout);
|
||||
originalReject(...args);
|
||||
}) as typeof reject;
|
||||
|
||||
// 开始加载视频
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成默认的视频封面(当提取失败时使用)
|
||||
* @param width 封面宽度
|
||||
* @param height 封面高度
|
||||
* @param text 封面文字
|
||||
* @returns base64格式的SVG图片
|
||||
*/
|
||||
export const generateDefaultVideoPoster = (
|
||||
width: number = 800,
|
||||
height: number = 450,
|
||||
text: string = '视频预览'
|
||||
): string => {
|
||||
const colors = [
|
||||
{ bg: '#1890ff', text: '#ffffff' }, // 蓝色
|
||||
{ bg: '#52c41a', text: '#ffffff' }, // 绿色
|
||||
{ bg: '#fa8c16', text: '#ffffff' }, // 橙色
|
||||
{ bg: '#f5222d', text: '#ffffff' }, // 红色
|
||||
{ bg: '#722ed1', text: '#ffffff' }, // 紫色
|
||||
];
|
||||
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
const svg = `
|
||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="${color.bg}"/>
|
||||
<path d="M${width / 2 - 40} ${height / 2 - 40} L${width / 2 + 40} ${height / 2} L${width / 2 - 40} ${height / 2 + 40} Z"
|
||||
fill="${color.text}" stroke="${color.text}" stroke-width="2"/>
|
||||
<text x="${width / 2}" y="${height / 2 + 80}"
|
||||
text-anchor="middle"
|
||||
font-family="Arial, sans-serif"
|
||||
font-size="18"
|
||||
font-weight="bold"
|
||||
fill="${color.text}">
|
||||
${text}
|
||||
</text>
|
||||
<text x="${width / 2}" y="${height - 20}"
|
||||
text-anchor="middle"
|
||||
font-family="Arial, sans-serif"
|
||||
font-size="12"
|
||||
fill="rgba(255,255,255,0.8)">
|
||||
点击播放
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* base64转Blob对象
|
||||
*/
|
||||
export const dataURLtoBlob = (dataurl: string): Blob => {
|
||||
const arr = dataurl.split(',');
|
||||
const mimeMatch = arr[0].match(/:(.*?);/);
|
||||
const mime = mimeMatch ? mimeMatch[1] : 'image/jpeg';
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
|
||||
return new Blob([u8arr], { type: mime });
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
export const getFileExtension = (filename: string): string => {
|
||||
return filename.slice(((filename.lastIndexOf('.') - 1) >>> 0) + 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取文件大小
|
||||
*/
|
||||
|
||||
// 格式化文件大小
|
||||
export const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user