Files
biji/biji-qianduan/src/components/HomePage.vue
ikmkj 165bd5ea92 feat(user): 添加用户修改密码功能
- 在前端 HomePage 组件中添加修改密码对话框
- 在 API 中添加 updatePassword 接口
- 在后端 UserController 中添加密码更新接口
- 在 UserService 中添加 updatePassword 方法
- 实现密码更新逻辑,包括旧密码验证和新密码加密
2025-08-01 20:20:17 +08:00

1424 lines
41 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>
<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">
{{ 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="warning" @click="showMoveNoteDialog = true">移动</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-dropdown v-if="!showEditor && userStore.isLoggedIn" @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" 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 @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>
<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">
<span>{{ file.title }}</span>
<span class="file-group-name">{{ file.groupingName }}</span>
</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-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-main>
</el-container>
</el-container>
</template>
<script setup>
import {onMounted, ref, nextTick, watch, h, computed} 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 } 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 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 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 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 previewHtml = ref('');
const saveStatus = ref('空闲');
let debounceTimer = null;
const categoryCascaderOptions = computed(() => 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) => {
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) {
console.error('获取分组失败:', error);
ElMessage.error('获取分组失败: ' + error.message);
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: ''
};
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) => {
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.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[0]);
if (promise.code !== 200) {
ElMessage.error(promise.msg);
return;
}
const url = promise.data.url;
// 修正IP地址并正确拼接
const baseUrl = "http://127.0.0.1:8084";
imageUrls.value.push(baseUrl + url);
vditor.value.insertValue(`![${files[0].name}](${baseUrl + url})`);
}
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.data;
// 如果当前正在预览这个文件,也更新 selectedFile
if (selectedFile.value && (!selectedFile.value.id || selectedFile.value.id === response.data.id)) {
selectedFile.value = response.data;
}
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.data;
} 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();
}
});
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>
<style scoped>
/* 整体布局 */
.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;
}
.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;
}