Files
biji/biji-qianduan/src/components/HomePage.vue

661 lines
19 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>
<el-container class="home-page" :class="{'is-mobile': isMobile}">
<!-- Sidebar -->
<SidebarMenu
:is-collapsed="isCollapsed"
:is-mobile="isMobile"
:active-menu="activeMenu"
:category-tree="categoryTree"
@toggle-collapse="isCollapsed = !isCollapsed"
@show-create-group="showCreateGroupDialog = true"
@select-file="handleSelectFile"
@show-rename-dialog="openRenameDialog"
@group-deleted="handleGroupDeleted"
@show-system-settings="showSystemSettingsDialog = true"
@show-update-password="showUpdatePasswordDialog = true"
@logout="handleLogout"
/>
<!-- Main Content -->
<el-container>
<el-main class="content">
<!-- Note Editor View -->
<NoteEditor
v-if="showEditor"
:edit-data="editData"
@back="handleEditorBack"
@update:edit-data="handleSaveSuccess"
/>
<!-- Note Preview View -->
<NotePreview
v-else-if="selectedFile"
:file="selectedFile"
:is-mobile="isMobile"
:is-user-logged-in="userStore.isLoggedIn"
@back="selectedFile = null"
@edit="editNote(selectedFile)"
@delete="deleteNote(selectedFile)"
@open-rename-dialog="openRenameDialog"
@show-move-note-dialog="showMoveNoteDialog = true"
@show-privacy-dialog="showPrivacyDialog = true"
@export="handleExport"
/>
<!-- Home/List View -->
<div v-else class="list-view-container">
<HomeHeader
:is-mobile="isMobile"
v-model:search-keyword="searchKeyword"
@search="handleSearch"
@reset-view="resetToHomeView"
@logout="handleLogout"
@show-update-password="showUpdatePasswordDialog = true"
@show-system-settings="showSystemSettingsDialog = true"
@show-create-note="showCreateNoteDialog = true"
@upload-markdown="handleMarkdownUpload"
@toggle-collapse="isCollapsed = !isCollapsed"
/>
<div class="note-list-wrapper" ref="noteListWrapper" @scroll="handleScroll">
<NoteList
:files="displayedFiles"
:is-user-logged-in="userStore.isLoggedIn"
@preview="previewFile"
/>
<div v-if="isLoadingMore" class="loading-more">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="hasMoreFiles && !showEditor && !selectedFile" class="load-more-trigger">
<el-button @click="loadMoreFiles" type="primary" plain>加载更多</el-button>
</div>
</div>
</div>
</el-main>
</el-container>
<!-- Sidebar Overlay for Mobile -->
<div v-if="isMobile && !isCollapsed" class="sidebar-overlay" @click="isCollapsed = true"></div>
<!-- Mobile FAB for creating note -->
<el-button
v-if="isMobile && userStore.isLoggedIn && !showEditor && !selectedFile"
class="fab"
type="primary"
circle
@click="showCreateNoteDialog = true"
>
<el-icon><Plus /></el-icon>
</el-button>
<!-- Dialogs -->
<CreateGroupDialog
v-model:visible="showCreateGroupDialog"
:category-options="categoryTree"
@group-created="fetchGroupings"
/>
<CreateNoteDialog
v-model:visible="showCreateNoteDialog"
:category-options="categoryTree"
@create-note="handleCreateNote"
/>
<RenameDialog
v-model:visible="showRenameDialog"
:item="itemToRename"
@renamed="handleRenamed"
/>
<SelectGroupDialog
v-model:visible="showSelectGroupDialog"
:category-options="categoryTree"
:file-to-import="fileToImport"
@import-success="handleImportSuccess"
/>
<MoveNoteDialog
v-model:visible="showMoveNoteDialog"
:category-options="categoryTree"
:note-to-move="selectedFile"
@move-success="handleMoveSuccess"
/>
<SystemSettingsDialog v-model:visible="showSystemSettingsDialog" />
<UpdatePasswordDialog
v-model:visible="showUpdatePasswordDialog"
@password-updated="handleLogout"
/>
<PrivacyDialog
v-model:visible="showPrivacyDialog"
:note="selectedFile"
@privacy-changed="handlePrivacyChanged"
/>
</el-container>
</template>
<script setup>
import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import {
groupingAll,
markdownList,
Preview,
updateMarkdown,
searchMarkdown,
deleteMarkdown,
getRecentFiles,
validateToken,
} from '@/api/CommonApi.js';
import { useUserStore } from '../stores/user';
import { useRouter } from 'vue-router';
import { privateNoteContent } from '../utils/privateNoteContent.js';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
// Import Components
import SidebarMenu from './home/SidebarMenu.vue';
import HomeHeader from './home/HomeHeader.vue';
import NoteList from './home/NoteList.vue';
import NotePreview from './home/NotePreview.vue';
import NoteEditor from './home/NoteEditor.vue';
import CreateGroupDialog from './home/dialogs/CreateGroupDialog.vue';
import CreateNoteDialog from './home/dialogs/CreateNoteDialog.vue';
import RenameDialog from './home/dialogs/RenameDialog.vue';
import SelectGroupDialog from './home/dialogs/SelectGroupDialog.vue';
import MoveNoteDialog from './home/dialogs/MoveNoteDialog.vue';
import SystemSettingsDialog from './home/dialogs/SystemSettingsDialog.vue';
import UpdatePasswordDialog from './home/dialogs/UpdatePasswordDialog.vue';
import PrivacyDialog from './home/dialogs/PrivacyDialog.vue';
import { Plus, Loading } from "@element-plus/icons-vue";
// Basic Setup
const userStore = useUserStore();
const router = useRouter();
// State
const searchKeyword = ref('');
const categoryTree = ref([]);
const groupMarkdownFiles = ref([]);
const displayedFiles = ref([]);
const currentPage = ref(0);
const pageSize = ref(16);
const isLoadingMore = ref(false);
const noteListWrapper = ref(null);
const showEditor = ref(false);
const selectedFile = ref(null);
const editData = ref(null);
const activeMenu = ref('all');
const isCollapsed = ref(window.innerWidth < 768);
const isMobile = ref(window.innerWidth < 768);
// Dialog visibility
const showCreateGroupDialog = ref(false);
const showCreateNoteDialog = ref(false);
const showRenameDialog = ref(false);
const showSelectGroupDialog = ref(false);
const showMoveNoteDialog = ref(false);
const showSystemSettingsDialog = ref(false);
const showUpdatePasswordDialog = ref(false);
const showPrivacyDialog = ref(false);
// Data for dialogs
const itemToRename = ref(null);
const fileToImport = ref(null);
const hasMoreFiles = computed(() => {
return displayedFiles.value.length < groupMarkdownFiles.value.length;
});
const resetEdit = () => {
editData.value = null;
};
// --- Core Logic ---
// Data Fetching
const fetchGroupings = async () => {
try {
const allCategories = await groupingAll("");
categoryTree.value = buildTree(allCategories || []);
} catch (error) {
categoryTree.value = [];
}
};
const buildTree = (items) => {
const tree = [];
const itemMap = new Map();
items.forEach(item => {
itemMap.set(String(item.id), { ...item, children: [] });
});
itemMap.forEach(item => {
const parentId = String(item.parentId);
if (parentId !== '0' && itemMap.has(parentId)) {
itemMap.get(parentId).children.push(item);
} else {
tree.push(item);
}
});
const cleanTree = (nodes) => {
nodes.forEach(node => {
if (node.children.length === 0) delete node.children;
else cleanTree(node.children);
});
};
cleanTree(tree);
return tree;
};
const resetToHomeView = async () => {
resetEdit();
selectedFile.value = null;
showEditor.value = false;
searchKeyword.value = '';
activeMenu.value = 'all';
currentPage.value = 0;
try {
groupMarkdownFiles.value = await getRecentFiles(100) || [];
updateDisplayedFiles();
} catch (error) {
ElMessage.error('获取最近文件失败: ' + error.message);
groupMarkdownFiles.value = [];
displayedFiles.value = [];
}
};
const updateDisplayedFiles = () => {
const start = 0;
const end = (currentPage.value + 1) * pageSize.value;
displayedFiles.value = groupMarkdownFiles.value.slice(start, end);
};
const loadMoreFiles = () => {
if (isLoadingMore.value || !hasMoreFiles.value) return;
isLoadingMore.value = true;
setTimeout(() => {
currentPage.value++;
updateDisplayedFiles();
isLoadingMore.value = false;
}, 300);
};
const handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollHeight - scrollTop - clientHeight < 100 && hasMoreFiles.value && !isLoadingMore.value) {
loadMoreFiles();
}
};
// Event Handlers from Components
const handleSelectFile = async (data) => {
resetEdit();
try {
const files = await markdownList(data.id);
groupMarkdownFiles.value = files || [];
currentPage.value = 0;
updateDisplayedFiles();
selectedFile.value = null;
showEditor.value = false;
activeMenu.value = `group-${data.id}`;
} catch (error) {
ElMessage.error('获取笔记列表失败: ' + error.message);
groupMarkdownFiles.value = [];
displayedFiles.value = [];
}
};
const handleGroupDeleted = async () => {
await fetchGroupings();
await resetToHomeView();
};
const handleCreateNote = (payload) => {
resetEdit();
// 直接设置编辑数据ID将在第一次保存时由后端生成
editData.value = payload;
showEditor.value = true;
selectedFile.value = null; // Ensure preview is hidden
};
const handleEditorBack = (data) => {
showEditor.value = false;
if (data && data.id) {
const fileWithGrouping = {
...data,
groupingName: data.groupingName || getCategoryName(data.groupingId)
};
selectedFile.value = fileWithGrouping;
} else {
selectedFile.value = null;
resetToHomeView();
}
};
const getCategoryName = (groupId) => {
if (!groupId) return '';
const findName = (items) => {
for (const item of items) {
if (item.id === groupId) return item.grouping;
if (item.children) {
const name = findName(item.children);
if (name) return name;
}
}
return null;
};
return findName(categoryTree.value) || '';
};
const handleSaveSuccess = (updatedFile) => {
selectedFile.value = updatedFile;
const index = groupMarkdownFiles.value.findIndex(f => f.id === updatedFile.id);
if (index !== -1) {
groupMarkdownFiles.value[index] = updatedFile;
} else {
groupMarkdownFiles.value.unshift(updatedFile);
}
updateDisplayedFiles();
fetchGroupings();
};
const previewFile = async (file) => {
resetEdit();
if (!file || file.id === null) {
editData.value = file;
selectedFile.value = null;
return;
}
try {
const content = await Preview(file.id) || '';
selectedFile.value = { ...file, content };
showEditor.value = false;
} catch (error) {
ElMessage.error('获取笔记内容失败: ' + error.message);
selectedFile.value = null;
}
};
const editNote = (file) => {
editData.value = { ...file };
showEditor.value = true;
isCollapsed.value = true; // Collapse sidebar on mobile when editing
};
const deleteNote = (file) => {
ElMessageBox.confirm(`确定要删除笔记 “${file.title}” 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning',
}).then(async () => {
try {
await deleteMarkdown(file.id);
ElMessage.success('笔记已删除');
selectedFile.value = null;
await fetchGroupings();
await resetToHomeView();
} catch (error) {
ElMessage.error('删除笔记失败: ' + error.message);
}
});
};
const openRenameDialog = (item, type) => {
itemToRename.value = { ...item, type };
showRenameDialog.value = true;
};
const handleRenamed = async (newName) => {
await fetchGroupings();
if (selectedFile.value && itemToRename.value.type === 'file' && selectedFile.value.id === itemToRename.value.id) {
// 直接更新当前选中文件的标题
selectedFile.value.title = newName;
// 更新笔记列表中的对应项
const index = groupMarkdownFiles.value.findIndex(f => f.id === selectedFile.value.id);
if (index !== -1) {
groupMarkdownFiles.value[index].title = newName;
}
// 重新获取文件内容以确保是最新的
previewFile(selectedFile.value); // Refresh preview
} else {
resetToHomeView();
}
};
const handleMarkdownUpload = (file) => {
fileToImport.value = file;
showSelectGroupDialog.value = true;
};
const handleImportSuccess = async () => {
await fetchGroupings();
await resetToHomeView();
};
const handleMoveSuccess = async () => {
selectedFile.value = null;
await fetchGroupings();
await resetToHomeView();
};
const handlePrivacyChanged = (updatedFile) => {
selectedFile.value = updatedFile;
const index = groupMarkdownFiles.value.findIndex(f => f.id === updatedFile.id);
if (index !== -1) {
groupMarkdownFiles.value[index] = updatedFile;
}
};
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
await resetToHomeView();
return;
}
try {
groupMarkdownFiles.value = await searchMarkdown(searchKeyword.value) || [];
currentPage.value = 0;
updateDisplayedFiles();
selectedFile.value = null;
showEditor.value = false;
activeMenu.value = 'search';
} catch (error) {
ElMessage.error('搜索失败: ' + error.message);
}
};
const handleLogout = () => {
userStore.logout();
ElMessage.success('已退出登录');
router.push('/login');
};
// Export Logic
const handleExport = async (format) => {
if (!selectedFile.value) return;
try {
await validateToken();
} catch {
ElMessage.error('登录已过期,请重新登录');
handleLogout();
return;
}
const title = (selectedFile.value.title || '未命名笔记').replace(/[<>:"/\\|?*]/g, '_');
const content = selectedFile.value.content;
const previewElement = document.querySelector('.markdown-preview');
if (!previewElement) {
ElMessage.error('无法找到预览区域');
return;
}
ElMessage.info(`正在导出为 ${format.toUpperCase()}...`);
try {
if (format === 'md') {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
downloadBlob(blob, `${title}.md`);
} else if (format === 'html') {
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${title}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${title}</h1>${previewElement.innerHTML}</body></html>`;
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
downloadBlob(blob, `${title}.html`);
} else if (format === 'pdf') {
const canvas = await html2canvas(previewElement, { scale: 2, useCORS: true });
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
const pageHeight = pdf.internal.pageSize.getHeight() - 20;
const pageWidth = pdf.internal.pageSize.getWidth() - 20;
const imgHeight = (canvas.height * pageWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 10;
const imgData = canvas.toDataURL('image/png');
pdf.addImage(imgData, 'PNG', 10, position, pageWidth, imgHeight);
heightLeft -= pageHeight;
while (heightLeft > 0) {
position = -pageHeight * (pdf.internal.getNumberOfPages()) + 10;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 10, position, pageWidth, imgHeight);
heightLeft -= pageHeight;
}
pdf.save(`${title}.pdf`);
}
ElMessage.success(`${format.toUpperCase()} 导出成功`);
} catch (error) {
ElMessage.error(`导出失败: ${error.message}`);
}
};
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
// Lifecycle and Watchers
onMounted(async () => {
await fetchGroupings();
await resetToHomeView();
window.addEventListener('resize', handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
});
const handleResize = () => {
isMobile.value = window.innerWidth < 768;
if (isMobile.value) {
isCollapsed.value = true;
}
};
// 使用防抖优化 Vditor 渲染
let renderTimeout = null;
let lastRenderedId = null;
watch([selectedFile, showEditor], ([newFile, newShowEditor]) => {
if (newFile && !newShowEditor) {
// 如果同一个文件已经渲染过,跳过
if (lastRenderedId === newFile.id) return;
clearTimeout(renderTimeout);
renderTimeout = setTimeout(() => {
nextTick(() => {
const previewElement = document.querySelector('.markdown-preview');
if (previewElement) {
const contentToRender = (newFile.isPrivate === 1 && !userStore.isLoggedIn) ? privateNoteContent : newFile.content;
Vditor.preview(previewElement, contentToRender || '', {
mode: 'light',
hljs: { enable: true, style: 'github' }
});
lastRenderedId = newFile.id;
}
});
}, 50); // 50ms 防抖
}
}, { deep: true });
</script>
<style>
/* Global styles can remain if they are truly global */
.hide-popper {
display: none !important;
}
.markdown-preview .vditor-reset pre {
max-height: none !important;
overflow: visible !important;
}
.markdown-preview .vditor-reset pre code {
max-height: none !important;
overflow-y: visible !important;
word-break: break-all !important;
white-space: pre-wrap !important;
display: block;
}
</style>
<style scoped>
.list-view-container {
display: flex;
flex-direction: column;
height: 100%;
}
.note-list-wrapper {
flex: 1;
overflow-y: auto;
}
.home-page {
height: 100vh;
overflow: hidden;
background: var(--bg-gradient);
background-attachment: fixed;
}
.content {
padding: 1.5rem;
background-color: transparent;
height: 100vh;
overflow-y: auto;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.fab {
position: fixed;
bottom: 80px;
right: 20px;
z-index: 100;
width: 56px;
height: 56px;
box-shadow: 0 4px 12px rgba(0,0,0,.15);
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
gap: 8px;
color: var(--text-color-secondary);
}
.load-more-trigger {
display: flex;
justify-content: center;
padding: 20px;
}
</style>