- 为适应移动端和桌面端的用户界面,对私密设置按钮进行了调整 - 在移动端使用圆形按钮并添加锁图标,以节省空间并保持清晰的功能指示 - 桌面端保持原有文本按钮,提供更详细的提示信息
1573 lines
47 KiB
Vue
1573 lines
47 KiB
Vue
<template>
|
||
<el-container class="home-page" :class="{'is-mobile': isMobile}">
|
||
<!-- 左侧菜单区域 -->
|
||
<el-aside class="sidebar" :class="{'is-collapsed': isCollapsed}">
|
||
<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 v-if="!isMobile">
|
||
<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>
|
||
<ElMenuItem index="trash" @click="goToTrash">
|
||
<ElIcon><Delete /></ElIcon>
|
||
<template #title>回收站</template>
|
||
</ElMenuItem>
|
||
</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">
|
||
<span>{{ selectedFile.title }}</span>
|
||
<el-icon v-if="selectedFile.isPrivate === 1" class="lock-icon"><Lock /></el-icon>
|
||
<el-icon class="edit-icon" @click="openRenameDialog(selectedFile, 'file')"><Edit /></el-icon>
|
||
</h2>
|
||
<div class="actions">
|
||
<el-button v-if="!showEditor" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" @click="selectedFile = null">
|
||
<el-icon v-if="isMobile"><Back /></el-icon>
|
||
<span v-else>返回</span>
|
||
</el-button>
|
||
<el-button v-if="!showEditor && userStore.isLoggedIn && !isMobile" type="warning" @click="showMoveNoteDialog = true">移动</el-button>
|
||
<el-button v-if="!showEditor && userStore.isLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" @click="editNote(selectedFile); isCollapsed = true">
|
||
<el-icon v-if="isMobile"><Edit /></el-icon>
|
||
<span v-else>编辑</span>
|
||
</el-button>
|
||
<el-button v-if="!showEditor && userStore.isLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="danger" @click="deleteNote(selectedFile)">
|
||
<el-icon v-if="isMobile"><Delete /></el-icon>
|
||
<span v-else>删除</span>
|
||
</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 && userStore.isLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="info" @click="showPrivacyDialog = true">
|
||
<el-icon v-if="isMobile"><Lock /></el-icon>
|
||
<span v-else>{{ selectedFile.isPrivate === 1 ? '设为公开' : '设为私密' }}</span>
|
||
</el-button>
|
||
<el-dropdown v-if="!showEditor && userStore.isLoggedIn && !isMobile" @command="handleExport">
|
||
<el-button type="success">
|
||
导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="md">导出为 .md</el-dropdown-item>
|
||
<el-dropdown-item command="pdf">导出为 .pdf</el-dropdown-item>
|
||
<el-dropdown-item command="html">导出为 .html</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</el-header>
|
||
<div v-if="!showEditor" :key="selectedFile.id" class="markdown-preview">
|
||
<!-- 私密笔记提示区域由JS渲染 -->
|
||
</div>
|
||
<!-- Vditor 编辑器 -->
|
||
<div v-show="showEditor" id="vditor" class="vditor" />
|
||
</div>
|
||
|
||
<div v-else>
|
||
<!-- Desktop Header -->
|
||
<el-header class="header" v-if="!isMobile">
|
||
<h1 @click="resetToHomeView" style="cursor: pointer; flex-grow: 1;">我的笔记</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" class="user-actions">
|
||
<span class="welcome-text">欢迎, {{ userStore.userInfo?.username }}</span>
|
||
<el-button type="danger" @click="handleLogout">退出</el-button>
|
||
<el-button type="primary" @click="showUpdatePasswordDialog = true">修改密码</el-button>
|
||
<el-button type="warning" @click="showSystemSettingsDialog = true">系统管理</el-button>
|
||
<el-button type="primary" @click="showCreateNoteDialog = true">新建笔记</el-button>
|
||
<el-upload
|
||
class="upload-btn"
|
||
action=""
|
||
:show-file-list="false"
|
||
:before-upload="handleMarkdownUpload"
|
||
accept=".md"
|
||
>
|
||
<el-button type="success">上传Markdown</el-button>
|
||
</el-upload>
|
||
</div>
|
||
<div v-else class="guest-actions">
|
||
<el-button type="primary" @click="goToLogin">登录</el-button>
|
||
<el-button @click="goToRegister">注册</el-button>
|
||
</div>
|
||
</div>
|
||
</el-header>
|
||
|
||
<!-- Mobile Header -->
|
||
<el-header class="header mobile-header" v-if="isMobile">
|
||
<el-button @click="isCollapsed = !isCollapsed" text circle class="mobile-menu-toggle">
|
||
<el-icon size="24"><Menu /></el-icon>
|
||
</el-button>
|
||
<h1 class="mobile-title" @click="resetToHomeView">我的笔记</h1>
|
||
<el-button text circle class="mobile-search-toggle" @click="handleSearch">
|
||
<el-icon size="22"><Search /></el-icon>
|
||
</el-button>
|
||
<el-button v-if="!userStore.isLoggedIn" text circle class="mobile-login-toggle" @click="goToLogin">
|
||
<el-icon size="24"><User /></el-icon>
|
||
</el-button>
|
||
</el-header>
|
||
|
||
<!-- Mobile Search Bar -->
|
||
<div v-if="isMobile" class="mobile-search-container">
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索笔记标题"
|
||
class="mobile-search-input"
|
||
@keyup.enter="handleSearch"
|
||
size="large"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
|
||
<div v-if="groupMarkdownFiles.length > 0" class="file-list">
|
||
<el-card v-for="file in groupMarkdownFiles" :key="file.id" shadow="hover" class="file-item" :class="{ 'private-note': file.isPrivate === 1 }">
|
||
<div @click="previewFile(file)" class="file-title">
|
||
<span>{{ file.title }}</span>
|
||
<span class="file-group-name">{{ file.groupingName }}</span>
|
||
<el-icon v-if="file.isPrivate === 1 && !userStore.isLoggedIn" class="lock-icon"><Lock /></el-icon>
|
||
</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-item label="私密笔记">
|
||
<el-switch
|
||
v-model="newNoteForm.isPrivate"
|
||
:active-value="1"
|
||
:inactive-value="0"
|
||
active-text="私密"
|
||
inactive-text="公开"
|
||
/>
|
||
<div class="form-item-help">私密笔记只有登录用户才能查看内容</div>
|
||
</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-dialog v-model="showMoveNoteDialog" title="移动笔记到" width="400px">
|
||
<el-cascader
|
||
v-model="moveToGroupId"
|
||
:options="categoryTree"
|
||
:props="{ checkStrictly: true, emitPath: false }"
|
||
clearable
|
||
placeholder="请选择目标分类"
|
||
style="width: 100%;"
|
||
></el-cascader>
|
||
<template #footer>
|
||
<el-button @click="showMoveNoteDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="handleMoveNote">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 系统管理对话框 -->
|
||
<el-dialog v-model="showSystemSettingsDialog" title="系统管理" width="500px">
|
||
<el-form label-width="120px">
|
||
<el-form-item label="开放注册">
|
||
<el-switch v-model="isRegistrationEnabled" @change="handleToggleRegistration"></el-switch>
|
||
</el-form-item>
|
||
<el-form-item label="生成注册码">
|
||
<el-button type="primary" @click="handleGenerateCode">生成</el-button>
|
||
</el-form-item>
|
||
<el-form-item v-if="generatedCode" label="新注册码">
|
||
<el-input v-model="generatedCode" readonly>
|
||
<template #append>
|
||
<el-button @click="copyToClipboard(generatedCode)">复制</el-button>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showSystemSettingsDialog = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 修改密码对话框 -->
|
||
<el-dialog v-model="showUpdatePasswordDialog" title="修改密码" width="400px" @close="resetUpdatePasswordForm">
|
||
<el-form :model="updatePasswordForm" :rules="updatePasswordFormRules" ref="updatePasswordFormRef" label-width="100px">
|
||
<el-form-item label="旧密码" prop="oldPassword">
|
||
<el-input v-model="updatePasswordForm.oldPassword" type="password" show-password autocomplete="off"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="新密码" prop="newPassword">
|
||
<el-input v-model="updatePasswordForm.newPassword" type="password" show-password autocomplete="off"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||
<el-input v-model="updatePasswordForm.confirmPassword" type="password" show-password autocomplete="off"></el-input>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showUpdatePasswordDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="handleUpdatePassword">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 修改笔记私密状态对话框 -->
|
||
<el-dialog v-model="showPrivacyDialog" :title="selectedFile && selectedFile.isPrivate === 1 ? '设为公开笔记' : '设为私密笔记'" width="400px">
|
||
<div v-if="selectedFile">
|
||
<p>您确定要将笔记 <strong>"{{ selectedFile.title }}"</strong> {{ selectedFile.isPrivate === 1 ? '设为公开' : '设为私密' }}吗?</p>
|
||
<div class="privacy-explanation">
|
||
<el-icon><InfoFilled /></el-icon>
|
||
<span>{{ selectedFile.isPrivate === 1 ? '公开笔记:所有用户都可以查看内容' : '私密笔记:只有登录用户才能查看内容' }}</span>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="showPrivacyDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="handlePrivacyChange">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</el-main>
|
||
</el-container>
|
||
<div v-if="isMobile && !isCollapsed" class="sidebar-overlay" @click="isCollapsed = true"></div>
|
||
</el-container>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {onMounted, ref, nextTick, watch, h, computed, onBeforeUnmount} from 'vue';
|
||
import {ElMessage, ElSubMenu, ElMenuItem, ElIcon, ElMessageBox, ElTooltip} 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,
|
||
deleteGrouping as apiDeleteGrouping,
|
||
getRecentFiles,
|
||
validateToken,
|
||
getRegistrationStatus,
|
||
toggleRegistration,
|
||
generateRegistrationCode,
|
||
updatePassword
|
||
} from '@/api/CommonApi.js'
|
||
import { Plus, Fold, Expand, Folder, Document, Search, Edit, Delete, ArrowDown, Clock, Lock, InfoFilled, Menu, User, Back } from "@element-plus/icons-vue";
|
||
import { useUserStore } from '../stores/user';
|
||
import { useRouter } from 'vue-router';
|
||
import { privateNoteContent } from '../utils/privateNoteContent.js';
|
||
|
||
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(true);
|
||
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 showMoveNoteDialog = ref(false);
|
||
const moveToGroupId = ref(null);
|
||
const currentGroupName = ref('');
|
||
const showSystemSettingsDialog = ref(false);
|
||
const isRegistrationEnabled = ref(true);
|
||
const generatedCode = ref('');
|
||
const showUpdatePasswordDialog = ref(false);
|
||
const showPrivacyDialog = ref(false);
|
||
|
||
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: '',
|
||
isPrivate: 0
|
||
});
|
||
const noteFormRules = ref({
|
||
title: [{ required: true, message: '请输入笔记标题', trigger: 'blur' }],
|
||
groupingId: [{ required: true, message: '请选择分类', trigger: 'change' }],
|
||
});
|
||
|
||
const updatePasswordFormRef = ref(null);
|
||
const updatePasswordForm = ref({
|
||
oldPassword: '',
|
||
newPassword: '',
|
||
confirmPassword: ''
|
||
});
|
||
const validateConfirmPassword = (rule, value, callback) => {
|
||
if (value === '') {
|
||
callback(new Error('请再次输入新密码'));
|
||
} else if (value !== updatePasswordForm.value.newPassword) {
|
||
callback(new Error("两次输入的新密码不一致"));
|
||
} else {
|
||
callback();
|
||
}
|
||
};
|
||
const updatePasswordFormRules = ref({
|
||
oldPassword: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
|
||
newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }],
|
||
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }]
|
||
});
|
||
|
||
const editData=ref(null)
|
||
const imageUrls = ref([]);
|
||
const originalImages = ref([]);
|
||
|
||
const vditor = ref(null);
|
||
const saveStatus = ref('空闲');
|
||
let debounceTimer = null;
|
||
|
||
const categoryCascaderOptions = computed(() => categoryTree.value);
|
||
|
||
const isMobile = ref(window.innerWidth < 768);
|
||
|
||
const handleResize = () => {
|
||
isMobile.value = window.innerWidth < 768;
|
||
if (isMobile.value) {
|
||
isCollapsed.value = true;
|
||
}
|
||
};
|
||
|
||
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) => {
|
||
const tree = [];
|
||
const itemMap = new Map();
|
||
|
||
// First, map all items by their id
|
||
items.forEach(item => {
|
||
itemMap.set(String(item.id), {
|
||
...item,
|
||
value: item.id,
|
||
label: item.grouping,
|
||
children: [], // Initialize children array
|
||
});
|
||
});
|
||
|
||
// Then, build the tree structure
|
||
itemMap.forEach(item => {
|
||
const parentId = String(item.parentId);
|
||
if (parentId !== '0' && itemMap.has(parentId)) {
|
||
const parent = itemMap.get(parentId);
|
||
parent.children.push(item);
|
||
} else {
|
||
// If it's a root node or an orphan, add it to the top level
|
||
tree.push(item);
|
||
}
|
||
});
|
||
|
||
// Helper to remove empty children arrays
|
||
const cleanTree = (nodes) => {
|
||
nodes.forEach(node => {
|
||
if (node.children.length === 0) {
|
||
delete node.children;
|
||
} else {
|
||
cleanTree(node.children);
|
||
}
|
||
});
|
||
};
|
||
|
||
cleanTree(tree);
|
||
return tree;
|
||
};
|
||
|
||
const fetchGroupings = async () => {
|
||
try {
|
||
const allCategories = await groupingAll("");
|
||
categoryTree.value = buildTree(allCategories || []);
|
||
} catch (error) {
|
||
categoryTree.value = [];
|
||
}
|
||
}
|
||
|
||
const selectFile = async (data) => {
|
||
try {
|
||
const files = await markdownList(data.id);
|
||
groupMarkdownFiles.value = files || [];
|
||
currentGroupName.value = data.grouping;
|
||
selectedFile.value = null;
|
||
} catch (error) {
|
||
ElMessage.error('获取笔记列表失败: ' + error.message);
|
||
groupMarkdownFiles.value = [];
|
||
}
|
||
};
|
||
|
||
const fetchMarkdownFiles = async () => {
|
||
try {
|
||
const files = await markdownAll();
|
||
markdownFiles.value = (files || []).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 = {
|
||
grouping: newGroupForm.value.name, // 将 name 映射到 grouping
|
||
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: '',
|
||
isPrivate: newNoteForm.value.isPrivate
|
||
};
|
||
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: '', isPrivate: 0 };
|
||
if (noteFormRef.value) {
|
||
noteFormRef.value.resetFields();
|
||
}
|
||
};
|
||
|
||
const renderMenu = (item) => {
|
||
const titleContent = () => h('div', { class: 'menu-item-title' }, [
|
||
h(ElIcon, () => h(Folder)),
|
||
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}`,
|
||
popperClass: isCollapsed.value ? 'hide-popper' : ''
|
||
}, {
|
||
title: () => h('div', {
|
||
class: 'submenu-title-wrapper',
|
||
onClick: () => selectFile(item),
|
||
style: 'width: 100%; display: flex; align-items: center;'
|
||
}, [ wrappedContent() ]),
|
||
default: () => item.children.map(child => renderMenu(child))
|
||
});
|
||
}
|
||
|
||
return h(ElMenuItem, { index: `group-${item.id}`, onClick: () => selectFile(item) }, {
|
||
default: wrappedContent
|
||
});
|
||
};
|
||
|
||
// 选择文件预览
|
||
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 || '');
|
||
selectedFile.value = {
|
||
...file,
|
||
content: content
|
||
};
|
||
showEditor.value = false; // 确保进入预览模式
|
||
|
||
// 如果是私密笔记且用户未登录,内容会被后端置空,这里不需要额外提示
|
||
// 提示信息已经在模板中通过条件渲染显示
|
||
} catch (error) {
|
||
ElMessage.error('获取笔记内容失败: ' + error.message);
|
||
selectedFile.value = null;
|
||
}
|
||
};
|
||
|
||
// 编辑笔记
|
||
const editNote = async (file) => {
|
||
editData.value = file
|
||
originalImages.value = extractImageUrls(file.content);
|
||
showEditor.value = true;
|
||
await nextTick(() => {
|
||
initVditor();
|
||
});
|
||
};
|
||
|
||
// 修改笔记私密状态
|
||
const handlePrivacyChange = async () => {
|
||
if (!selectedFile.value) return;
|
||
|
||
try {
|
||
const newPrivacyStatus = selectedFile.value.isPrivate === 1 ? 0 : 1;
|
||
const payload = {
|
||
...selectedFile.value,
|
||
isPrivate: newPrivacyStatus
|
||
};
|
||
|
||
const updatedFile = await updateMarkdown(payload);
|
||
selectedFile.value = updatedFile;
|
||
|
||
// 更新列表中的文件状态
|
||
const index = groupMarkdownFiles.value.findIndex(file => file.id === selectedFile.value.id);
|
||
if (index !== -1) {
|
||
groupMarkdownFiles.value[index] = updatedFile;
|
||
}
|
||
|
||
showPrivacyDialog.value = false;
|
||
ElMessage.success(`笔记已${newPrivacyStatus === 1 ? '设为私密' : '设为公开'}`);
|
||
} catch (error) {
|
||
ElMessage.error('修改笔记状态失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// 图片上传
|
||
const handleImageUpload=async (files) => {
|
||
const promise = await uploadImage(files[0]);
|
||
if (promise.url == null) {
|
||
ElMessage.error(promise.msg);
|
||
return;
|
||
}
|
||
const url = promise.url;
|
||
// 从环境变量获取 baseURL,并与后端返回的 URL 路径拼接
|
||
const fullUrl = `${import.meta.env.VITE_API_BASE_URL}${url}`;
|
||
imageUrls.value.push(fullUrl);
|
||
vditor.value.insertValue(`![${files[0].name}](${fullUrl})`);
|
||
}
|
||
|
||
const debouncedSave = (value) => {
|
||
saveStatus.value = '正在输入...';
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => {
|
||
handleSave(value);
|
||
}, 2000);
|
||
};
|
||
|
||
const handleSave = async (content) => {
|
||
saveStatus.value = '正在保存...';
|
||
try {
|
||
// 构造一个干净的、只包含必要字段的 payload
|
||
const payload = {
|
||
id: editData.value.id, // 可能是 null,用于创建
|
||
title: editData.value.title,
|
||
groupingId: editData.value.groupingId,
|
||
content: content,
|
||
fileName: editData.value.fileName || `${editData.value.title}.md`,
|
||
};
|
||
|
||
// 调用后端接口
|
||
const response = await updateMarkdown(payload);
|
||
|
||
// 使用后端返回的完整、最新的数据更新前端状态
|
||
// 这对于新创建的笔记至关重要,因为它会获得一个新的 ID
|
||
editData.value = response;
|
||
|
||
// 如果当前正在预览这个文件,也更新 selectedFile
|
||
if (selectedFile.value && (!selectedFile.value.id || selectedFile.value.id === response.id)) {
|
||
selectedFile.value = response;
|
||
}
|
||
|
||
saveStatus.value = '已保存';
|
||
ElMessage.success('保存成功');
|
||
|
||
// 刷新文件列表以反映更改(例如,新文件出现)
|
||
await fetchMarkdownFiles();
|
||
// 如果当前在某个分类下,也刷新该分类的列表
|
||
if (activeMenu.value.startsWith('group-')) {
|
||
const groupId = activeMenu.value.split('-');
|
||
await selectFile({ id: groupId, grouping: currentGroupName.value });
|
||
}
|
||
|
||
} catch (error) {
|
||
saveStatus.value = '保存失败';
|
||
ElMessage.error('保存失败: ' + (error.response?.data?.message || error.message));
|
||
}
|
||
};
|
||
|
||
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 === 'file' ? item.title : item.grouping;
|
||
showRenameDialog.value = true;
|
||
};
|
||
|
||
const handleRename = async () => {
|
||
if (!newName.value.trim()) {
|
||
ElMessage.error('名称不能为空');
|
||
return;
|
||
}
|
||
try {
|
||
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;
|
||
}
|
||
} 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;
|
||
}
|
||
};
|
||
|
||
const handleDeleteGroup = (group) => {
|
||
ElMessageBox.confirm(
|
||
`确定要删除分类 “${group.grouping}” 吗?这将同时删除该分类下的所有子分类和笔记。`,
|
||
'警告',
|
||
{
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
}
|
||
).then(async () => {
|
||
try {
|
||
await apiDeleteGrouping(group.id);
|
||
ElMessage.success('分类已删除');
|
||
// 删除分类后,刷新分组树并回到主视图
|
||
await fetchGroupings();
|
||
await resetToHomeView();
|
||
} 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 fetchGroupings();
|
||
await resetToHomeView();
|
||
} catch (error) {
|
||
ElMessage.error('删除笔记失败: ' + error.message);
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleMarkdownUpload = (file) => {
|
||
fileToImport.value = file;
|
||
showSelectGroupDialog.value = true;
|
||
return false; // 阻止 el-upload 自动上传
|
||
};
|
||
|
||
const confirmImport = async () => {
|
||
if (!importGroupId.value) {
|
||
ElMessage.error('请选择要导入的分类');
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = async (e) => {
|
||
const content = e.target.result;
|
||
const payload = {
|
||
title: fileToImport.value.name.replace(/\.md$/, ''),
|
||
groupingId: importGroupId.value,
|
||
content: content,
|
||
fileName: fileToImport.value.name
|
||
};
|
||
try {
|
||
await updateMarkdown(payload);
|
||
ElMessage.success('Markdown 文件导入成功');
|
||
await fetchGroupings();
|
||
await fetchMarkdownFiles();
|
||
showSelectGroupDialog.value = false;
|
||
} catch (error) {
|
||
ElMessage.error('导入失败: ' + error.message);
|
||
}
|
||
};
|
||
reader.readAsText(fileToImport.value);
|
||
};
|
||
|
||
const handleMoveNote = async () => {
|
||
if (!moveToGroupId.value) {
|
||
ElMessage.error('请选择目标分类');
|
||
return;
|
||
}
|
||
try {
|
||
const payload = {
|
||
...selectedFile.value,
|
||
groupingId: moveToGroupId.value
|
||
};
|
||
await updateMarkdown(payload);
|
||
ElMessage.success('笔记移动成功');
|
||
showMoveNoteDialog.value = false;
|
||
selectedFile.value = null;
|
||
await fetchGroupings();
|
||
await fetchMarkdownFiles();
|
||
} catch (error) {
|
||
ElMessage.error('移动失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
const handleSearch = async () => {
|
||
if (!searchKeyword.value.trim()) {
|
||
groupMarkdownFiles.value = markdownFiles.value;
|
||
return;
|
||
}
|
||
try {
|
||
const response = await searchMarkdown(searchKeyword.value);
|
||
groupMarkdownFiles.value = response || [];
|
||
} catch (error) {
|
||
ElMessage.error('搜索失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
import jsPDF from 'jspdf';
|
||
import html2canvas from 'html2canvas';
|
||
|
||
|
||
const showExportLoading = ref(false);
|
||
|
||
// 文件名特殊字符清理
|
||
const sanitizeFilename = (name) => name.replace(/[<>:"/\\|?*]/g, '_').trim() || '未命名笔记';
|
||
|
||
const handleExport = async (format) => {
|
||
if (!selectedFile.value || showExportLoading.value) return;
|
||
|
||
try {
|
||
await validateToken();
|
||
} catch (error) {
|
||
ElMessage.error('登录已过期,请重新登录');
|
||
userStore.logout();
|
||
router.push('/login');
|
||
return;
|
||
}
|
||
|
||
const title = sanitizeFilename(selectedFile.value.title);
|
||
const content = selectedFile.value.content;
|
||
const previewElement = document.querySelector('.markdown-preview');
|
||
|
||
if (!previewElement) {
|
||
ElMessage.error('无法找到预览区域');
|
||
return;
|
||
}
|
||
|
||
showExportLoading.value = true;
|
||
ElMessage.info(`正在导出为 ${format.toUpperCase()}...`);
|
||
|
||
try {
|
||
switch (format) {
|
||
case 'md':
|
||
exportAsMd(title, content);
|
||
break;
|
||
case 'pdf':
|
||
await exportAsPdf(title, previewElement);
|
||
break;
|
||
case 'html':
|
||
exportAsHtml(title, previewElement.innerHTML);
|
||
break;
|
||
}
|
||
ElMessage.success(`${format.toUpperCase()} 导出成功`);
|
||
} catch (error) {
|
||
console.error('Export failed:', error);
|
||
ElMessage.error(`导出失败: ${error.message}`);
|
||
} finally {
|
||
showExportLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const exportAsMd = (title, content) => {
|
||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||
downloadBlob(blob, `${title}.md`);
|
||
};
|
||
|
||
const exportAsPdf = async (title, element) => {
|
||
const canvas = await html2canvas(element, {
|
||
scale: 2,
|
||
useCORS: true,
|
||
logging: false,
|
||
});
|
||
|
||
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 imgWidth = pageWidth;
|
||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||
let heightLeft = imgHeight;
|
||
let position = 10; // 初始Y轴位置
|
||
const imgData = canvas.toDataURL('image/png');
|
||
|
||
pdf.addImage(imgData, 'PNG', 10, position, imgWidth, imgHeight);
|
||
heightLeft -= pageHeight;
|
||
|
||
let pageCount = 1;
|
||
while (heightLeft > 0) {
|
||
pageCount++;
|
||
position = -pageHeight * (pageCount - 1) + 10;
|
||
pdf.addPage();
|
||
pdf.addImage(imgData, 'PNG', 10, position, imgWidth, imgHeight);
|
||
heightLeft -= pageHeight;
|
||
}
|
||
|
||
// 添加页眉和页脚
|
||
for (let i = 1; i <= pageCount; i++) {
|
||
pdf.setPage(i);
|
||
pdf.setFontSize(8);
|
||
pdf.setTextColor(150);
|
||
pdf.text(title, pdf.internal.pageSize.getWidth() / 2, 8, { align: 'center' });
|
||
pdf.text(`第 ${i} 页 / 共 ${pageCount} 页`, pdf.internal.pageSize.getWidth() / 2, pdf.internal.pageSize.getHeight() - 8, { align: 'center' });
|
||
}
|
||
|
||
pdf.save(`${title}.pdf`);
|
||
};
|
||
|
||
const exportAsHtml = (title, htmlContent) => {
|
||
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; }
|
||
@media (max-width: 767px) { body { padding: 15px; } }
|
||
</style>
|
||
</head>
|
||
<body class="markdown-body">
|
||
<h1>${title}</h1>
|
||
${htmlContent}
|
||
</body>
|
||
</html>
|
||
`;
|
||
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
|
||
downloadBlob(blob, `${title}.html`);
|
||
};
|
||
|
||
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);
|
||
}
|
||
|
||
const goToLogin = () => {
|
||
router.push('/login');
|
||
};
|
||
|
||
const goToRegister = () => {
|
||
router.push('/register');
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
userStore.logout();
|
||
ElMessage.success('已退出登录');
|
||
router.push('/login');
|
||
};
|
||
|
||
const goToTrash = () => {
|
||
router.push({ name: 'Trash' });
|
||
};
|
||
|
||
const resetToHomeView = async () => {
|
||
selectedFile.value = null;
|
||
showEditor.value = false;
|
||
searchKeyword.value = '';
|
||
try {
|
||
groupMarkdownFiles.value = await getRecentFiles() || [];
|
||
} catch (error) {
|
||
ElMessage.error('获取最近文件失败: ' + error.message);
|
||
groupMarkdownFiles.value = [];
|
||
}
|
||
};
|
||
|
||
|
||
watch(activeMenu, (newVal) => {
|
||
if (newVal === 'all') {
|
||
resetToHomeView();
|
||
}
|
||
});
|
||
|
||
watch([selectedFile, showEditor], ([newFile, newShowEditor]) => {
|
||
if (newFile && !newShowEditor) {
|
||
nextTick(() => {
|
||
const previewElement = document.querySelector('.markdown-preview');
|
||
if (previewElement) {
|
||
// 如果是私密笔记且用户未登录,显示提示内容
|
||
if (newFile.isPrivate === 1 && !userStore.isLoggedIn) {
|
||
// 渲染私密笔记提示内容
|
||
Vditor.preview(previewElement, privateNoteContent, {
|
||
mode: 'light',
|
||
hljs: {
|
||
enable: true,
|
||
style: 'github'
|
||
}
|
||
});
|
||
} else if (newFile.content) {
|
||
// 只有在有内容时才渲染 Markdown
|
||
Vditor.preview(previewElement, newFile.content, {
|
||
mode: 'light',
|
||
hljs: {
|
||
enable: true,
|
||
style: 'github'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}, { deep: true });
|
||
|
||
const handleToggleRegistration = async (value) => {
|
||
try {
|
||
await toggleRegistration(value);
|
||
ElMessage.success(`注册功能已${value ? '开启' : '关闭'}`);
|
||
} catch (error) {
|
||
ElMessage.error('操作失败');
|
||
isRegistrationEnabled.value = !value; // revert
|
||
}
|
||
};
|
||
|
||
const handleGenerateCode = async () => {
|
||
try {
|
||
const code = await generateRegistrationCode();
|
||
generatedCode.value = code;
|
||
ElMessage.success('注册码生成成功');
|
||
} catch (error) {
|
||
ElMessage.error('生成注册码失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
const copyToClipboard = (text) => {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
ElMessage.success('已复制到剪贴板');
|
||
}, () => {
|
||
ElMessage.error('复制失败');
|
||
});
|
||
};
|
||
|
||
onMounted(async () => {
|
||
await fetchGroupings();
|
||
await resetToHomeView();
|
||
try {
|
||
isRegistrationEnabled.value = await getRegistrationStatus();
|
||
} catch (error) {
|
||
console.error("Failed to fetch registration status:", error);
|
||
}
|
||
});
|
||
const handleUpdatePassword = async () => {
|
||
if (!updatePasswordFormRef.value) return;
|
||
await updatePasswordFormRef.value.validate(async (valid) => {
|
||
if (valid) {
|
||
try {
|
||
await updatePassword({
|
||
oldPassword: updatePasswordForm.value.oldPassword,
|
||
newPassword: updatePasswordForm.value.newPassword
|
||
});
|
||
ElMessage.success('密码修改成功,请重新登录');
|
||
showUpdatePasswordDialog.value = false;
|
||
await handleLogout();
|
||
} catch (error) {
|
||
ElMessage.error('密码修改失败: ' + error.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const resetUpdatePasswordForm = () => {
|
||
updatePasswordForm.value = { oldPassword: '', newPassword: '', confirmPassword: '' };
|
||
if (updatePasswordFormRef.value) {
|
||
updatePasswordFormRef.value.resetFields();
|
||
}
|
||
};
|
||
|
||
</script>
|
||
|
||
<style>
|
||
/* 全局 Element Plus 样式覆盖 */
|
||
.el-dialog {
|
||
border-radius: 8px !important;
|
||
box-shadow: var(--box-shadow-dark) !important;
|
||
}
|
||
|
||
.el-dialog__header {
|
||
border-bottom: 1px solid var(--border-color-light);
|
||
padding: 15px 20px !important;
|
||
margin-right: 0 !important;
|
||
}
|
||
|
||
.el-dialog__title {
|
||
color: var(--text-color) !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.el-dialog__body {
|
||
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);
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
.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 scoped>
|
||
.mobile-title {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.mobile-search-container {
|
||
padding: 0 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
:deep(.mobile-search-input .el-input__wrapper) {
|
||
border-radius: 9999px !important;
|
||
}
|
||
|
||
/* 整体布局 */
|
||
.home-page {
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
background: var(--bg-gradient);
|
||
background-attachment: fixed;
|
||
}
|
||
|
||
/* 侧边栏 */
|
||
.sidebar {
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
backdrop-filter: blur(10px);
|
||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||
transition: width var(--transition-duration) ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.dark-theme .sidebar {
|
||
background-color: rgba(23, 23, 39, 0.8);
|
||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 1rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.el-menu {
|
||
border-right: none;
|
||
background: transparent;
|
||
flex-grow: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.el-menu-vertical-demo:not(.el-menu--collapse) {
|
||
width: 250px;
|
||
}
|
||
|
||
:deep(.el-menu-item), :deep(.el-sub-menu__title) {
|
||
height: 48px;
|
||
line-height: 48px;
|
||
border-radius: var(--border-radius);
|
||
margin: 0.25rem 0.5rem;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
|
||
:deep(.el-menu-item.is-active) {
|
||
background-color: var(--primary-color);
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.el-menu-item:hover), :deep(.el-sub-menu__title:hover) {
|
||
background-color: var(--primary-color-light);
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.menu-item-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
gap: 5px;
|
||
}
|
||
|
||
.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;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
|
||
.edit-icon:hover, .delete-icon:hover {
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.content {
|
||
padding: 1.5rem;
|
||
background-color: transparent;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.header, .preview-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1.5rem;
|
||
padding: 1rem;
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: var(--border-radius);
|
||
box-shadow: var(--box-shadow-light);
|
||
gap: 1rem; /* Add gap between header items */
|
||
}
|
||
|
||
.dark-theme .header, .dark-theme .preview-header {
|
||
background-color: rgba(30, 30, 47, 0.8);
|
||
}
|
||
|
||
.preview-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-grow: 1;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.preview-title span {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.preview-title .edit-icon {
|
||
visibility: visible;
|
||
opacity: 0.6;
|
||
}
|
||
.preview-title .edit-icon:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
:deep(.search-input .el-input__wrapper) {
|
||
border-radius: var(--border-radius) !important;
|
||
}
|
||
|
||
.user-actions, .guest-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.welcome-text {
|
||
white-space: nowrap;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
|
||
.file-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
.file-item {
|
||
cursor: pointer;
|
||
border-radius: var(--border-radius);
|
||
border: 1px solid transparent;
|
||
transition: all var(--transition-duration) ease;
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.dark-theme .file-item {
|
||
background-color: rgba(30, 30, 47, 0.8);
|
||
}
|
||
|
||
.file-item:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: var(--box-shadow);
|
||
border-color: var(--primary-color);
|
||
}
|
||
|
||
.file-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.file-group-name {
|
||
font-size: 0.75rem;
|
||
color: var(--text-color-secondary);
|
||
background-color: var(--bg-color-secondary);
|
||
padding: 0.2rem 0.5rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.file-preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.markdown-preview {
|
||
flex-grow: 1;
|
||
overflow-y: auto;
|
||
padding: 2rem;
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: var(--border-radius);
|
||
box-shadow: var(--box-shadow-light);
|
||
}
|
||
|
||
.dark-theme .markdown-preview {
|
||
background-color: rgba(30, 30, 47, 0.8);
|
||
}
|
||
|
||
.vditor {
|
||
flex-grow: 1;
|
||
border: none;
|
||
}
|
||
|
||
.save-status {
|
||
font-size: 14px;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
|
||
.upload-btn {
|
||
display: inline-block;
|
||
/* margin-left is no longer needed due to gap in parent */
|
||
}
|
||
</style>
|
||
|
||
/* 对话框样式 */
|
||
:deep(.el-dialog) {
|
||
border-radius: var(--border-radius);
|
||
background-color: var(--bg-color-secondary);
|
||
}
|
||
|
||
:deep(.el-dialog__header) {
|
||
border-bottom: 1px solid var(--border-color);
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
:deep(.el-dialog__title) {
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
:deep(.el-dialog__body) {
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
:deep(.el-dialog__footer) {
|
||
padding: 1.5rem;
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
|
||
.welcome-text {
|
||
white-space: nowrap;
|
||
}
|
||
.user-actions, .guest-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|