Files
gangyan/chat_web_front/src/views/reading/index.vue

1168 lines
39 KiB
Vue
Raw Normal View History

<template>
<div class="reading-layout">
<!-- 左栏文件管理树 -->
<div class="left-panel">
<div class="left-header">
<div class="left-title">文件管理</div>
<div class="left-actions">
<img src="@/assets/images/reading/create.png" alt="新建" title="新建文件夹" class="action-icon" @click="createFolder()">
</div>
</div>
<div class="left-search">
<el-input v-model="searchWord" placeholder="搜索文件..." size="small" clearable>
<template #prefix>
<img src="@/assets/images/search.png" style="width:14px;height:14px;">
</template>
</el-input>
</div>
<div class="left-tree">
<el-tree
ref="treeRef"
:data="filteredTreeData"
:props="treeProps"
node-key="id"
:expand-on-click-node="false"
:default-expand-all="false"
highlight-current
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="tree-node" :class="{ 'is-file': !data.isFolder, 'is-selected-file': selectedFile && selectedFile.fileId === data.id && !data.isFolder }">
<div class="tree-node-content">
<img v-if="data.isFolder" src="@/assets/images/reading/folder.png" class="tree-icon folder-icon">
<span v-else class="tree-file-icon">{{ getFileIcon(data.label) }}</span>
<span class="tree-label" :title="data.label">{{ data.label }}</span>
</div>
<div class="tree-node-actions" v-if="data.isFolder" @click.stop>
<el-dropdown trigger="click" size="small">
<img src="@/assets/images/reading/operates.png" class="tree-operate-icon">
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="uploadToFolder(data)">上传文件</el-dropdown-item>
<el-dropdown-item @click="createFolder(data)">重命名</el-dropdown-item>
<el-dropdown-item @click="delFolder(data)" style="color:#BE0000">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="tree-node-actions" v-if="!data.isFolder" @click.stop>
<el-dropdown trigger="click" size="small">
<img src="@/assets/images/reading/operates.png" class="tree-operate-icon">
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="renameFile(data)">重命名</el-dropdown-item>
<el-dropdown-item @click="downLoadDoc(data)">下载</el-dropdown-item>
<el-dropdown-item @click="delFile(data)" style="color:#BE0000">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
</el-tree>
</div>
<div class="left-footer">
<div class="batch-del-btn" @click="delBatch">
<img src="@/assets/images/reading/del.png" alt="">
<span>批量删除</span>
</div>
</div>
</div>
<!-- 中间栏文件预览 -->
<div class="center-panel">
<div v-if="!selectedFile" class="center-placeholder">
<div class="placeholder-content">
<img src="@/assets/images/reading/empty.png" alt="" style="width:80px;height:80px;opacity:0.5;">
<p>点击左侧文件查看内容</p>
</div>
</div>
<div v-else class="center-content">
<div class="center-header">
<span class="center-title" :title="selectedFile.fileName">{{ selectedFile.fileName }}</span>
</div>
<div class="file-content" ref="fileContent" id="file-content">
<div class="view-md" id="file-html-content" v-html="docHtml"></div>
<div id="note-content" :title="noteContent" class="file-note"></div>
</div>
</div>
</div>
<!-- 右栏ReadingBox -->
<div class="right-panel">
<div v-if="!selectedFile" class="right-placeholder">
<div class="placeholder-content">
<img src="@/assets/images/reading/empty.png" alt="" style="width:60px;height:60px;opacity:0.4;">
<p class="placeholder-title">知识库问答</p>
<p class="placeholder-sub">选择文件后开始提问</p>
</div>
</div>
<ReadingBox v-else ref="readingBox" />
</div>
<!-- 右键菜单 -->
<div class="click-bar-button" id="shortMenuDom" ref="shortMenuDom" v-show="shortMenuShow">
<template v-for="(item, index) in contentMenuList" :key="index">
<div class="menu-name" @click="fileContentMenu(index)">{{ item }}</div>
<div class="vertical-line" v-if="index < contentMenuList.length - 1"></div>
</template>
</div>
</div>
<!-- 创建/重命名文件夹弹框 -->
<el-dialog v-model="folderDialogVisible" :title="folderDialogTitle" width="500"
:close-on-click-modal="false">
<el-input v-model="submitFolderParam.name" placeholder="请输入文件夹名称"
@keyup.enter="submitFolderName"></el-input>
<template #footer>
<el-button @click="folderDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitFolderName">确定</el-button>
</template>
</el-dialog>
<!-- 重命名文件弹框 -->
<el-dialog v-model="showRenameDialog" title="重命名" width="500" :close-on-click-modal="false">
<el-input v-model="renameDocName" placeholder="请输入文件名称" />
<template #footer>
<el-button @click="showRenameDialog = false">取消</el-button>
<el-button type="primary" @click="saveDocName">保存</el-button>
</template>
</el-dialog>
<!-- 上传文件弹框 -->
<el-dialog v-model="showUploadDialog" title="上传文件" width="715" @close="closeUpload()"
:close-on-click-modal="false">
<el-upload
class="upload-demo"
action="#"
:auto-upload="false"
accept=".pdf,.docx,.txt,.xlsx,.xls"
name="files"
multiple
:show-file-list="false"
:on-change="handleFileChange"
>
<el-icon :size="100" class="el-icon--upload" style="display: flex;flex-wrap: wrap">
<upload-filled style="color: #004EA0"/>
<span style="margin-top:-50px;font-size:13px; font-style: normal;color:#004EA0">选择文件</span>
</el-icon>
<template #tip>
<div class="el-upload__tip">
支持TXT/DOCX/PDF/XLSX/XLS格式文件文件大小不超过10M
</div>
</template>
</el-upload>
<div class="files-upload-result">
<div class="result" v-for="item in uploadingFileList" :key="item.uid">
<div class="file-name" :title="item.name">{{ item.name }}</div>
<div class="result-info">
<div class="fail-msg" v-if="item.status === 'fail'" :title="item.message">{{ item.message }}</div>
<img class="upload-remove" v-if="item.status === 'ready'" src="@/assets/images/reading/upload_remove.png"
alt="" @click="handleRemove(item)">
<div class="loading" v-if="item.status === 'uploading'">
<Loading></Loading>
</div>
<img v-if="item.status === 'success'" src="@/assets/images/reading/upload_success.png" alt="">
<img v-if="item.status === 'fail'" src="@/assets/images/reading/upload_fail.png" alt="">
</div>
</div>
</div>
<template #footer>
<div style="text-align: center" v-if="uploadingFileList.length > 0">
<el-button @click="closeUpload()">关闭</el-button>
<div class="dialog-tips">
<img src="@/assets/images/reading/tips.png" alt="">
<span>关闭弹窗后文件将继续上传</span>
</div>
</div>
</template>
</el-dialog>
<!-- 添加笔记弹框 -->
<el-dialog v-model="showAddNoteDialog" title="写下你的想法吧" width="715"
:close-on-click-modal="false" @open="openAddNoteDialog">
<el-input ref="noteMsgRef" v-model="noteMsg" type="textarea" :rows="10" placeholder="请输入笔记内容"/>
<template #footer>
<div style="text-align: center;">
<el-button type="primary" @click="submitNote()">发表</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import {onMounted, onUnmounted, ref, reactive, provide, nextTick, computed, watch} from "vue";
import {
getKnowledgeBaseList, addKnowledgeBase, editKnowledgeBase, delKnowledgeBase,
getKnowledgeBaseContent, uploadFile, editFile, delFile, delFiles,
downloadFile, getFileContent, addFileNote, getSize
} from "@/api";
import {withLoading} from "@/utils/loading";
import {copyToClip, getGlobalSelectionPosition} from "@/utils";
import {transforMd} from "@/utils/markdown";
import ReadingBox from "@/components/ReadingBox.vue";
import Loading from "@/components/Loading.vue";
import {UploadFilled} from '@element-plus/icons-vue';
import {ElMessage, ElMessageBox, type UploadFile, type UploadFiles} from "element-plus";
import Mark from "mark.js";
// ===================== 文件树数据 =====================
const treeRef = ref(null);
const searchWord = ref('');
const treeData = ref<any[]>([]);
const treeProps = { children: 'children', label: 'label' };
// ===================== 选中文件 =====================
const selectedFile = ref<any>(null);
provide('selectedFile', selectedFile);
// ===================== 文件预览 =====================
const docHtml = ref('');
const fileContent = ref(null);
const readingBox = ref(null);
// ===================== 笔记 =====================
const fileNote = reactive({ notes: [] as any[] });
const noteContent = ref('');
provide('fileNote', fileNote);
// ===================== 右键菜单 =====================
const shortMenuDom = ref(null);
const shortMenuShow = ref(false);
const selectText = ref('');
const contentMenuList = ['引用', '复制', '名词解释', '添加笔记', '翻译'];
// ===================== 引用 (provide to ReadingBox) =====================
const quoteMsg = ref<string>('');
provide('quoteMsg', quoteMsg);
provide('updateQuoteMsg', (newValue: string) => { quoteMsg.value = newValue; });
// ===================== 笔记弹框 =====================
const showAddNoteDialog = ref(false);
const noteMsg = ref('');
const noteMsgRef = ref(null);
provide('noteMsg', noteMsg);
provide('showAddNoteDialog', showAddNoteDialog);
interface NoteParam {
id: string; fileId: number; pstartId: string; pendId: string;
wordContent: string; startIndex: number; endIndex: number; noteContent: string;
}
interface PositionInfo {
start: number; end: number; parentStart: string | null; parentEnd: string | null;
}
const noteParam = ref<NoteParam>({
id: '', fileId: 0, pstartId: '', pendId: '',
wordContent: '', startIndex: 0, endIndex: 0, noteContent: ''
});
provide('noteParam', noteParam);
const positionInfo = ref<PositionInfo>({ start: -1, end: -1, parentStart: '', parentEnd: '' });
const activeNoteIndex = ref(-1);
provide('activeNoteIndex', activeNoteIndex);
provide('highlightContentFlag', (note: any, newIndex: number) => {
activeNoteIndex.value = newIndex;
showFileNote(note, true);
});
const showFileNoteEvent = ref('');
// ===================== 文件夹弹框 =====================
const folderDialogVisible = ref(false);
const folderDialogTitle = ref("新建文件夹");
const submitFolderParam = reactive({ id: 0, name: "" });
// ===================== 文件重命名弹框 =====================
const showRenameDialog = ref(false);
const renameDocName = ref('');
const renameDocType = ref('');
const renameDocId = ref(0);
// ===================== 上传文件弹框 =====================
const showUploadDialog = ref(false);
const uploadFolderId = ref(0);
interface CusUploadFile extends UploadFile { message?: string | null }
const uploadingFileList = ref([] as CusUploadFile[]);
const isUploading = ref(false);
const uploadFinishFlag = ref(true);
const currentIndex = ref(0);
const totalFileSize = ref<number>(0);
const allowFileTypes = ["TXT", "DOCX", "PDF", "XLSX", "XLS"];
const FILE_MAX_SIZE = 10 * 1024 * 1024;
const TOTAL_FILE_MAX_SIZE = 50 * 1024 * 1024;
// ========================================
// 文件树:加载、过滤
// ========================================
const loadTreeData = async () => {
try {
let res = await getKnowledgeBaseList<any>({ folderType: "0" });
if (res && res.data) {
const folders = res.data.map((f: any) => ({
id: f.id, label: f.name, isFolder: true, children: [], raw: f, loaded: false
}));
treeData.value = folders;
}
} catch (error: any) {
ElMessage.error(error?.message || '加载文件夹失败');
}
};
const loadFolderFiles = async (folder: any) => {
if (folder.loaded) return;
try {
let res = await getKnowledgeBaseContent<any>({
folderType: "1", knowledgeBaseId: folder.id + "", patentId: "0"
});
if (res && res.data) {
folder.children = res.data.map((f: any) => ({
id: f.id, label: f.filename, isFolder: false, isLeaf: true, raw: f,
folderId: folder.id, folderName: folder.label
}));
}
folder.loaded = true;
} catch (error: any) {
ElMessage.error(error?.message || '加载文件列表失败');
}
};
const filteredTreeData = computed(() => {
if (!searchWord.value.trim()) return treeData.value;
const keyword = searchWord.value.toLowerCase();
return treeData.value.filter((folder: any) => {
const folderMatch = folder.label.toLowerCase().includes(keyword);
const childMatch = folder.children?.some((f: any) => f.label.toLowerCase().includes(keyword));
return folderMatch || childMatch;
}).map((folder: any) => {
if (!searchWord.value.trim()) return folder;
return {
...folder,
children: folder.children?.filter((f: any) =>
f.label.toLowerCase().includes(keyword) || folder.label.toLowerCase().includes(keyword)
)
};
});
});
const handleNodeClick = async (data: any) => {
if (data.isFolder) {
await loadFolderFiles(data);
return;
}
// 点击文件
const doc = data.raw;
selectedFile.value = {
fileId: doc.id,
fileName: doc.filename,
embeddingId: doc.embeddingId,
folderId: data.folderId,
folderName: data.folderName,
articleAbstract: doc.articleAbstract || '暂无内容,请重试',
articleKeywords: doc.articleKeywords || '暂无内容,请重试',
articleParagraph: doc.articleParagraph || '暂无内容,请重试',
fullContent: doc.context
};
await loadFileContent();
};
const getFileIcon = (filename: string) => {
const ext = filename?.split('.').pop()?.toLowerCase() || '';
const icons: Record<string, string> = {
'pdf': '📄', 'docx': '📝', 'doc': '📝', 'txt': '📃',
'xlsx': '📊', 'xls': '📊', 'md': '📋'
};
return icons[ext] || '📄';
};
// ========================================
// 文件预览:加载内容、笔记、交互
// ========================================
const pattern = /(\$\$.*?\$\$|\$.*?\$)/g;
const loadFileContent = async () => {
if (!selectedFile.value) return;
let offsets: number[] = [];
try {
let res = await withLoading(getFileContent)({
fileId: selectedFile.value.fileId,
embeddingId: selectedFile.value.embeddingId,
knowledgeBaseId: selectedFile.value.folderId
});
if (res && res.code === 200 && res.data && res.data.content) {
let content = res.data.content;
content = content.replace(pattern, (match: string, _cg: string, offset: number) => {
offsets.push(offset);
return transforMd(match);
});
docHtml.value = content.replace(/<p>(.*?<span class="katex">.*?<\/span>.*?)<\/p>/g, '$1');
fileNote.notes = res.data.notes || [];
} else {
docHtml.value = '<p style="color:#999;text-align:center;margin-top:40px;">文件内容获取失败</p>';
}
} catch (error: any) {
docHtml.value = '<p style="color:#999;text-align:center;margin-top:40px;">文件内容获取失败</p>';
}
await nextTick();
try {
handelLaTeXElements(offsets);
handelCommentElements();
bindFileContentEvents();
handelNoteFlagMouseEvent();
} catch (err) { /* ignore */ }
};
provide('updateFileContent', async () => {
await loadFileContent();
});
const handelLaTeXElements = (offsets: number[]) => {
let katexElements = document.querySelectorAll('span[class="katex"]');
for (let i = 0; i < katexElements.length; i++) {
katexElements[i].setAttribute('id', 'katex-' + offsets[i]);
}
};
const handelCommentElements = () => {
document.querySelectorAll('a[href^="#comment-"]').forEach(element => {
let targetId = element.getAttribute('href');
(element as HTMLElement).onclick = function (e) {
e.preventDefault();
const targetElement = document.querySelector(targetId!);
if (targetElement) targetElement.scrollIntoView({ behavior: 'smooth' });
};
});
};
// ========================================
// 右键菜单
// ========================================
const fileContentMenu = (index: number) => {
disShowShortMenu();
if (index === 0) {
quoteMsg.value = selectText.value;
if (quoteMsg.value && quoteMsg.value.length > 1000) quoteMsg.value = quoteMsg.value.slice(0, 1000);
readingBox.value?.changeHead(2);
}
if (index === 1) {
copyToClip(selectText.value);
ElMessage.success("复制成功");
}
if (index === 2) {
let sendText = "我想请你解释一下这个词语的含义:" + selectText.value;
readingBox.value?.changeHead(2);
readingBox.value?.readingBoxSend(sendText, '1');
}
if (index === 3) {
readingBox.value?.changeHead(3);
showAddNoteDialog.value = true;
}
if (index === 4) {
readingBox.value?.changeHead(4);
readingBox.value?.translateQuery(selectText.value);
}
};
const disShowShortMenu = () => { shortMenuShow.value = false; };
const bindFileContentEvents = () => {
if (!fileContent.value) return;
fileContent.value.addEventListener('mouseup', function (event: MouseEvent) {
activeNoteIndex.value = -1;
displayFileNote();
removeHighlightSelectedWord();
setTimeout(() => {
const selection = window.getSelection();
if (!selection) return;
selectText.value = selection.toString();
let position = getGlobalSelectionPosition(selection);
positionInfo.value = position;
if (selectText.value && shortMenuDom.value && fileContent.value?.contains(selection.anchorNode)) {
let offset = getViewportOffsetByEvent(event);
shortMenuShow.value = true;
(shortMenuDom.value as HTMLElement).style.left = offset.left + 'px';
(shortMenuDom.value as HTMLElement).style.top = offset.top + 'px';
}
});
});
fileContent.value.addEventListener('scroll', () => {
let noteElement = document.getElementById('note-' + (activeNoteIndex.value + 1));
let noteContentElement = document.getElementById('note-content');
if (noteElement && noteContentElement) {
const rectNoteElement = noteElement.getBoundingClientRect();
let top = rectNoteElement.top - rectNoteElement.height - 8;
noteContentElement.style.top = top + 'px';
if (top < 75) noteContentElement.style.display = 'none';
else if (activeNoteIndex.value != -1) noteContentElement.style.display = 'block';
}
});
};
// ========================================
// 笔记功能
// ========================================
const openAddNoteDialog = async () => {
await nextTick();
noteMsgRef.value?.focus();
};
const submitNote = async () => {
if (noteParam.value.id) {
noteParam.value.noteContent = noteMsg.value;
try {
let res = await withLoading(addFileNote)(noteParam.value);
if (res.code == 200) {
ElMessage.success('笔记修改成功');
showAddNoteDialog.value = false;
noteMsg.value = '';
await loadFileContent();
} else ElMessage.error('笔记修改失败');
} catch (error: any) { ElMessage.error(error?.message || '未知错误'); }
} else {
noteParam.value.fileId = selectedFile.value?.fileId;
noteParam.value.pstartId = positionInfo.value.parentStart || '';
noteParam.value.pendId = positionInfo.value.parentEnd || '';
noteParam.value.wordContent = selectText.value;
noteParam.value.startIndex = positionInfo.value.start;
noteParam.value.endIndex = positionInfo.value.end;
noteParam.value.noteContent = noteMsg.value;
try {
let res = await withLoading(addFileNote)(noteParam.value);
if (res.code == 200) {
ElMessage.success('笔记添加成功');
showAddNoteDialog.value = false;
noteMsg.value = '';
await loadFileContent();
} else ElMessage.error('笔记添加失败');
} catch (error: any) { ElMessage.error(error?.message || '未知错误'); }
}
};
const displayFileNote = () => {
const noteElements = document.querySelectorAll('span[id^="note-"]');
noteElements.forEach((element) => { element.classList.remove('note-flag-active'); });
let noteContentElement = document.getElementById('note-content');
if (noteContentElement) noteContentElement.style.display = 'none';
const previousHighlights = document.querySelectorAll('.highlight');
previousHighlights.forEach(highlight => {
const parent = highlight.parentNode!;
const textNode = document.createTextNode(highlight.textContent || '');
parent.replaceChild(textNode, highlight);
parent.normalize();
});
};
const showFileNote = (note: any, isPosition: boolean = false) => {
displayFileNote();
if (!note) return;
let noteElement = document.getElementById('note-' + (activeNoteIndex.value + 1));
let noteContentEl = document.getElementById('note-content');
let fileContentEl = document.getElementById("file-content");
if (!fileContentEl) return;
const rectFileContent = fileContentEl.getBoundingClientRect();
if (noteElement) {
noteElement.classList.add('note-flag-active');
highlightSelectedWord(note);
if (isPosition) {
let selectedElements = document.querySelectorAll('mark[class^="highlight"]');
if (selectedElements.length > 0) selectedElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
noteContent.value = note.noteContent;
const rectNoteElement = noteElement.getBoundingClientRect();
if (noteContentEl) {
noteContentEl.innerHTML = noteContent.value;
noteContentEl.style.display = 'block';
let rectNoteContentElement = noteContentEl.getBoundingClientRect();
let left = rectNoteElement.left - 10;
if (left + rectNoteContentElement.width > rectFileContent.width) left = rectFileContent.width - rectNoteContentElement.width;
noteContentEl.style.left = left + 'px';
noteContentEl.style.top = rectNoteElement.top - rectNoteElement.height - 8 + 'px';
}
}
};
const highlightSelectedWord = (note: any) => {
if (!note) return;
let marked = new Mark('#file-html-content');
marked.markRanges([{ start: note.startIndex, length: note.endIndex - note.startIndex }], {
className: 'highlight', exclude: ['.note-flag']
});
};
const removeHighlightSelectedWord = () => {
const previousHighlights = document.querySelectorAll('.highlight');
previousHighlights.forEach(highlight => {
const parent = highlight.parentNode!;
const textNode = document.createTextNode(highlight.textContent || '');
parent.replaceChild(textNode, highlight);
});
};
const handelNoteFlagMouseEvent = () => {
const noteElements = document.querySelectorAll('span[id^="note-"]');
noteElements.forEach((element) => {
element.addEventListener('mouseover', () => {
showFileNoteEvent.value = 'mouseover';
let elementId = Number(element.id.replace('note-', ''));
for (let i = 0; i < fileNote.notes.length; i++) {
if (i + 1 == elementId) { activeNoteIndex.value = i; showFileNote(fileNote.notes[i]); break; }
}
});
element.addEventListener('mouseleave', () => {
if (showFileNoteEvent.value === 'mouseover') { activeNoteIndex.value = -1; displayFileNote(); }
});
element.addEventListener('click', async (e) => {
showFileNoteEvent.value = 'click';
let elementId = Number(element.id.replace('note-', ''));
for (let i = 0; i < fileNote.notes.length; i++) {
if (i + 1 == elementId) { activeNoteIndex.value = i; break; }
}
readingBox.value?.changeHead(3);
await nextTick();
let rightNoteElement = document.getElementById('right-note-' + elementId);
if (rightNoteElement) rightNoteElement.scrollIntoView({ behavior: 'smooth' });
showFileNote(fileNote.notes[activeNoteIndex.value]);
e.stopPropagation();
});
});
};
// ========================================
// 文件夹操作
// ========================================
const createFolder = (item: any = null) => {
if (item) {
submitFolderParam.id = item.id;
submitFolderParam.name = item.label || item.name;
folderDialogTitle.value = "重命名文件夹";
} else {
submitFolderParam.id = 0;
submitFolderParam.name = "";
folderDialogTitle.value = "新建文件夹";
}
folderDialogVisible.value = true;
};
const submitFolderName = async () => {
if (submitFolderParam.name.trim().length === 0) {
ElMessage.warning("请输入文件夹名称");
return;
}
try {
if (submitFolderParam.id !== 0) {
let res = await withLoading(editKnowledgeBase<any>)({
id: submitFolderParam.id + "", name: submitFolderParam.name.trim(), folderType: "0"
});
if (res?.code === 200) ElMessage.success("重命名成功");
else ElMessage.error(res.msg);
} else {
let res = await withLoading(addKnowledgeBase<any>)({ name: submitFolderParam.name.trim() });
if (res?.code === 200) ElMessage.success("文件夹创建成功");
else ElMessage.error(res.msg);
}
folderDialogVisible.value = false;
await loadTreeData();
} catch (error: any) { ElMessage.error(error?.message || '未知错误'); }
};
const delFolder = async (item: any) => {
ElMessageBox.confirm("确定删除该文件夹?", "提示", {
confirmButtonText: "确定", cancelButtonText: "取消", type: "warning",
}).then(async () => {
try {
let res = await withLoading(delKnowledgeBase<any>)({ knowledgeBaseId: item.id + "", folderType: "0" });
if (res?.code === 200) { ElMessage.success("删除成功"); await loadTreeData(); }
else ElMessage.error(res.msg);
} catch (error: any) { ElMessage.error(error?.message || '未知错误'); }
}).catch(() => {});
};
const delBatch = () => {
ElMessage.info("请使用文件夹或文件旁的操作菜单进行删除");
};
// ========================================
// 文件操作
// ========================================
const renameFile = (data: any) => {
const filename = data.label || data.raw?.filename;
if (filename) {
let names = filename.split('.');
renameDocName.value = names[0];
renameDocType.value = names.length >= 2 ? names[names.length - 1] : '';
}
renameDocId.value = data.id;
showRenameDialog.value = true;
};
const saveDocName = async () => {
if (!renameDocName.value.trim()) { ElMessage.error("请输入文件名"); return; }
let param = new FormData();
param.append('fileId', renameDocId.value + '');
param.append('fileName', renameDocName.value + (renameDocType.value ? '.' + renameDocType.value : ''));
param.append('folderId', '0');
try {
await withLoading(editFile)(param);
ElMessage.success("修改成功");
showRenameDialog.value = false;
await refreshCurrentFolder();
} catch { ElMessage.error("修改失败"); }
};
const downLoadDoc = async (data: any) => {
try {
const response = await downloadFile({ fileId: data.id });
const url = URL.createObjectURL(new Blob([response]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', data.label || data.raw?.filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error: any) { ElMessage.error(error?.message || '下载失败'); }
};
const delFile = (data: any) => {
ElMessageBox.confirm("确认删除文档吗?", "提示", {
confirmButtonText: "确定", cancelButtonText: "取消", type: "warning",
}).then(async () => {
try {
let res = await withLoading(delFiles)({ fileList: [data.id], folderList: [] });
if (res?.code === 200) {
ElMessage.success("删除成功");
if (selectedFile.value?.fileId === data.id) { selectedFile.value = null; docHtml.value = ''; }
await refreshCurrentFolder();
} else ElMessage.error("删除失败");
} catch { ElMessage.error("删除失败"); }
}).catch(() => {});
};
const refreshCurrentFolder = async () => {
// 重新加载所有已展开的文件夹
for (let folder of treeData.value) {
if (folder.loaded) {
folder.loaded = false;
await loadFolderFiles(folder);
}
}
};
// ========================================
// 上传文件
// ========================================
const uploadToFolder = async (data: any) => {
uploadFolderId.value = data.id;
await getUploadedSize();
showUploadDialog.value = true;
if (uploadFinishFlag.value) uploadingFileList.value = [];
};
const getUploadedSize = async () => {
try {
let res = await getSize();
if (res.code === 200) totalFileSize.value = res.data;
} catch {}
};
const handleFileChange = async (file: UploadFile) => {
const fileExtension = file.name.split('.').pop()?.toUpperCase();
if (!allowFileTypes.includes(fileExtension as string)) {
ElMessage.warning(file.name + '文件格式不匹配');
return;
}
if (file.size && file.size > FILE_MAX_SIZE) {
ElMessage.warning(file.name + '文件大小已超过10M');
return;
}
totalFileSize.value += file.size || 0;
if (totalFileSize.value > TOTAL_FILE_MAX_SIZE) {
totalFileSize.value -= file.size || 0;
ElMessage.warning('个人素材总量不能超过50M');
return;
}
uploadingFileList.value.push(file as CusUploadFile);
};
watch(uploadingFileList, newVal => {
if (!isUploading.value) {
for (let i = 0; i < newVal.length; i++) {
if (newVal[i].status === 'ready') {
isUploading.value = true;
newVal[i].status = 'uploading';
startUpload();
break;
}
}
}
}, { immediate: true, deep: true });
const handleRemove = (file: UploadFile) => {
uploadingFileList.value = uploadingFileList.value.filter(item => item.uid !== file.uid);
};
const startUpload = async () => { await uploadNextFile(); };
const uploadNextFile = async () => {
uploadFinishFlag.value = true;
for (let i = 0; i < uploadingFileList.value.length; i++) {
if (uploadingFileList.value[i].status !== 'success' && uploadingFileList.value[i].status !== 'fail') {
uploadFinishFlag.value = false;
}
}
if (uploadFinishFlag.value) { isUploading.value = false; return; }
const file = uploadingFileList.value[currentIndex.value];
file.status = 'uploading';
const formData = new FormData();
formData.append('files', file.raw as Blob);
formData.append('folderId', '0');
formData.append('knowledgeBaseId', uploadFolderId.value + '');
try {
let res = await uploadFile(formData);
if (res.code && res.code != 200) {
file.status = 'fail';
(file as CusUploadFile).message = res.msg;
} else {
file.status = 'success';
}
} catch (error: any) {
file.status = 'fail';
(file as CusUploadFile).message = error?.message || '未知错误';
} finally {
currentIndex.value++;
await uploadNextFile();
}
};
const closeUpload = () => {
showUploadDialog.value = false;
currentIndex.value = 0;
refreshCurrentFolder();
};
// ========================================
// 工具函数
// ========================================
const getViewportOffsetByEvent = (evt: MouseEvent) => {
return { left: evt.clientX, top: evt.clientY };
};
// ========================================
// 生命周期
// ========================================
onMounted(async () => {
await loadTreeData();
// 自动展开所有文件夹的文件
for (let folder of treeData.value) {
await loadFolderFiles(folder);
}
document.addEventListener('mousedown', (event: MouseEvent) => {
let execlute = document.getElementById('shortMenuDom');
if (execlute && execlute.contains(event.target as Node)) { /* noop */ }
else disShowShortMenu();
});
});
</script>
<style lang="scss" scoped>
.reading-layout {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
// ========== 左栏 ==========
.left-panel {
width: 280px;
min-width: 280px;
height: 100%;
background: #fff;
border-right: 1px solid #E6EDFF;
display: flex;
flex-direction: column;
.left-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 8px 16px;
.left-title {
font-weight: bold;
font-size: 16px;
color: #000;
}
.action-icon {
width: 18px;
height: 18px;
cursor: pointer;
opacity: 0.7;
&:hover { opacity: 1; }
}
}
.left-search {
padding: 0 12px 8px 12px;
}
.left-tree {
flex: 1;
overflow-y: auto;
padding: 0 4px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #D5DDFF; border-radius: 2px; }
:deep(.el-tree-node__content) {
height: auto;
padding: 2px 0;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 8px;
border-radius: 6px;
&:hover {
background: #F0F4FF;
.tree-node-actions { opacity: 1; }
}
&.is-selected-file {
background: #E0EAFF;
}
.tree-node-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.folder-icon {
width: 20px;
height: 18px;
margin-right: 6px;
}
.tree-file-icon {
font-size: 14px;
margin-right: 6px;
}
.tree-label {
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
}
.tree-node-actions {
opacity: 0;
margin-left: 4px;
.tree-operate-icon {
width: 12px;
cursor: pointer;
padding: 4px;
}
}
}
}
.left-footer {
padding: 8px 12px;
border-top: 1px solid #E6EDFF;
.batch-del-btn {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
border: 1px solid #004EA0;
border-radius: 4px;
cursor: pointer;
color: #004EA0;
font-size: 13px;
img { width: 14px; margin-right: 4px; }
&:hover { background: #F0F4FF; }
}
}
}
// ========== 中间栏 ==========
.center-panel {
flex: 1;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
.center-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
.placeholder-content {
text-align: center;
color: #999;
p { margin-top: 12px; font-size: 14px; }
}
}
.center-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.center-header {
padding: 12px 20px;
border-bottom: 1px solid #E6EDFF;
background: #fff;
.center-title {
font-weight: bold;
font-size: 16px;
color: #000;
}
}
.file-content {
flex: 1;
overflow: auto;
background: #FFFFFF;
margin: 10px;
box-shadow: 0px 0px 8px 1px rgba(180, 189, 221, 0.56);
border-radius: 12px;
position: relative;
.view-md {
padding: 24px;
position: relative;
:deep(p) {
font-size: 16px;
line-height: 1.8rem;
margin-block-start: 0;
}
:deep(.highlight) { background: #D0EAC8; }
:deep(.note-flag) {
width: 23px; height: 28px; line-height: 28px;
display: inline-block; text-align: center;
font-weight: bold; font-size: 10px; margin-left: 8px; cursor: pointer;
background: url("@/assets/images/reading/note.png");
color: #004EA0;
background-size: contain !important;
background-repeat: no-repeat !important;
background-position: center bottom !important;
}
:deep(.note-flag:hover) {
color: #FAFBFF;
background: url("@/assets/images/reading/note_active.png");
}
:deep(.note-flag-active) {
color: #FAFBFF;
background: url("@/assets/images/reading/note_active.png");
}
}
.file-note {
max-width: 240px; height: 36px; line-height: 36px;
padding: 0 12px; position: absolute; display: none;
background: #004EA0;
box-shadow: 0px 0px 8px 1px rgba(180, 189, 221, 0.56);
border-radius: 8px; border: 1px solid #D5DDFF;
font-size: 14px; color: #FFFFFF;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
}
}
}
// ========== 右栏 ==========
.right-panel {
width: 400px;
min-width: 400px;
height: calc(100% - 20px);
margin: 10px 10px 10px 0;
background: linear-gradient(180deg, rgba(178, 226, 255, 0.4) 0%, rgba(106, 148, 255, 0.4) 100%);
box-shadow: 0 0 8px 1px rgba(180, 189, 221, 0.56);
border-radius: 16px;
overflow: hidden;
.right-placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.placeholder-content {
text-align: center;
color: #999;
.placeholder-title { margin-top: 12px; font-size: 16px; font-weight: bold; color: #666; }
.placeholder-sub { margin-top: 4px; font-size: 13px; }
}
}
}
// ========== 右键菜单 ==========
.click-bar-button {
height: 36px; padding: 0 4px;
background: #FFFFFF;
box-shadow: 0px 0px 8px 1px rgba(180, 189, 221, 0.56);
border-radius: 8px; border: 1px solid #D5DDFF;
display: flex; justify-content: space-evenly; align-items: center;
position: fixed; z-index: 9999; font-size: 14px;
.menu-name { line-height: 36px; cursor: pointer; padding: 0 12px; }
.menu-name:hover { color: #004EA0; }
.vertical-line { height: 16px; width: 2px; background-color: #D5DDFF; }
}
// ========== 上传弹框 ==========
.upload-demo {
width: 100%; text-align: center;
.el-upload__tip { text-align: center; margin-top: 12px; }
}
.files-upload-result {
max-height: 210px; overflow-y: auto; margin-top: 16px;
.result {
height: 30px; display: flex; justify-content: space-between; align-items: center;
.file-name {
width: 434px; font-size: 14px; color: #606771;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-left: 10px;
}
.result-info {
display: flex; align-items: center;
.fail-msg { width: 192px; text-align: right; font-size: 12px; color: #BD0000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 8px; }
.loading { width: 38px; height: 38px; }
img { width: 14px; margin-right: 10px; }
.upload-remove { cursor: pointer; }
}
}
}
.dialog-tips {
font-size: 12px; color: #858A94; margin-top: 8px;
display: flex; align-items: center; justify-content: center;
img { width: 12px; height: 12px; margin-right: 3px; }
}
</style>