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

975 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="reading-layout">
<!-- 左栏文件管理树 -->
<div class="left-panel" :style="{ width: leftCollapsed ? '0px' : leftWidth + 'px' }" v-show="!leftCollapsed">
<div class="panel-card">
<div class="left-header">
<div class="left-title">文件管理</div>
<div class="left-actions">
<div class="create-folder-btn" @click="createFolder()" title="新建文件夹">
<img src="@/assets/images/reading/create.png" alt="">
<span>新建</span>
</div>
<span class="collapse-btn" title="收起" @click="leftCollapsed = true">«</span>
</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="treeId"
:expand-on-click-node="false"
:default-expand-all="false"
:default-expanded-keys="expandedKeys"
highlight-current
:show-checkbox="batchMode"
@node-click="handleNodeClick"
@check-change="handleCheckChange"
>
<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" @click.stop>
<el-dropdown trigger="click" size="small">
<img src="@/assets/images/reading/operates.png" class="tree-operate-icon">
<template #dropdown>
<el-dropdown-menu>
<template v-if="data.isFolder">
<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>
</template>
<template v-else>
<el-dropdown-item @click="renameFile(data)">重命名</el-dropdown-item>
<el-dropdown-item @click="downLoadDoc(data)">下载</el-dropdown-item>
<el-dropdown-item @click="delFileItem(data)" style="color:#BE0000">删除</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
</el-tree>
</div>
<div class="left-footer">
<div v-if="!batchMode" class="batch-del-btn" @click="batchMode = true">
<img src="@/assets/images/reading/del.png" alt="">
<span>批量删除</span>
</div>
<div v-else style="display:flex;gap:8px;">
<div class="batch-del-btn batch-confirm" @click="delBatch">
<span>确认删除</span>
</div>
<div class="batch-del-btn" @click="batchMode = false">
<span>取消</span>
</div>
</div>
</div>
</div>
</div>
<!-- 左栏展开按钮折叠时显示 -->
<div class="expand-btn" v-if="leftCollapsed" @click="leftCollapsed = false" title="展开文件管理">»</div>
<!-- 左分隔条可拖拽 -->
<div class="splitter" v-show="!leftCollapsed" @mousedown="startResizeLeft"></div>
<!-- 中间栏文件预览 -->
<div class="center-panel">
<div class="panel-card">
<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 v-if="fileType === 'pdf'" class="view-mode-toggle">
<span :class="{ active: !readingMode }" @click="readingMode = false">预览</span>
<span class="mode-sep">|</span>
<span :class="{ active: readingMode }" @click="switchToReadingMode">阅读(笔记)</span>
</div>
</div>
<!-- PDF 原生渲染 -->
<div v-if="fileType === 'pdf' && !readingMode" class="file-content" ref="fileContent" id="file-content">
<PdfViewer v-if="pdfBytes" :src="pdfBytes" :scale="1.3" />
</div>
<!-- HTML 阅读模式PDF 阅读模式 + 非PDF文件 -->
<div v-else 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>
</div>
<!-- 右分隔条可拖拽 -->
<div class="splitter" @mousedown="startResizeRight"></div>
<!-- 右栏ReadingBox -->
<div class="right-panel" :style="{ width: rightWidth + 'px' }">
<div class="panel-card right-card">
<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>
<!-- 右键菜单 -->
<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" @click="handleRemove(item)">
<div class="loading" v-if="item.status === 'uploading'"><Loading /></div>
<img v-if="item.status === 'success'" src="@/assets/images/reading/upload_success.png">
<img v-if="item.status === 'fail'" src="@/assets/images/reading/upload_fail.png">
</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"><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, getFileGuide
} from "@/api";
import {withLoading} from "@/utils/loading";
import {copyToClip, getGlobalSelectionPosition} from "@/utils";
import {transforMd} from "@/utils/markdown";
import ReadingBox from "@/components/ReadingBox.vue";
import PdfViewer from "@/components/PdfViewer.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";
import axios from '@/utils/request/axios';
// ===================== 面板尺寸与折叠 =====================
const leftWidth = ref(280);
const rightWidth = ref(420);
const leftCollapsed = ref(false);
let resizingPanel = '' as '' | 'left' | 'right';
let startX = 0;
let startWidth = 0;
const startResizeLeft = (e: MouseEvent) => {
resizingPanel = 'left';
startX = e.clientX;
startWidth = leftWidth.value;
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', stopResize);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const startResizeRight = (e: MouseEvent) => {
resizingPanel = 'right';
startX = e.clientX;
startWidth = rightWidth.value;
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', stopResize);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const onResize = (e: MouseEvent) => {
if (resizingPanel === 'left') {
const newWidth = startWidth + (e.clientX - startX);
leftWidth.value = Math.max(200, Math.min(500, newWidth));
} else if (resizingPanel === 'right') {
const newWidth = startWidth - (e.clientX - startX);
rightWidth.value = Math.max(300, Math.min(600, newWidth));
}
};
const stopResize = () => {
resizingPanel = '';
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', stopResize);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
// ===================== 文件树数据 =====================
const treeRef = ref(null);
const searchWord = ref('');
const treeData = ref<any[]>([]);
const treeProps = { children: 'children', label: 'label' };
const checkedNodes = ref<any[]>([]);
const batchMode = ref(false);
// ===================== 选中文件 =====================
const selectedFile = ref<any>(null);
provide('selectedFile', selectedFile);
// ===================== 文件预览 =====================
const docHtml = ref('');
const fileContent = ref(null);
const readingBox = ref(null);
const pdfBytes = ref<Uint8Array | null>(null); // 存原始字节,不会被 detach
const readingMode = ref(false);
const fileType = computed(() => {
const name = selectedFile.value?.fileName || '';
return name.split('.').pop()?.toLowerCase() || '';
});
const switchToReadingMode = async () => {
readingMode.value = true;
// 如果还没加载 HTML 内容,加载一下
if (!docHtml.value) {
await loadFileContent();
}
await nextTick();
bindFileContentEvents();
handelNoteFlagMouseEvent();
};
// ===================== 笔记 =====================
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;
// ========================================
// 文件树加载、过滤、checkbox
// ========================================
let treeIdCounter = 0;
const loadTreeData = async () => {
try {
let res = await getKnowledgeBaseList<any>({ folderType: "0" });
if (res && res.data) {
const folders = res.data.map((f: any) => ({
treeId: 'folder-' + f.id,
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) => ({
treeId: 'file-' + f.id,
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) => ({
...folder,
children: folder.children?.filter((f: any) =>
f.label.toLowerCase().includes(keyword) || folder.label.toLowerCase().includes(keyword)
)
}));
});
const expandedKeys = computed(() => {
if (!searchWord.value.trim()) return [];
return filteredTreeData.value.map((f: any) => f.treeId);
});
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
};
// 从 API 获取最新的导读数据(后台线程可能已更新 MySQL
try {
const res = await getFileGuide(doc.id + '');
if (res?.code === 200 && res.data) {
const fresh = res.data;
if (fresh.articleAbstract) { selectedFile.value.articleAbstract = fresh.articleAbstract; data.raw.articleAbstract = fresh.articleAbstract; }
if (fresh.articleKeywords) { selectedFile.value.articleKeywords = fresh.articleKeywords; data.raw.articleKeywords = fresh.articleKeywords; }
if (fresh.articleParagraph) { selectedFile.value.articleParagraph = fresh.articleParagraph; data.raw.articleParagraph = fresh.articleParagraph; }
}
} catch {}
// 根据文件类型加载内容
readingMode.value = false;
const ext = doc.filename?.split('.').pop()?.toLowerCase() || '';
if (ext === 'pdf') {
await loadPdfFile();
} else {
pdfBytes.value = null;
await loadFileContent();
}
};
const loadPdfFile = async () => {
if (!selectedFile.value) return;
docHtml.value = '';
try {
const resp = await axios.get('/gpt/file/downloadFile', {
params: { fileId: selectedFile.value.fileId },
responseType: 'arraybuffer'
});
pdfBytes.value = new Uint8Array(resp.data as ArrayBuffer);
} catch (e: any) {
pdfBytes.value = null;
docHtml.value = '<p style="color:#999;text-align:center;margin-top:40px;">PDF 文件加载失败</p>';
}
// 同时加载 HTML 用于笔记功能(后台)
try {
let res = await getFileContent({
fileId: selectedFile.value.fileId,
embeddingId: selectedFile.value.embeddingId,
knowledgeBaseId: selectedFile.value.folderId
});
if (res?.code === 200 && res.data) {
fileNote.notes = res.data.notes || [];
if (res.data.content) {
// 保存 HTML 内容供阅读模式使用
let content = res.data.content;
content = content.replace(pattern, (match: string, _cg: string, offset: number) => {
return transforMd(match);
});
docHtml.value = content.replace(/<p>(.*?<span class="katex">.*?<\/span>.*?)<\/p>/g, '$1');
}
}
} catch {}
// 绑定 PDF text layer 的选择事件
await nextTick();
setTimeout(() => {
if (fileContent.value) {
fileContent.value.addEventListener('mouseup', (event: MouseEvent) => {
setTimeout(() => {
const sel = window.getSelection(); if (!sel) return;
selectText.value = sel.toString();
if (selectText.value && shortMenuDom.value) {
shortMenuShow.value = true;
(shortMenuDom.value as HTMLElement).style.left = event.clientX + 'px';
(shortMenuDom.value as HTMLElement).style.top = event.clientY + 'px';
}
});
});
}
}, 500);
};
const handleCheckChange = () => {
if (treeRef.value) {
checkedNodes.value = (treeRef.value as any).getCheckedNodes();
}
};
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 {
docHtml.value = '<p style="color:#999;text-align:center;margin-top:40px;">文件内容获取失败</p>';
}
await nextTick();
try { handelLaTeXElements(offsets); handelCommentElements(); bindFileContentEvents(); handelNoteFlagMouseEvent(); } catch {}
};
provide('updateFileContent', async () => { await loadFileContent(); });
const handelLaTeXElements = (offsets: number[]) => {
let els = document.querySelectorAll('span[class="katex"]');
for (let i = 0; i < els.length; i++) els[i].setAttribute('id', 'katex-' + offsets[i]);
};
const handelCommentElements = () => {
document.querySelectorAll('a[href^="#comment-"]').forEach(el => {
let targetId = el.getAttribute('href');
(el as HTMLElement).onclick = (e) => { e.preventDefault(); document.querySelector(targetId!)?.scrollIntoView({ behavior: 'smooth' }); };
});
};
// ========================================
// 右键菜单
// ========================================
const fileContentMenu = (index: number) => {
disShowShortMenu();
if (index === 0) { quoteMsg.value = selectText.value?.slice(0, 1000) || ''; readingBox.value?.changeHead(2); }
if (index === 1) { copyToClip(selectText.value); ElMessage.success("复制成功"); }
if (index === 2) { readingBox.value?.changeHead(2); readingBox.value?.readingBoxSend("我想请你解释一下这个词语的含义:" + selectText.value, '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', (event: MouseEvent) => {
activeNoteIndex.value = -1; displayFileNote(); removeHighlightSelectedWord();
setTimeout(() => {
const sel = window.getSelection(); if (!sel) return;
selectText.value = sel.toString();
positionInfo.value = getGlobalSelectionPosition(sel);
if (selectText.value && shortMenuDom.value && fileContent.value?.contains(sel.anchorNode)) {
shortMenuShow.value = true;
(shortMenuDom.value as HTMLElement).style.left = event.clientX + 'px';
(shortMenuDom.value as HTMLElement).style.top = event.clientY + 'px';
}
});
});
fileContent.value.addEventListener('scroll', () => {
let noteEl = document.getElementById('note-' + (activeNoteIndex.value + 1));
let ncEl = document.getElementById('note-content');
if (noteEl && ncEl) {
let top = noteEl.getBoundingClientRect().top - noteEl.getBoundingClientRect().height - 8;
ncEl.style.top = top + 'px';
ncEl.style.display = top < 75 ? 'none' : (activeNoteIndex.value != -1 ? 'block' : 'none');
}
});
};
// ========================================
// 笔记功能
// ========================================
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 (e: any) { ElMessage.error(e?.message || '未知错误'); }
} else {
Object.assign(noteParam.value, { fileId: selectedFile.value?.fileId, pstartId: positionInfo.value.parentStart || '', pendId: positionInfo.value.parentEnd || '', wordContent: selectText.value, startIndex: positionInfo.value.start, endIndex: positionInfo.value.end, 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 (e: any) { ElMessage.error(e?.message || '未知错误'); }
}
};
const displayFileNote = () => {
document.querySelectorAll('span[id^="note-"]').forEach(el => el.classList.remove('note-flag-active'));
let ncEl = document.getElementById('note-content'); if (ncEl) ncEl.style.display = 'none';
document.querySelectorAll('.highlight').forEach(hl => { const p = hl.parentNode!; p.replaceChild(document.createTextNode(hl.textContent || ''), hl); p.normalize(); });
};
const showFileNote = (note: any, isPosition = false) => {
displayFileNote(); if (!note) return;
let noteEl = document.getElementById('note-' + (activeNoteIndex.value + 1));
let ncEl = document.getElementById('note-content');
let fcEl = document.getElementById("file-content"); if (!fcEl) return;
const rcFC = fcEl.getBoundingClientRect();
if (noteEl) {
noteEl.classList.add('note-flag-active');
new Mark('#file-html-content').markRanges([{ start: note.startIndex, length: note.endIndex - note.startIndex }], { className: 'highlight', exclude: ['.note-flag'] });
if (isPosition) { let sels = document.querySelectorAll('mark[class^="highlight"]'); if (sels.length > 0) sels[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); }
noteContent.value = note.noteContent;
const rn = noteEl.getBoundingClientRect();
if (ncEl) { ncEl.innerHTML = noteContent.value; ncEl.style.display = 'block'; let rnc = ncEl.getBoundingClientRect(); let left = rn.left - 10; if (left + rnc.width > rcFC.width) left = rcFC.width - rnc.width; ncEl.style.left = left + 'px'; ncEl.style.top = rn.top - rn.height - 8 + 'px'; }
}
};
const removeHighlightSelectedWord = () => { document.querySelectorAll('.highlight').forEach(hl => { const p = hl.parentNode!; p.replaceChild(document.createTextNode(hl.textContent || ''), hl); }); };
const handelNoteFlagMouseEvent = () => {
document.querySelectorAll('span[id^="note-"]').forEach(el => {
el.addEventListener('mouseover', () => { showFileNoteEvent.value = 'mouseover'; let eid = Number(el.id.replace('note-', '')); for (let i = 0; i < fileNote.notes.length; i++) if (i + 1 == eid) { activeNoteIndex.value = i; showFileNote(fileNote.notes[i]); break; } });
el.addEventListener('mouseleave', () => { if (showFileNoteEvent.value === 'mouseover') { activeNoteIndex.value = -1; displayFileNote(); } });
el.addEventListener('click', async (e) => { showFileNoteEvent.value = 'click'; let eid = Number(el.id.replace('note-', '')); for (let i = 0; i < fileNote.notes.length; i++) if (i + 1 == eid) { activeNoteIndex.value = i; break; } readingBox.value?.changeHead(3); await nextTick(); document.getElementById('right-note-' + eid)?.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()) { 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(); for (let f of treeData.value) await loadFolderFiles(f);
} catch (e: any) { ElMessage.error(e?.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(); for (let f of treeData.value) await loadFolderFiles(f); } else ElMessage.error(res.msg); } catch (e: any) { ElMessage.error(e?.message || '未知错误'); }
}).catch(() => {});
};
const delBatch = async () => {
if (!treeRef.value) return;
const checked = (treeRef.value as any).getCheckedNodes();
const fileIds = checked.filter((n: any) => !n.isFolder).map((n: any) => n.id);
const folderIds = checked.filter((n: any) => n.isFolder).map((n: any) => n.id);
if (fileIds.length === 0 && folderIds.length === 0) { ElMessage.warning("请先勾选要删除的文件或文件夹"); return; }
ElMessageBox.confirm(`确定删除选中的 ${folderIds.length} 个文件夹和 ${fileIds.length} 个文件?`, "提示", { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }).then(async () => {
try {
if (fileIds.length > 0) await delFiles({ fileList: fileIds, folderList: [] });
for (const fid of folderIds) await delKnowledgeBase<any>({ knowledgeBaseId: fid + "", folderType: "0" });
ElMessage.success("删除成功");
if (selectedFile.value && fileIds.includes(selectedFile.value.fileId)) { selectedFile.value = null; docHtml.value = ''; }
await loadTreeData(); for (let f of treeData.value) await loadFolderFiles(f);
} catch { ElMessage.error("删除失败"); }
}).catch(() => {});
};
// ========================================
// 文件操作
// ========================================
const renameFile = (data: any) => {
const fn = data.label || data.raw?.filename;
if (fn) { let ns = fn.split('.'); renameDocName.value = ns[0]; renameDocType.value = ns.length >= 2 ? ns[ns.length - 1] : ''; }
renameDocId.value = data.id; showRenameDialog.value = true;
};
const saveDocName = async () => {
if (!renameDocName.value.trim()) { ElMessage.error("请输入文件名"); return; }
let p = new FormData(); p.append('fileId', renameDocId.value + ''); p.append('fileName', renameDocName.value + (renameDocType.value ? '.' + renameDocType.value : '')); p.append('folderId', '0');
try { await withLoading(editFile)(p); ElMessage.success("修改成功"); showRenameDialog.value = false; await refreshFolders(); } catch { ElMessage.error("修改失败"); }
};
const downLoadDoc = async (data: any) => {
try { const resp = await downloadFile({ fileId: data.id }); const url = URL.createObjectURL(new Blob([resp])); const a = document.createElement('a'); a.href = url; a.setAttribute('download', data.label || data.raw?.filename); document.body.appendChild(a); a.click(); document.body.removeChild(a); } catch (e: any) { ElMessage.error(e?.message || '下载失败'); }
};
const delFileItem = (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 refreshFolders(); } else ElMessage.error("删除失败"); } catch { ElMessage.error("删除失败"); }
}).catch(() => {});
};
const refreshFolders = async () => {
for (let f of treeData.value) if (f.loaded) { f.loaded = false; await loadFolderFiles(f); }
};
// ========================================
// 上传文件
// ========================================
const uploadToFolder = async (data: any) => { uploadFolderId.value = data.id; try { let r = await getSize(); if (r.code === 200) totalFileSize.value = r.data; } catch {} showUploadDialog.value = true; if (uploadFinishFlag.value) uploadingFileList.value = []; };
const handleFileChange = async (file: UploadFile) => {
const ext = file.name.split('.').pop()?.toUpperCase();
if (!allowFileTypes.includes(ext 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, nv => {
if (!isUploading.value) for (let i = 0; i < nv.length; i++) if (nv[i].status === 'ready') { isUploading.value = true; nv[i].status = 'uploading'; uploadNext(); break; }
}, { immediate: true, deep: true });
const handleRemove = (file: UploadFile) => { uploadingFileList.value = uploadingFileList.value.filter(i => i.uid !== file.uid); };
const uploadNext = async () => {
uploadFinishFlag.value = uploadingFileList.value.every(f => f.status === 'success' || f.status === 'fail');
if (uploadFinishFlag.value) { isUploading.value = false; return; }
const file = uploadingFileList.value[currentIndex.value]; file.status = 'uploading';
const fd = new FormData(); fd.append('files', file.raw as Blob); fd.append('folderId', '0'); fd.append('knowledgeBaseId', uploadFolderId.value + '');
try { let r = await uploadFile(fd); if (r.code && r.code != 200) { file.status = 'fail'; (file as CusUploadFile).message = r.msg; } else file.status = 'success'; }
catch (e: any) { file.status = 'fail'; (file as CusUploadFile).message = e?.message || '未知错误'; }
finally { currentIndex.value++; await uploadNext(); }
};
const closeUpload = () => { showUploadDialog.value = false; currentIndex.value = 0; refreshFolders(); };
// ========================================
// 生命周期
// ========================================
onMounted(async () => {
await loadTreeData();
for (let f of treeData.value) await loadFolderFiles(f);
document.addEventListener('mousedown', (e: MouseEvent) => {
let el = document.getElementById('shortMenuDom');
if (!el?.contains(e.target as Node)) disShowShortMenu();
});
});
</script>
<style lang="scss" scoped>
.reading-layout {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
padding: 10px;
gap: 0;
box-sizing: border-box;
}
.panel-card {
height: 100%;
background: #fff;
border-radius: 12px;
box-shadow: 0 0 8px 1px rgba(180, 189, 221, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
}
// ========== 左栏折叠/展开按钮 ==========
.expand-btn {
width: 20px;
height: 60px;
background: #fff;
border-radius: 0 8px 8px 0;
box-shadow: 2px 0 6px rgba(180, 189, 221, 0.3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #004EA0;
font-size: 14px;
font-weight: bold;
align-self: center;
&:hover { background: #F0F4FF; }
}
// ========== 分隔条 ==========
.splitter {
width: 6px;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:hover { background: rgba(0, 78, 160, 0.1); border-radius: 3px; }
&::after { content: ''; width: 2px; height: 30px; background: #D5DDFF; border-radius: 1px; }
}
// ========== 左栏 ==========
.left-panel {
min-width: 200px;
height: 100%;
flex-shrink: 0;
transition: width 0.2s;
.left-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 12px 6px 16px;
.left-title { font-weight: bold; font-size: 15px; color: #000; }
.left-actions {
display: flex; align-items: center; gap: 6px;
.create-folder-btn {
display: flex; align-items: center; gap: 3px;
padding: 3px 10px; border-radius: 4px;
background: #004EA0; color: #fff; font-size: 12px; cursor: pointer;
img { width: 14px; height: 14px; filter: brightness(10); }
&:hover { background: #003d80; }
}
.collapse-btn { font-size: 14px; cursor: pointer; color: #999; padding: 2px 4px; border-radius: 4px; &:hover { background: #F0F4FF; color: #004EA0; } }
}
}
.left-search { padding: 4px 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: 1px 0; }
:deep(.el-checkbox) { margin-right: 4px; }
.tree-node {
display: flex; align-items: center; justify-content: space-between; width: 100%;
padding: 4px 6px; 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: 18px; height: 16px; margin-right: 5px; }
.tree-file-icon { font-size: 13px; margin-right: 5px; }
.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; flex-shrink: 0; .tree-operate-icon { width: 10px; 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: 30px; flex: 1;
border: 1px solid #004EA0; border-radius: 4px; cursor: pointer; color: #004EA0; font-size: 13px;
img { width: 14px; margin-right: 4px; }
&:hover { background: #F0F4FF; }
&.batch-confirm { background: #BE0000; color: #fff; border-color: #BE0000; &:hover { background: #a00; } }
}
}
}
// ========== 中间栏 ==========
.center-panel {
flex: 1;
height: 100%;
min-width: 300px;
overflow: hidden;
.center-placeholder {
flex: 1; display: flex; align-items: center; justify-content: center; height: 100%;
.placeholder-content { text-align: center; color: #999; p { margin-top: 12px; font-size: 14px; } }
}
.center-content {
flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden;
.center-header {
padding: 10px 20px; border-bottom: 1px solid #E6EDFF;
display: flex; justify-content: space-between; align-items: center;
.center-title { font-weight: bold; font-size: 15px; color: #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.view-mode-toggle {
flex-shrink: 0; margin-left: 12px; font-size: 13px; color: #999;
span { cursor: pointer; padding: 2px 6px; border-radius: 3px; }
span.active { color: #004EA0; font-weight: bold; background: #E8F0FE; }
.mode-sep { cursor: default; color: #ddd; padding: 0 2px; }
}
}
.file-content {
flex: 1; overflow: auto; position: relative; padding: 0;
.view-md {
padding: 20px;
:deep(p) { font-size: 15px; 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: 0 0 8px 1px rgba(180,189,221,0.56); border-radius: 8px; border: 1px solid #D5DDFF; font-size: 14px; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
}
}
// ========== 右栏 ==========
.right-panel {
min-width: 300px;
height: 100%;
flex-shrink: 0;
.right-card {
background: linear-gradient(180deg, rgba(178, 226, 255, 0.4) 0%, rgba(106, 148, 255, 0.4) 100%);
}
.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: #fff;
box-shadow: 0 0 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; &: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>