975 lines
44 KiB
Vue
975 lines
44 KiB
Vue
<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>
|