967 lines
27 KiB
Vue
967 lines
27 KiB
Vue
<template>
|
||
<el-container class="home-page">
|
||
<!-- 左侧菜单区域 -->
|
||
<el-aside :width="isCollapsed ? '64px' : '250px'" class="sidebar">
|
||
<div class="sidebar-header">
|
||
<span v-if="!isCollapsed" style="margin-right: 15px; font-weight: bold;">笔记分类</span>
|
||
<el-button v-if="!isCollapsed" type="primary" size="small" @click="showCreateGroupDialog = true" circle>
|
||
<el-icon><Plus /></el-icon>
|
||
</el-button>
|
||
<el-button @click="isCollapsed = !isCollapsed" type="primary" size="small" circle>
|
||
<el-icon>
|
||
<Fold v-if="!isCollapsed" />
|
||
<Expand v-else />
|
||
</el-icon>
|
||
</el-button>
|
||
</div>
|
||
|
||
<el-menu
|
||
:default-active="activeMenu"
|
||
class="el-menu-vertical-demo"
|
||
:collapse="isCollapsed"
|
||
popper-effect="light"
|
||
:collapse-transition="false"
|
||
>
|
||
<!-- 递归菜单组件 -->
|
||
<template v-for="menu in categoryTree" :key="menu.id">
|
||
<component :is="renderMenu(menu)" />
|
||
</template>
|
||
</el-menu>
|
||
</el-aside>
|
||
|
||
<!-- 右侧内容区域 -->
|
||
<el-container>
|
||
<el-main class="content">
|
||
<div v-if="selectedFile" class="file-preview">
|
||
<el-header class="preview-header">
|
||
<h2 class="preview-title">
|
||
{{ selectedFile.title }}
|
||
<el-icon class="edit-icon" @click="openRenameDialog(selectedFile, 'file')"><Edit /></el-icon>
|
||
</h2>
|
||
<div class="actions">
|
||
<el-button v-if="!showEditor" type="primary" @click="selectedFile = null">清空</el-button>
|
||
<el-button v-if="!showEditor && userStore.isLoggedIn" type="primary" @click="editNote(selectedFile); isCollapsed = true">编辑</el-button>
|
||
<el-button v-if="!showEditor && userStore.isLoggedIn" type="danger" @click="deleteNote(selectedFile)">删除</el-button>
|
||
<el-button v-if="showEditor" type="primary" @click="showEditor = !showEditor; previewFile(editData)">返回</el-button>
|
||
<el-button v-if="showEditor && userStore.isLoggedIn" type="success" @click="handleSave(vditor.getValue())">保存</el-button>
|
||
<span v-if="showEditor" class="save-status">{{ saveStatus }}</span>
|
||
<el-button v-if="!showEditor" type="success" @click="handleExportMd">导出为.md</el-button>
|
||
</div>
|
||
</el-header>
|
||
<div v-if="!showEditor" v-html="previewHtml" class="markdown-preview"></div>
|
||
<!-- Vditor 编辑器 -->
|
||
<div v-show="showEditor" id="vditor" class="vditor" />
|
||
</div>
|
||
|
||
<div v-else>
|
||
<el-header class="header">
|
||
<h1>我的笔记</h1>
|
||
<div class="actions">
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索笔记标题"
|
||
class="search-input"
|
||
@keyup.enter="handleSearch"
|
||
>
|
||
<template #append>
|
||
<el-button @click="handleSearch">
|
||
<el-icon><Search /></el-icon>
|
||
</el-button>
|
||
</template>
|
||
</el-input>
|
||
<div v-if="userStore.isLoggedIn">
|
||
<span>欢迎, {{ userStore.userInfo?.username }}</span>
|
||
<el-button type="danger" @click="handleLogout">退出</el-button>
|
||
</div>
|
||
<div v-else>
|
||
<el-button type="primary" @click="goToLogin">登录</el-button>
|
||
<el-button @click="goToRegister">注册</el-button>
|
||
</div>
|
||
<el-button v-if="userStore.isLoggedIn" type="primary" @click="showCreateNoteDialog = true">新建笔记</el-button>
|
||
<el-upload
|
||
v-if="userStore.isLoggedIn"
|
||
class="upload-btn"
|
||
action=""
|
||
:show-file-list="false"
|
||
:before-upload="handleMarkdownUpload"
|
||
accept=".md"
|
||
>
|
||
<el-button type="success">上传Markdown</el-button>
|
||
</el-upload>
|
||
</div>
|
||
</el-header>
|
||
|
||
<div v-if="groupMarkdownFiles.length > 0" class="file-list">
|
||
<el-card v-for="file in groupMarkdownFiles" :key="file.id" shadow="hover" class="file-item">
|
||
<div @click="previewFile(file)" class="file-title">{{ file.title }}</div>
|
||
</el-card>
|
||
</div>
|
||
<el-empty v-else description="暂无笔记,请创建或上传" />
|
||
</div>
|
||
|
||
<!-- 分类创建对话框 -->
|
||
<el-dialog v-model="showCreateGroupDialog" title="新建分类" width="400px" @close="resetGroupForm">
|
||
<el-form :model="newGroupForm" :rules="groupFormRules" ref="groupFormRef" label-width="80px">
|
||
<el-form-item label="父级分类">
|
||
<el-cascader
|
||
v-model="newGroupForm.parentId"
|
||
:options="categoryCascaderOptions"
|
||
:props="{ checkStrictly: true, emitPath: false }"
|
||
clearable
|
||
placeholder="不选则为一级分类"
|
||
style="width: 100%;"
|
||
></el-cascader>
|
||
</el-form-item>
|
||
<el-form-item label="分类名称" prop="name">
|
||
<el-input v-model="newGroupForm.name" autocomplete="off"></el-input>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showCreateGroupDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="createGrouping">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 笔记创建对话框 -->
|
||
<el-dialog v-model="showCreateNoteDialog" title="新建笔记" width="400px" @close="resetNoteForm">
|
||
<el-form :model="newNoteForm" :rules="noteFormRules" ref="noteFormRef" label-width="80px">
|
||
<el-form-item label="笔记标题" prop="title">
|
||
<el-input v-model="newNoteForm.title" autocomplete="off"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="选择分类" prop="groupingId">
|
||
<el-cascader
|
||
v-model="newNoteForm.groupingId"
|
||
:options="categoryTree"
|
||
:props="{ checkStrictly: true, emitPath: false }"
|
||
clearable
|
||
placeholder="请选择笔记所属分类"
|
||
style="width: 100%;"
|
||
></el-cascader>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showCreateNoteDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="createNote">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 重命名对话框 -->
|
||
<el-dialog v-model="showRenameDialog" title="重命名" width="400px">
|
||
<el-input v-model="newName" placeholder="请输入新名称"></el-input>
|
||
<template #footer>
|
||
<el-button @click="showRenameDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="handleRename">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 导入选择分类对话框 -->
|
||
<el-dialog v-model="showSelectGroupDialog" title="选择导入的分类" width="400px">
|
||
<el-cascader
|
||
v-model="importGroupId"
|
||
:options="categoryTree"
|
||
:props="{ checkStrictly: true, emitPath: false }"
|
||
clearable
|
||
placeholder="请选择要导入的分类"
|
||
style="width: 100%;"
|
||
></el-cascader>
|
||
<template #footer>
|
||
<el-button @click="showSelectGroupDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmImport">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</el-main>
|
||
</el-container>
|
||
</el-container>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {onMounted, ref, nextTick, watch, h, computed} from 'vue';
|
||
import {ElMessage, ElSubMenu, ElMenuItem, ElIcon} from 'element-plus';
|
||
import Vditor from 'vditor';
|
||
import 'vditor/dist/index.css';
|
||
import {
|
||
addGroupings,
|
||
deleteImages, deleteMarkdown,
|
||
groupingAll,
|
||
markdownAll, markdownList,
|
||
Preview,
|
||
updateMarkdown, uploadImage,
|
||
searchMarkdown,
|
||
updateGroupingName,
|
||
updateMarkdownTitle
|
||
} from '@/api/CommonApi.js'
|
||
import { Plus, Fold, Expand, Folder, Document, Search, Edit } from "@element-plus/icons-vue";
|
||
import { useUserStore } from '../stores/user';
|
||
import { useRouter } from 'vue-router';
|
||
|
||
const userStore = useUserStore();
|
||
const router = useRouter();
|
||
const searchKeyword = ref('');
|
||
|
||
const markdownFiles = ref([]);
|
||
const categoryTree = ref([]);
|
||
const groupMarkdownFiles = ref({});
|
||
const showEditor = ref(false);
|
||
const selectedFile = ref(null);
|
||
const activeMenu = ref('all');
|
||
const isCollapsed = ref(false);
|
||
const showCreateGroupDialog = ref(false);
|
||
const showCreateNoteDialog = ref(false);
|
||
const showRenameDialog = ref(false);
|
||
const itemToRename = ref(null);
|
||
const newName = ref('');
|
||
const showSelectGroupDialog = ref(false);
|
||
const importGroupId = ref(null);
|
||
const fileToImport = ref(null);
|
||
|
||
const groupFormRef = ref(null);
|
||
const newGroupForm = ref({ name: '', parentId: null });
|
||
const groupFormRules = ref({
|
||
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }],
|
||
});
|
||
|
||
const noteFormRef = ref(null);
|
||
const newNoteForm = ref({
|
||
id: null,
|
||
title: '',
|
||
groupingId: null,
|
||
fileName: '',
|
||
content: ''
|
||
});
|
||
const noteFormRules = ref({
|
||
title: [{ required: true, message: '请输入笔记标题', trigger: 'blur' }],
|
||
groupingId: [{ required: true, message: '请选择分类', trigger: 'change' }],
|
||
});
|
||
|
||
const editData=ref(null)
|
||
const imageUrls = ref([]);
|
||
const originalImages = ref([]);
|
||
|
||
const vditor = ref(null);
|
||
const previewHtml = ref('');
|
||
const saveStatus = ref('空闲');
|
||
let debounceTimer = null;
|
||
|
||
const categoryCascaderOptions = computed(() => {
|
||
return [{ id: 0, grouping: '根分类', value: 0, label: '根分类' }, ...categoryTree.value];
|
||
});
|
||
|
||
const initVditor = () => {
|
||
vditor.value = new Vditor('vditor', {
|
||
height: 'calc(100vh - 120px)',
|
||
mode: 'ir',
|
||
after: () => {
|
||
if (editData.value) {
|
||
vditor.value.setValue(editData.value.content);
|
||
}
|
||
},
|
||
input: (value) => {
|
||
debouncedSave(value);
|
||
},
|
||
upload: {
|
||
accept: 'image/*',
|
||
handler(files) {
|
||
handleImageUpload(files);
|
||
},
|
||
},
|
||
});
|
||
};
|
||
|
||
const buildTree = (items, parentId = 0) => {
|
||
return items
|
||
.filter(item => +item.parentId === +parentId)
|
||
.map(item => {
|
||
const children = buildTree(items, item.id);
|
||
return {
|
||
...item,
|
||
value: item.id,
|
||
label: item.grouping,
|
||
children: children.length > 0 ? children : undefined,
|
||
};
|
||
});
|
||
};
|
||
|
||
const fetchGroupings = async () => {
|
||
try {
|
||
const response = await groupingAll("");
|
||
const allCategories = response.data || [];
|
||
categoryTree.value = buildTree(allCategories);
|
||
} catch (error) {
|
||
console.error('获取分组失败:', error);
|
||
ElMessage.error('获取分组失败: ' + (error.response?.data?.message || error.message));
|
||
categoryTree.value = [];
|
||
}
|
||
};
|
||
|
||
const selectFile = async (data) => {
|
||
const promise = await markdownList(data.id);
|
||
groupMarkdownFiles.value = promise.data;
|
||
selectedFile.value = null;
|
||
};
|
||
|
||
const fetchMarkdownFiles = async () => {
|
||
try {
|
||
const response = await markdownAll()
|
||
markdownFiles.value = (response.data || []).map(file => ({
|
||
...file,
|
||
id: String(file.id)
|
||
}));
|
||
} catch (error) {
|
||
ElMessage.error('获取笔记列表失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
const createGrouping = async () => {
|
||
if (!groupFormRef.value) return;
|
||
await groupFormRef.value.validate(async (valid) => {
|
||
if (valid) {
|
||
try {
|
||
const payload = {
|
||
name: newGroupForm.value.name,
|
||
parentId: newGroupForm.value.parentId || 0
|
||
};
|
||
await addGroupings(payload);
|
||
ElMessage.success('分类创建成功');
|
||
showCreateGroupDialog.value = false;
|
||
await fetchGroupings();
|
||
} catch (error) {
|
||
ElMessage.error('创建分类失败: ' + error.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const resetGroupForm = () => {
|
||
newGroupForm.value = { name: '', parentId: null };
|
||
if (groupFormRef.value) {
|
||
groupFormRef.value.resetFields();
|
||
}
|
||
};
|
||
|
||
const createNote = async () => {
|
||
if (!noteFormRef.value) return;
|
||
await noteFormRef.value.validate(async (valid) => {
|
||
if (valid) {
|
||
try {
|
||
const groupingId = newNoteForm.value.groupingId;
|
||
if (!groupingId) {
|
||
ElMessage.error('必须选择一个分类');
|
||
return;
|
||
}
|
||
const payload = {
|
||
id: null,
|
||
title: newNoteForm.value.title,
|
||
groupingId: groupingId,
|
||
fileName: newNoteForm.value.title + '.md',
|
||
content: ''
|
||
};
|
||
editData.value = payload;
|
||
showCreateNoteDialog.value = false;
|
||
showEditor.value = true;
|
||
selectedFile.value = editData.value;
|
||
await nextTick(() => {
|
||
initVditor();
|
||
});
|
||
} catch (error) {
|
||
ElMessage.error('创建笔记失败: ' + error.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const resetNoteForm = () => {
|
||
newNoteForm.value = { id: null, title: '', groupingId: null, fileName: '', content: '' };
|
||
if (noteFormRef.value) {
|
||
noteFormRef.value.resetFields();
|
||
}
|
||
};
|
||
|
||
const renderMenu = (item) => {
|
||
if (item.children && item.children.length > 0) {
|
||
return h(ElSubMenu, { index: `group-${item.id}` }, {
|
||
title: () => 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))
|
||
]),
|
||
default: () => item.children.map(child => renderMenu(child))
|
||
});
|
||
}
|
||
return h(ElMenuItem, { index: `group-${item.id}`, onClick: () => selectFile(item) }, {
|
||
default: () => [
|
||
h(ElIcon, () => h(Document)),
|
||
h('span', null, item.grouping)
|
||
]
|
||
});
|
||
};
|
||
|
||
// 选择文件预览
|
||
const previewFile = async (file) => {
|
||
if (file.id === null){
|
||
editData.value=file
|
||
selectedFile.value=null
|
||
return;
|
||
}
|
||
try {
|
||
const response = await Preview(file.id)
|
||
|
||
// 确保内容为字符串
|
||
const content = String(response.data || '');
|
||
|
||
selectedFile.value = {
|
||
...file,
|
||
content: content
|
||
};
|
||
await nextTick();
|
||
const previewElement = document.querySelector('.markdown-preview');
|
||
if (previewElement) {
|
||
Vditor.preview(previewElement, content, {
|
||
// 在这里提供一个基本的配置对象
|
||
mode: 'light', // 或者 'dark',可以根据当前主题动态设置
|
||
hljs: {
|
||
enable: true,
|
||
style: 'github'
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('获取笔记内容失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// 编辑笔记
|
||
const editNote = async (file) => {
|
||
editData.value = file
|
||
originalImages.value = extractImageUrls(file.content);
|
||
showEditor.value = true;
|
||
await nextTick(() => {
|
||
initVditor();
|
||
});
|
||
};
|
||
|
||
// 图片上传
|
||
const handleImageUpload=async (files) => {
|
||
const promise = await uploadImage(files);
|
||
if (promise.code !== 200) {
|
||
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(``);
|
||
}
|
||
|
||
// 在编辑页面,按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) => {
|
||
saveStatus.value = '正在输入...';
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(async () => {
|
||
saveStatus.value = '正在保存...';
|
||
try {
|
||
// 直接使用 vditor.value.getValue() 获取最新内容
|
||
await handleSave(vditor.value.getValue(), true);
|
||
saveStatus.value = '已保存';
|
||
} catch (error) {
|
||
saveStatus.value = '保存失败';
|
||
}
|
||
}, 2000); // 2-second delay
|
||
};
|
||
|
||
const openRenameDialog = (item, type) => {
|
||
itemToRename.value = { ...item, type };
|
||
newName.value = type === 'group' ? item.grouping : item.title;
|
||
showRenameDialog.value = true;
|
||
};
|
||
|
||
const handleRename = async () => {
|
||
if (!newName.value.trim()) {
|
||
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('笔记重命名成功');
|
||
selectedFile.value.title = newName.value;
|
||
await fetchMarkdownFiles();
|
||
}
|
||
showRenameDialog.value = false;
|
||
} catch (error) {
|
||
ElMessage.error('重命名失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// 上传Markdown文件处理
|
||
const handleMarkdownUpload = (file) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
fileToImport.value = {
|
||
content: e.target.result,
|
||
title: file.name.replace('.md', ''),
|
||
fileName: file.name,
|
||
};
|
||
showSelectGroupDialog.value = true;
|
||
};
|
||
reader.readAsText(file);
|
||
return false; // 阻止默认上传行为
|
||
};
|
||
|
||
const confirmImport = async () => {
|
||
if (!importGroupId.value) {
|
||
ElMessage.error('请选择一个分类');
|
||
return;
|
||
}
|
||
try {
|
||
await updateMarkdown({
|
||
...fileToImport.value,
|
||
groupingId: importGroupId.value,
|
||
});
|
||
ElMessage.success('导入成功');
|
||
showSelectGroupDialog.value = false;
|
||
await chushihua();
|
||
} catch (error) {
|
||
ElMessage.error('导入失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
chushihua();
|
||
// 根据屏幕宽度初始化侧边栏状态
|
||
if (window.innerWidth < 768) {
|
||
isCollapsed.value = true;
|
||
}
|
||
});
|
||
|
||
const chushihua = async () => {
|
||
await fetchMarkdownFiles();
|
||
await fetchGroupings();
|
||
}
|
||
|
||
const goToLogin = () => {
|
||
router.push('/login');
|
||
};
|
||
|
||
const goToRegister = () => {
|
||
router.push('/register');
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
userStore.logout();
|
||
router.push('/login');
|
||
};
|
||
|
||
const handleSearch = async () => {
|
||
if (!searchKeyword.value) {
|
||
await fetchMarkdownFiles();
|
||
return;
|
||
}
|
||
try {
|
||
const response = await searchMarkdown(searchKeyword.value);
|
||
groupMarkdownFiles.value = response.data;
|
||
} catch (error) {
|
||
ElMessage.error('搜索失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
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 link = document.createElement('a');
|
||
link.href = url;
|
||
link.setAttribute('download', `${selectedFile.value.title}.md`);
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
};
|
||
</script>
|
||
|
||
<style>
|
||
/* 对话框全局样式覆盖 */
|
||
.el-dialog {
|
||
background-color: var(--bg-color-tertiary) !important;
|
||
border-radius: 12px !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);
|
||
}
|
||
|
||
.el-dialog__title {
|
||
color: var(--text-color) !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.el-dialog__body {
|
||
padding: 25px 20px !important;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
|
||
.el-dialog__footer {
|
||
padding: 10px 20px 20px !important;
|
||
border-top: 1px solid var(--border-color-light);
|
||
}
|
||
|
||
/* Vditor 暗黑模式适配 */
|
||
.dark-theme .vditor {
|
||
--panel-background-color: var(--bg-color-secondary) !important;
|
||
--textarea-background-color: var(--bg-color) !important;
|
||
--toolbar-background-color: var(--bg-color-tertiary) !important;
|
||
--border-color: var(--border-color) !important;
|
||
color: var(--text-color) !important;
|
||
}
|
||
.dark-theme .vditor-toolbar,
|
||
.dark-theme .vditor-content,
|
||
.dark-theme .vditor-preview {
|
||
background-color: var(--bg-color) !important;
|
||
}
|
||
.dark-theme .vditor-toolbar__item:hover,
|
||
.dark-theme .vditor-toolbar__item--current {
|
||
background-color: var(--primary-color-light) !important;
|
||
}
|
||
</style>
|
||
|
||
<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);
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: width var(--transition-duration) ease;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 15px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
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;
|
||
}
|
||
|
||
: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;
|
||
}
|
||
|
||
: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;
|
||
}
|
||
|
||
.menu-item-title {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.menu-item-title span {
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.edit-icon {
|
||
cursor: pointer;
|
||
margin-left: 8px;
|
||
opacity: 0;
|
||
transition: opacity var(--transition-duration) ease;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
.edit-icon:hover {
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.el-sub-menu__title:hover .edit-icon,
|
||
.el-menu-item:hover .edit-icon,
|
||
.preview-title:hover .edit-icon {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* --- Content Area --- */
|
||
.content {
|
||
padding: 24px;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.header h1 {
|
||
color: var(--text-color);
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.actions span {
|
||
color: var(--text-color-secondary);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.search-input {
|
||
width: 240px;
|
||
}
|
||
|
||
.file-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.file-item {
|
||
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;
|
||
}
|
||
|
||
.file-item:hover {
|
||
transform: translateY(-5px);
|
||
box-shadow: var(--box-shadow);
|
||
border-color: var(--primary-color);
|
||
}
|
||
|
||
.file-title {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: var(--text-color);
|
||
padding: 20px;
|
||
}
|
||
|
||
/* --- 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;
|
||
}
|
||
|
||
.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;
|
||
padding: 20px;
|
||
background: var(--bg-color);
|
||
border: none;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* --- 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;
|
||
}
|
||
}
|
||
</style>
|