Files
biji/biji-qianduan/src/components/HomePage.vue
黄孟 252a5c8503 refactor(biji-qianduan): 修改 Markdown 文件更新逻辑
- 将 createMarkdown 函数替换为 updateMarkdown 函数
- 此修改提高了 Markdown 文件的更新效率
2025-07-31 11:49:51 +08:00

967 lines
27 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">
<!-- 左侧菜单区域 -->
<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(`![](${imageUrl})`);
}
// 在编辑页面按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>