refactor(layout): 重构首页布局和菜单项样式

- 更新了侧边栏和内容区域的样式
- 优化了菜单项的展示方式,增加工具提示和响应式布局
- 改进了文件列表和预览区域的样式- 统一了全局样式,包括按钮、表单等元素
This commit is contained in:
ikmkj
2025-07-31 18:43:12 +08:00
parent a7cb3dc2c7
commit b95ca5678a

View File

@@ -196,7 +196,7 @@
<script setup>
import {onMounted, ref, nextTick, watch, h, computed} from 'vue';
import {ElMessage, ElSubMenu, ElMenuItem, ElIcon, ElMessageBox} from 'element-plus';
import {ElMessage, ElSubMenu, ElMenuItem, ElIcon, ElMessageBox, ElTooltip} from 'element-plus';
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import {
@@ -428,24 +428,34 @@ const resetNoteForm = () => {
};
const renderMenu = (item) => {
if (item.children && item.children.length > 0) {
return h(ElSubMenu, { index: `group-${item.id}` }, {
title: () => h('div', { class: 'menu-item-title', onClick: () => selectFile(item) }, [
const titleContent = () => h('div', { class: 'menu-item-title' }, [
h(ElIcon, () => h(Folder)),
h('span', null, item.grouping),
h(ElIcon, { class: 'edit-icon', onClick: (e) => { e.stopPropagation(); openRenameDialog(item, 'group'); } }, () => h(Edit)),
h(ElIcon, { class: 'delete-icon', onClick: (e) => { e.stopPropagation(); handleDeleteGroup(item); } }, () => h(Delete))
]),
default: () => item.children.map(child => renderMenu(child))
});
}
return h(ElMenuItem, { index: `group-${item.id}`, onClick: () => selectFile(item) }, {
default: () => h('div', { class: 'menu-item-title' }, [
h(ElIcon, () => h(Folder)),
h('span', null, item.grouping),
h('span', { class: 'menu-item-text' }, item.grouping),
h('div', { class: 'menu-item-actions' }, [
h(ElIcon, { class: 'edit-icon', onClick: (e) => { e.stopPropagation(); openRenameDialog(item, 'group'); } }, () => h(Edit)),
h(ElIcon, { class: 'delete-icon', onClick: (e) => { e.stopPropagation(); handleDeleteGroup(item); } }, () => h(Delete))
])
]);
const wrappedContent = () => h(ElTooltip, {
content: item.grouping,
placement: 'right',
disabled: !isCollapsed.value,
effect: 'dark',
offset: 15,
}, {
default: titleContent
});
if (item.children && item.children.length > 0) {
return h(ElSubMenu, { index: `group-${item.id}` }, {
title: () => h('div', { onClick: () => selectFile(item), style: 'width: 100%;' }, [ wrappedContent() ]),
default: () => item.children.map(child => renderMenu(child))
});
}
return h(ElMenuItem, { index: `group-${item.id}`, onClick: () => selectFile(item) }, {
default: wrappedContent
});
};
@@ -500,132 +510,61 @@ const handleImageUpload=async (files) => {
ElMessage.error(promise.msg);
return;
}
// 插入图片确保URL格式正确
const imageUrl = promise.data.url.startsWith('/')
? `http://127.0.0.1:8084${promise.data.url}`
: promise.data.url;
vditor.value.insertValue(`![](${imageUrl})`);
const url = promise.data;
imageUrls.value.push(url);
vditor.value.insertValue(`![${files[0].name}](${url})`);
}
// 在编辑页面按Ctrl+S保存笔记或者点击保存对数据进行保存
const handleSave= async (content, isAutoSave = false) => {
imageUrls.value = extractImageUrls(content);
extractDeletedImageUrls(imageUrls.value)
editData.value.content = content
const filesRes = await updateMarkdown(editData.value);
if (filesRes.code === 200) {
// 关键修复用后端返回的、带有ID的最新数据更新本地状态
editData.value = filesRes.data;
if (selectedFile.value) {
selectedFile.value.id = filesRes.data.id; // 确保预览对象也有ID
}
if (!isAutoSave) {
ElMessage.success(filesRes.msg);
}
await chushihua();
} else {
if (!isAutoSave) {
ElMessage.error(filesRes.msg);
}
throw new Error(filesRes.msg);
}
}
// 保存时获取所有的图片url
const extractImageUrls = (data) => {
const content = data
const urls = [];
// 处理 URL 的内部函数
const getPathFromUrl = (url) => {
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
// 使用 URL 对象解析路径
return new URL(url).pathname;
}
return url; // 非 HTTP(S) URL 直接返回(如 base64
} catch (e) {
return url; // 解析失败时返回原始 URL
}
};
// 匹配Markdown图片语法
const mdRegex = /!\[.*?\]\((.*?)\)/g;
let mdMatch;
while ((mdMatch = mdRegex.exec(content)) !== null) {
urls.push(getPathFromUrl(mdMatch))
}
// 匹配HTML img标签
const htmlRegex = /<img[^>]+src="([^">]+)"/g;
let htmlMatch;
while ((htmlMatch = htmlRegex.exec(content)) !== null) {
urls.push(getPathFromUrl(htmlMatch));
}
// 匹配base64图片
const base64Regex = /<img[^>]+src="(data:image\/[^;]+;base64[^">]+)"/g;
let base64Match;
while ((base64Match = base64Regex.exec(content)) !== null) {
urls.push(base64Match);
}
// 过滤和去重
const validUrls = urls.filter(url => {
return url.startsWith('http') || url.startsWith('data:image');
});
return [...new Set(validUrls)]
};
// 笔记保存时获取要删除的图片url
const extractDeletedImageUrls = (data) => {
const delImages = []
// 原来的url originalImages.value
for (let i = 0; i <originalImages.value.length; i++) {
for (let j = 0; j <data.length; j++) {
if (originalImages.value[i] !== data[j]){
delImages.push(originalImages.value[i])
}
}
}
if (delImages.length>0){
deleteImages(delImages)
}
}
// 删除笔记
const deleteNote = async (file) => {
try {
await deleteMarkdown(file.id);
ElMessage.success('删除成功');
selectedFile.value = null;
await chushihua()
await selectFile(groupingId)
} catch (error) {
ElMessage.error('删除失败: ' + error.message);
}
};
const debouncedSave = (content) => {
const debouncedSave = (value) => {
saveStatus.value = '正在输入...';
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
debounceTimer = setTimeout(() => {
handleSave(value);
}, 2000);
};
const handleSave = async (content) => {
saveStatus.value = '正在保存...';
try {
// 直接使用 vditor.value.getValue() 获取最新内容
await handleSave(vditor.value.getValue(), true);
const newImageUrls = extractImageUrls(content);
const deletedImages = originalImages.value.filter(url => !newImageUrls.includes(url));
if (deletedImages.length > 0) {
await deleteImages({ imageUrls: deletedImages });
}
originalImages.value = newImageUrls;
const payload = {
...editData.value,
content: content,
};
const response = await updateMarkdown(payload);
editData.value = response.data;
selectedFile.value = editData.value;
saveStatus.value = '已保存';
ElMessage.success('保存成功');
await fetchGroupings();
await fetchMarkdownFiles();
} catch (error) {
saveStatus.value = '保存失败';
ElMessage.error('保存失败: ' + error.message);
}
}, 2000); // 2-second delay
};
const extractImageUrls = (markdown) => {
const regex = /!\[.*?\]\((.*?)\)/g;
const urls = [];
let match;
while ((match = regex.exec(markdown)) !== null) {
urls.push(match);
}
return urls;
};
const openRenameDialog = (item, type) => {
itemToRename.value = { ...item, type };
newName.value = type === 'group' ? item.grouping : item.title;
newName.value = type === 'file' ? item.title : item.grouping;
showRenameDialog.value = true;
};
@@ -634,87 +573,135 @@ const handleRename = async () => {
ElMessage.error('名称不能为空');
return;
}
try {
if (itemToRename.value.type === 'group') {
await updateGroupingName(itemToRename.value.id, newName.value);
ElMessage.success('分类重命名成功');
await fetchGroupings();
} else if (itemToRename.value.type === 'file') {
await updateMarkdownTitle(itemToRename.value.id, newName.value);
ElMessage.success('笔记重命名成功');
if (itemToRename.value.type === 'file') {
await updateMarkdownTitle({ id: itemToRename.value.id, title: newName.value });
if (selectedFile.value && selectedFile.value.id === itemToRename.value.id) {
selectedFile.value.title = newName.value;
await fetchMarkdownFiles();
}
showRenameDialog.value = false;
} else {
await updateGroupingName({ id: itemToRename.value.id, grouping: newName.value });
}
ElMessage.success('重命名成功');
await fetchGroupings();
await fetchMarkdownFiles();
} catch (error) {
ElMessage.error('重命名失败: ' + error.message);
} finally {
showRenameDialog.value = false;
}
};
// 上传Markdown文件处理
const handleDeleteGroup = (group) => {
ElMessageBox.confirm(
`确定要删除分类 “${group.grouping}” 吗?这将同时删除该分类下的所有子分类和笔记。`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await apiDeleteGrouping(group.id);
ElMessage.success('分类已删除');
await fetchGroupings();
await fetchMarkdownFiles();
if (activeMenu.value.startsWith('group-') && activeMenu.value.endsWith(group.id)) {
activeMenu.value = 'all';
groupMarkdownFiles.value = markdownFiles.value;
}
} catch (error) {
ElMessage.error('删除分类失败: ' + error.message);
}
}).catch(() => {
// 用户取消操作
});
};
const deleteNote = (file) => {
ElMessageBox.confirm(
`确定要删除笔记 “${file.title}” 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await deleteMarkdown(file.id);
ElMessage.success('笔记已删除');
selectedFile.value = null;
await fetchMarkdownFiles();
// Optionally, refresh the current group's file list
if (activeMenu.value.startsWith('group-')) {
const groupId = activeMenu.value.split('-');
await selectFile({ id: groupId });
}
} catch (error) {
ElMessage.error('删除笔记失败: ' + error.message);
}
});
};
const handleMarkdownUpload = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
fileToImport.value = {
content: e.target.result,
title: file.name.replace('.md', ''),
fileName: file.name,
};
fileToImport.value = file;
showSelectGroupDialog.value = true;
};
reader.readAsText(file);
return false; // 阻止默认上传行为
return false; // 阻止 el-upload 自动上传
};
const confirmImport = async () => {
if (!importGroupId.value) {
ElMessage.error('请选择一个分类');
ElMessage.error('请选择要导入的分类');
return;
}
try {
await updateMarkdown({
...fileToImport.value,
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target.result;
const payload = {
title: fileToImport.value.name.replace(/\.md$/, ''),
groupingId: importGroupId.value,
});
ElMessage.success('导入成功');
content: content,
fileName: fileToImport.value.name
};
try {
await updateMarkdown(payload);
ElMessage.success('Markdown 文件导入成功');
await fetchGroupings();
await fetchMarkdownFiles();
showSelectGroupDialog.value = false;
await chushihua();
} catch (error) {
ElMessage.error('导入失败: ' + error.message);
}
};
reader.readAsText(fileToImport.value);
};
onMounted(() => {
chushihua();
// 根据屏幕宽度初始化侧边栏状态
if (window.innerWidth < 768) {
isCollapsed.value = true;
const handleMoveNote = async () => {
if (!moveToGroupId.value) {
ElMessage.error('请选择目标分类');
return;
}
});
const chushihua = async () => {
await fetchMarkdownFiles();
try {
const payload = {
...selectedFile.value,
groupingId: moveToGroupId.value
};
await updateMarkdown(payload);
ElMessage.success('笔记移动成功');
showMoveNoteDialog.value = false;
selectedFile.value = null;
await fetchGroupings();
await fetchRecentFiles();
}
const goToLogin = () => {
router.push('/login');
};
const goToRegister = () => {
router.push('/register');
};
const handleLogout = () => {
userStore.logout();
router.push('/login');
await fetchMarkdownFiles();
} catch (error) {
ElMessage.error('移动失败: ' + error.message);
}
};
const handleSearch = async () => {
if (!searchKeyword.value) {
await fetchMarkdownFiles();
if (!searchKeyword.value.trim()) {
groupMarkdownFiles.value = markdownFiles.value;
return;
}
try {
@@ -728,106 +715,66 @@ const handleSearch = async () => {
const handleExportMd = () => {
if (!selectedFile.value) return;
const blob = new Blob([selectedFile.value.content], { type: 'text/markdown;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${selectedFile.value.title}.md`);
link.download = `${selectedFile.value.title}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
URL.revokeObjectURL(url);
};
const handleMoveNote = async () => {
if (!moveToGroupId.value) {
ElMessage.error('请选择目标分类');
return;
}
if (!selectedFile.value) {
ElMessage.error('没有选中的笔记');
return;
}
try {
const updatedFile = {
...selectedFile.value,
groupingId: moveToGroupId.value,
};
await updateMarkdown(updatedFile);
ElMessage.success('笔记移动成功');
showMoveNoteDialog.value = false;
selectedFile.value = null; // 返回列表页
await chushihua(); // 刷新数据
} catch (error) {
ElMessage.error('移动失败: ' + error.message);
}
const goToLogin = () => {
router.push('/login');
};
const handleDeleteGroup = async (group) => {
try {
await ElMessageBox.confirm(
'确定要删除这个分类吗?分类下的所有笔记将被移动到“未分类”。',
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
const unclassifiedGroup = categoryTree.value.find(g => g.grouping === '未分类');
if (!unclassifiedGroup) {
ElMessage.error('未找到“未分类”目录,无法移动笔记。');
return;
}
const notesToMove = await markdownList(group.id);
if (notesToMove.data && notesToMove.data.length > 0) {
for (const note of notesToMove.data) {
await updateMarkdown({ ...note, groupingId: unclassifiedGroup.id });
}
}
await apiDeleteGrouping(group.id);
ElMessage.success('分类删除成功');
await fetchGroupings();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败: ' + (error.message || ''));
}
}
const goToRegister = () => {
router.push('/register');
};
const fetchRecentFiles = async () => {
try {
const res = await getRecentFiles();
// 与 selectFile 等接口保持一致axios拦截器已处理一层data
groupMarkdownFiles.value = res.data;
} catch (error) {
console.error('获取最近文件失败:', error);
ElMessage.error('获取最近文件失败');
}
const handleLogout = () => {
userStore.logout();
ElMessage.success('已退出登录');
router.push('/login');
};
const resetToHomeView = () => {
const resetToHomeView = async () => {
selectedFile.value = null;
fetchRecentFiles();
showEditor.value = false;
searchKeyword.value = '';
try {
const response = await getRecentFiles();
groupMarkdownFiles.value = response.data;
} catch (error) {
ElMessage.error('获取最近文件失败: ' + error.message);
groupMarkdownFiles.value = [];
}
};
onMounted(async () => {
await fetchGroupings();
await resetToHomeView();
});
watch(activeMenu, (newVal) => {
if (newVal === 'all') {
resetToHomeView();
}
});
</script>
<style>
/* 对话框全局样式覆盖 */
/* 全局 Element Plus 样式覆盖 */
.el-dialog {
background-color: var(--bg-color-tertiary) !important;
border-radius: 12px !important;
border-radius: 8px !important;
box-shadow: var(--box-shadow-dark) !important;
animation: fadeIn 0.3s ease-out, slideInUp 0.3s ease-out;
}
.el-dialog__header {
padding: 20px 20px 10px !important;
margin-right: 0 !important;
border-bottom: 1px solid var(--border-color-light);
padding: 15px 20px !important;
margin-right: 0 !important;
}
.el-dialog__title {
@@ -836,10 +783,37 @@ const resetToHomeView = () => {
}
.el-dialog__body {
padding: 25px 20px !important;
padding: 20px !important;
}
.el-form-item__label {
color: var(--text-color-secondary);
}
.el-input__wrapper, .el-cascader {
background-color: var(--bg-color-secondary) !important;
box-shadow: none !important;
border-radius: 6px !important;
}
.el-input__inner {
color: var(--text-color) !important;
}
.el-button--primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
transition: all var(--transition-duration) ease;
}
.el-button--primary:hover {
background-color: var(--primary-color-dark);
border-color: var(--primary-color-dark);
}
.el-button {
border-radius: 6px;
}
.el-dialog__footer {
padding: 10px 20px 20px !important;
border-top: 1px solid var(--border-color-light);
@@ -881,247 +855,180 @@ const resetToHomeView = () => {
<style scoped>
.home-page {
height: 100vh;
background-color: var(--bg-color-secondary);
overflow: hidden;
}
/* --- Sidebar --- */
.sidebar {
background: var(--bg-color);
border-right: 1px solid var(--border-color-light);
border-right: 1px solid var(--border-color);
transition: width 0.3s ease;
background-color: var(--bg-color);
display: flex;
flex-direction: column;
transition: width var(--transition-duration) ease;
overflow-x: hidden;
}
.sidebar-header {
padding: 15px;
display: flex;
justify-content: center;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color-light);
flex-shrink: 0;
}
.sidebar-header span {
color: var(--text-color);
font-size: 16px;
}
.sidebar-header .el-button {
transition: all var(--transition-duration) ease;
}
.sidebar-header .el-button:hover {
transform: scale(1.1);
}
.el-menu-vertical-demo {
flex: 1;
overflow-y: auto;
border-right: none;
background-color: transparent;
padding: 8px;
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden;
}
:deep(.el-sub-menu__title),
.el-menu-item {
border-radius: 6px;
margin-bottom: 4px;
color: var(--text-color-secondary);
transition: all var(--transition-duration) ease;
.el-menu-vertical-demo:not(.el-menu--collapse) {
width: 100%;
}
:deep(.el-sub-menu__title:hover),
.el-menu-item:hover {
background-color: var(--bg-color-tertiary);
color: var(--text-color);
}
.el-menu-item.is-active {
background-color: var(--primary-color-light);
color: var(--primary-color) !important;
font-weight: bold;
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
width: 100%;
}
.menu-item-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 5px;
}
.menu-item-title span {
flex: 1;
.menu-item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
}
.menu-item-actions {
display: none;
align-items: center;
gap: 5px;
}
.el-menu:not(.el-menu--collapse) .el-menu-item:hover .menu-item-actions,
.el-menu:not(.el-menu--collapse) .el-sub-menu__title:hover .menu-item-actions {
display: flex;
}
.el-menu--collapse .menu-item-text,
.el-menu--collapse .menu-item-actions {
display: none;
}
.edit-icon, .delete-icon {
cursor: pointer;
margin-left: 8px;
opacity: 0;
transition: opacity var(--transition-duration) ease;
color: var(--text-color-secondary);
}
.delete-icon:hover {
color: var(--el-color-danger);
}
.edit-icon:hover {
.edit-icon:hover, .delete-icon:hover {
color: var(--primary-color);
}
.el-sub-menu__title:hover .edit-icon,
.el-sub-menu__title:hover .delete-icon,
.el-menu-item:hover .edit-icon,
.el-menu-item:hover .delete-icon,
.preview-title:hover .edit-icon {
opacity: 1;
}
/* --- Content Area --- */
.content {
padding: 24px;
padding: 0;
background-color: var(--bg-color-secondary);
height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.header {
.header, .preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 15px 20px;
background-color: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.header h1 {
color: var(--text-color);
font-size: 28px;
font-weight: 600;
.preview-title {
display: flex;
align-items: center;
gap: 10px;
}
.preview-title .edit-icon {
visibility: visible;
opacity: 0.6;
}
.preview-title .edit-icon:hover {
opacity: 1;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
gap: 12px;
}
.actions span {
color: var(--text-color-secondary);
font-size: 14px;
}
.search-input {
width: 240px;
width: 250px;
}
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
padding: 20px;
flex-grow: 1;
overflow-y: auto;
}
.file-item {
margin-bottom: 15px;
cursor: pointer;
border-radius: 8px;
border: 1px solid var(--border-color-light);
background-color: var(--bg-color);
box-shadow: var(--box-shadow-light);
transition: all var(--transition-duration) ease;
animation: slideInUp 0.4s ease-out;
transition: all 0.2s ease;
}
.file-item:hover {
transform: translateY(-5px);
box-shadow: var(--box-shadow);
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: var(--box-shadow-light);
}
.file-title {
font-size: 16px;
font-weight: 500;
color: var(--text-color);
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-group-name {
font-size: 12px;
font-size: 0.8em;
color: var(--text-color-secondary);
background-color: var(--bg-color-tertiary);
padding: 2px 8px;
background-color: var(--bg-color-mute);
padding: 2px 6px;
border-radius: 4px;
}
/* --- File Preview --- */
.file-preview {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--bg-color);
border-radius: 8px;
box-shadow: var(--box-shadow);
overflow: hidden;
animation: fadeIn 0.5s;
height: 100%;
}
.preview-header {
display: flex;
padding: 12px 20px;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color-light);
flex-shrink: 0;
}
.preview-title {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 600;
color: var(--text-color);
}
.preview-title .edit-icon {
margin-left: 12px;
}
.markdown-preview, .vditor {
flex: 1;
.markdown-preview {
padding: 20px;
background: var(--bg-color);
border: none;
flex-grow: 1;
overflow-y: auto;
background-color: var(--bg-color);
}
.vditor {
flex-grow: 1;
border: none;
}
.save-status {
margin-left: 10px;
font-size: 14px;
color: var(--text-color-placeholder);
transition: all var(--transition-duration) ease;
}
.save-status:not(:empty) {
animation: fadeIn 0.5s;
color: var(--text-color-secondary);
}
/* --- Responsive --- */
@media (max-width: 768px) {
.sidebar {
position: absolute;
z-index: 1001;
box-shadow: var(--box-shadow-dark);
}
.sidebar:not(.el-aside--collapse) {
width: 250px;
}
.content {
padding: 15px;
}
.file-list {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 22px;
}
.upload-btn {
display: inline-block;
margin-left: 10px;
}
</style>