Files
biji/biji-qianduan/src/components/HomePage.vue
黄孟 ae31a453d7 refactor(biji-qianduan): 优化笔记页面的私密设置按钮
- 为适应移动端和桌面端的用户界面,对私密设置按钮进行了调整
- 在移动端使用圆形按钮并添加锁图标,以节省空间并保持清晰的功能指示
- 桌面端保持原有文本按钮,提供更详细的提示信息
2025-08-08 16:56:39 +08:00

1573 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-container class="home-page" :class="{'is-mobile': isMobile}">
<!-- 左侧菜单区域 -->
<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;
}