feat(components): 新增创建分类和笔记对话框及头部组件

- 新增 CreateGroupDialog 组件用于创建分类
- 新增 CreateNoteDialog 组件用于创建笔记
- 新增 HomeHeader 组件用于显示主页头部信息
- 对话框组件使用 Element Plus 样式- 头部组件包含用户操作按钮和搜索功能
This commit is contained in:
ikmkj
2025-08-08 20:19:52 +08:00
parent f00b60ddb7
commit c28b12ecd1
14 changed files with 1852 additions and 1425 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
<template>
<div>
<!-- Desktop Header -->
<el-header class="header" v-if="!isMobile">
<h1 @click="$emit('reset-view')" style="cursor: pointer; flex-grow: 1;">我的笔记</h1>
<div class="actions">
<el-input
:model-value="searchKeyword"
@update:model-value="$emit('update:searchKeyword', $event)"
placeholder="搜索笔记标题"
class="search-input"
@keyup.enter="$emit('search')"
>
<template #append>
<el-button @click="$emit('search')">
<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="$emit('logout')">退出</el-button>
<el-button type="primary" @click="$emit('show-update-password')">修改密码</el-button>
<el-button type="warning" @click="$emit('show-system-settings')">系统管理</el-button>
<el-button type="primary" @click="$emit('show-create-note')">新建笔记</el-button>
<el-upload
class="upload-btn"
action=""
:show-file-list="false"
:before-upload="handleUpload"
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="$emit('toggle-collapse')" text circle class="mobile-menu-toggle">
<el-icon size="24"><Menu /></el-icon>
</el-button>
<h1 class="mobile-title" @click="$emit('reset-view')">我的笔记</h1>
<el-button text circle class="mobile-search-toggle" @click="$emit('search')">
<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
:model-value="searchKeyword"
@update:model-value="$emit('update:searchKeyword', $event)"
placeholder="搜索笔记标题"
class="mobile-search-input"
@keyup.enter="$emit('search')"
size="large"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
</template>
<script setup>
import { Search, Menu, User } from '@element-plus/icons-vue';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
const props = defineProps({
isMobile: Boolean,
searchKeyword: String,
});
const emit = defineEmits([
'update:searchKeyword',
'search',
'reset-view',
'logout',
'show-update-password',
'show-system-settings',
'show-create-note',
'upload-markdown',
'toggle-collapse'
]);
const userStore = useUserStore();
const router = useRouter();
const goToLogin = () => router.push('/login');
const goToRegister = () => router.push('/register');
const handleUpload = (file) => {
emit('upload-markdown', file);
return false; // Prevent el-upload's default behavior
};
</script>
<style scoped>
.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;
}
.dark-theme .header {
background-color: rgba(30, 30, 47, 0.8);
}
.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);
}
.upload-btn {
display: inline-block;
}
.mobile-header {
padding: 0 1rem;
}
.mobile-title {
cursor: pointer;
flex-grow: 1;
text-align: center;
margin: 0;
font-size: 1.25rem;
}
.mobile-search-container {
padding: 0 1.5rem;
margin-bottom: 1.5rem;
}
:deep(.mobile-search-input .el-input__wrapper) {
border-radius: 9999px !important;
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<div class="note-editor-wrapper">
<el-header class="editor-header">
<h2 class="editor-title">{{ editData.title }}</h2>
<div class="actions">
<el-button type="primary" @click="$emit('back', editData)">返回</el-button>
<el-button type="success" @click="save">保存</el-button>
<span class="save-status">{{ saveStatus }}</span>
</div>
</el-header>
<div id="vditor-editor" class="vditor" />
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import { ElMessage } from 'element-plus';
import { updateMarkdown, uploadImage } from '@/api/CommonApi.js';
const props = defineProps({
editData: {
type: Object,
required: true,
},
});
const emit = defineEmits(['back', 'save-success']);
const vditor = ref(null);
const saveStatus = ref('空闲');
let debounceTimer = null;
const initVditor = () => {
vditor.value = new Vditor('vditor-editor', {
height: 'calc(100vh - 120px)',
mode: 'ir',
after: () => {
if (props.editData && props.editData.content) {
vditor.value.setValue(props.editData.content);
}
},
input: (value) => {
saveStatus.value = '正在输入...';
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
handleSave(value);
}, 2000);
},
upload: {
accept: 'image/*',
handler(files) {
handleImageUpload(files);
},
},
});
};
const handleImageUpload = async (files) => {
const file = files;
if (!file) return;
try {
const promise = await uploadImage(file);
if (promise.url == null) {
ElMessage.error(promise.msg || '图片上传失败');
return;
}
const fullUrl = `${import.meta.env.VITE_API_BASE_URL}${promise.url}`;
vditor.value.insertValue(`![${file.name}](${fullUrl})`);
} catch (error) {
ElMessage.error('图片上传失败: ' + error.message);
}
};
const handleSave = async (content) => {
saveStatus.value = '正在保存...';
try {
const payload = {
id: props.editData.id,
title: props.editData.title,
groupingId: props.editData.groupingId,
content: content,
fileName: props.editData.fileName || `${props.editData.title}.md`,
isPrivate: props.editData.isPrivate,
};
const response = await updateMarkdown(payload);
emit('save-success', response);
saveStatus.value = '已保存';
ElMessage.success('保存成功');
} catch (error) {
saveStatus.value = '保存失败';
ElMessage.error('保存失败: ' + (error.response?.data?.message || error.message));
}
};
const save = () => {
if (vditor.value) {
handleSave(vditor.value.getValue());
}
};
onMounted(() => {
initVditor();
});
onBeforeUnmount(() => {
if (vditor.value) {
vditor.value.destroy();
}
clearTimeout(debounceTimer);
});
watch(() => props.editData, (newData) => {
if (vditor.value && newData) {
vditor.value.setValue(newData.content || '');
}
}, { deep: true });
// Expose the save method to the parent
defineExpose({ save });
</script>
<style scoped>
.note-editor-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.editor-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);
}
.dark-theme .editor-header {
background-color: rgba(30, 30, 47, 0.8);
}
.editor-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
}
.save-status {
font-size: 14px;
color: var(--text-color-secondary);
width: 80px; /* Give it a fixed width to prevent layout shifts */
text-align: center;
}
.vditor {
flex-grow: 1;
border: none;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div v-if="files.length > 0" class="file-list">
<el-card
v-for="file in files"
:key="file.id"
shadow="hover"
class="file-item"
:class="{ 'private-note': file.isPrivate === 1 }"
>
<div @click="$emit('preview', file)" class="file-title">
<span>{{ file.title }}</span>
<span class="file-group-name">{{ file.groupingName }}</span>
<el-icon v-if="file.isPrivate === 1 && !isUserLoggedIn" class="lock-icon"><Lock /></el-icon>
</div>
</el-card>
</div>
<el-empty v-else description="暂无笔记,请创建或上传" />
</template>
<script setup>
import { Lock } from '@element-plus/icons-vue';
const props = defineProps({
files: {
type: Array,
required: true,
},
isUserLoggedIn: {
type: Boolean,
required: true,
}
});
defineEmits(['preview']);
</script>
<style scoped>
.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;
}
.lock-icon {
color: var(--text-color-secondary);
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="file-preview">
<el-header class="preview-header">
<h2 class="preview-title">
<span>{{ file.title }}</span>
<el-icon v-if="file.isPrivate === 1" class="lock-icon"><Lock /></el-icon>
<el-icon class="edit-icon" @click="$emit('open-rename-dialog', file, 'file')"><Edit /></el-icon>
</h2>
<div class="actions">
<el-button :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" @click="$emit('back')">
<el-icon v-if="isMobile"><Back /></el-icon>
<span v-else>返回</span>
</el-button>
<el-button v-if="isUserLoggedIn && !isMobile" type="warning" @click="$emit('show-move-note-dialog')">移动</el-button>
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" @click="$emit('edit')">
<el-icon v-if="isMobile"><Edit /></el-icon>
<span v-else>编辑</span>
</el-button>
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="danger" @click="$emit('delete')">
<el-icon v-if="isMobile"><Delete /></el-icon>
<span v-else>删除</span>
</el-button>
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="info" @click="$emit('show-privacy-dialog')">
<el-icon v-if="isMobile"><Lock /></el-icon>
<span v-else>{{ file.isPrivate === 1 ? '设为公开' : '设为私密' }}</span>
</el-button>
<el-dropdown v-if="isUserLoggedIn && !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 :key="file.id" class="markdown-preview">
<!-- Content is rendered by Vditor.preview in the parent -->
</div>
</div>
</template>
<script setup>
import { Lock, Edit, Delete, ArrowDown, Back } from '@element-plus/icons-vue';
const props = defineProps({
file: {
type: Object,
required: true,
},
isMobile: Boolean,
isUserLoggedIn: Boolean,
});
const emit = defineEmits([
'back',
'edit',
'delete',
'open-rename-dialog',
'show-move-note-dialog',
'show-privacy-dialog',
'export'
]);
const handleExport = (format) => {
emit('export', format);
};
</script>
<style scoped>
.file-preview {
display: flex;
flex-direction: column;
height: 100%;
}
.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;
}
.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;
cursor: pointer;
}
.preview-title .edit-icon:hover {
opacity: 1;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
}
.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);
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<el-aside class="sidebar" :width="isCollapsed ? (isMobile ? '0' : '64px') : '250px'">
<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="$emit('show-create-group')" circle>
<el-icon><Plus /></el-icon>
</el-button>
<el-button @click="$emit('toggle-collapse')" type="primary" size="small" circle v-if="!isMobile">
<el-icon>
<Fold v-if="!isCollapsed" />
<Expand v-else />
</el-icon>
</el-button>
</div>
<!-- Desktop Menu -->
<el-menu
v-if="!isMobile"
: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>
<!-- Mobile Menu -->
<el-menu
v-if="isMobile"
:default-active="activeMenu"
class="el-menu-vertical-demo"
:collapse="isCollapsed"
:collapse-transition="false"
>
<div class="mobile-menu-header">
<div v-if="userStore.isLoggedIn" class="user-info">
<span class="username">欢迎, {{ userStore.userInfo?.username }}</span>
</div>
<div v-else class="guest-info">
<el-button type="primary" @click="goToLogin">登录</el-button>
<el-button @click="goToRegister">注册</el-button>
</div>
</div>
<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>
<ElMenuItem v-if="userStore.isLoggedIn" index="system-settings" @click="$emit('show-system-settings')">
<ElIcon><setting /></ElIcon>
<template #title>系统管理</template>
</ElMenuItem>
<ElMenuItem v-if="userStore.isLoggedIn" index="update-password" @click="$emit('show-update-password')">
<ElIcon><lock /></ElIcon>
<template #title>修改密码</template>
</ElMenuItem>
<ElMenuItem v-if="userStore.isLoggedIn" index="logout" @click="$emit('logout')">
<ElIcon><SwitchButton /></ElIcon>
<template #title>退出登录</template>
</ElMenuItem>
</el-menu>
</el-aside>
</template>
<script setup>
import { h } from 'vue';
import { ElSubMenu, ElMenuItem, ElIcon, ElMessageBox, ElTooltip } from 'element-plus';
import { Folder, Delete, Edit, Plus, Fold, Expand, Setting, Lock, SwitchButton } from '@element-plus/icons-vue';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
import { deleteGrouping } from '@/api/CommonApi.js';
const props = defineProps({
isCollapsed: Boolean,
isMobile: Boolean,
activeMenu: String,
categoryTree: Array,
});
const emit = defineEmits([
'select-file',
'show-rename-dialog',
'show-create-group',
'toggle-collapse',
'group-deleted',
'show-system-settings',
'show-update-password',
'logout'
]);
const userStore = useUserStore();
const router = useRouter();
const goToTrash = () => router.push({ name: 'Trash' });
const goToLogin = () => router.push('/login');
const goToRegister = () => router.push('/register');
const handleDeleteGroup = (group) => {
ElMessageBox.confirm(
`确定要删除分类 “${group.grouping}” 吗?这将同时删除该分类下的所有子分类和笔记。`,
'警告',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
await deleteGrouping(group.id);
ElMessage.success('分类已删除');
emit('group-deleted');
} catch (error) {
ElMessage.error('删除分类失败: ' + error.message);
}
});
};
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(); emit('show-rename-dialog', 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: !props.isCollapsed,
effect: 'dark',
offset: 15,
}, { default: titleContent });
if (item.children && item.children.length > 0) {
return h(ElSubMenu, {
index: `group-${item.id}`,
popperClass: props.isCollapsed ? 'hide-popper' : ''
}, {
title: () => h('div', {
class: 'submenu-title-wrapper',
onClick: () => emit('select-file', 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: () => emit('select-file', item) }, {
default: wrappedContent
});
};
</script>
<style scoped>
.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;
}
: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);
}
.mobile-menu-header {
padding: 20px;
text-align: center;
border-bottom: 1px solid #e0e0e0;
}
.mobile-menu-header .username {
font-weight: bold;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<el-dialog
:model-value="visible"
title="新建分类"
width="400px"
@close="handleClose"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="父级分类">
<el-cascader
v-model="form.parentId"
:options="categoryOptions"
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'grouping' }"
clearable
placeholder="不选则为一级分类"
style="width: 100%;"
></el-cascader>
</el-form-item>
<el-form-item label="分类名称" prop="name">
<el-input v-model="form.name" autocomplete="off"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { addGroupings } from '@/api/CommonApi.js';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:visible', 'group-created']);
const formRef = ref(null);
const form = ref({
name: '',
parentId: null,
});
const rules = ref({
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }],
});
// 当对话框关闭时,重置表单
watch(() => props.visible, (newVal) => {
if (!newVal && formRef.value) {
formRef.value.resetFields();
form.value = { name: '', parentId: null };
}
});
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
try {
const payload = {
grouping: form.value.name,
parentId: form.value.parentId || 0,
};
await addGroupings(payload);
ElMessage.success('分类创建成功');
emit('group-created'); // 通知父组件刷新
handleClose();
} catch (error) {
ElMessage.error('创建分类失败: ' + error.message);
}
}
});
};
</script>

View File

@@ -0,0 +1,109 @@
<template>
<el-dialog
:model-value="visible"
title="新建笔记"
width="400px"
@close="handleClose"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="笔记标题" prop="title">
<el-input v-model="form.title" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="选择分类" prop="groupingId">
<el-cascader
v-model="form.groupingId"
:options="categoryOptions"
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'grouping' }"
clearable
placeholder="请选择笔记所属分类"
style="width: 100%;"
></el-cascader>
</el-form-item>
<el-form-item label="私密笔记">
<el-switch
v-model="form.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="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:visible', 'create-note']);
const formRef = ref(null);
const form = ref({
title: '',
groupingId: null,
isPrivate: 0,
});
const rules = ref({
title: [{ required: true, message: '请输入笔记标题', trigger: 'blur' }],
groupingId: [{ required: true, message: '请选择分类', trigger: 'change' }],
});
watch(() => props.visible, (newVal) => {
if (!newVal && formRef.value) {
formRef.value.resetFields();
form.value = { title: '', groupingId: null, isPrivate: 0 };
}
});
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate((valid) => {
if (valid) {
const payload = {
id: null,
title: form.value.title,
groupingId: form.value.groupingId,
fileName: form.value.title + '.md',
content: '',
isPrivate: form.value.isPrivate,
};
emit('create-note', payload);
handleClose();
} else {
ElMessage.error('请填写必要的字段');
}
});
};
</script>
<style scoped>
.form-item-help {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<el-dialog
:model-value="visible"
title="移动笔记到"
width="400px"
@close="handleClose"
:close-on-click-modal="false"
>
<el-cascader
v-model="moveToGroupId"
:options="categoryOptions"
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'grouping' }"
clearable
placeholder="请选择目标分类"
style="width: 100%;"
></el-cascader>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { updateMarkdown } from '@/api/CommonApi.js';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
noteToMove: {
type: Object,
default: null,
}
});
const emit = defineEmits(['update:visible', 'move-success']);
const moveToGroupId = ref(null);
watch(() => props.visible, (newVal) => {
if (!newVal) {
moveToGroupId.value = null;
}
});
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
if (!moveToGroupId.value) {
ElMessage.error('请选择目标分类');
return;
}
if (!props.noteToMove) {
ElMessage.error('没有需要移动的笔记');
return;
}
try {
const payload = {
...props.noteToMove,
groupingId: moveToGroupId.value,
};
await updateMarkdown(payload);
ElMessage.success('笔记移动成功');
emit('move-success');
handleClose();
} catch (error) {
ElMessage.error('移动失败: ' + error.message);
}
};
</script>

View File

@@ -0,0 +1,91 @@
<template>
<el-dialog
:model-value="visible"
:title="title"
width="400px"
@close="handleClose"
:close-on-click-modal="false"
>
<div v-if="note">
<p>您确定要将笔记 <strong>"{{ note.title }}"</strong> {{ note.isPrivate === 1 ? '设为公开' : '设为私密' }}</p>
<div class="privacy-explanation">
<el-icon><InfoFilled /></el-icon>
<span>{{ explanation }}</span>
</div>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
import { updateMarkdown } from '@/api/CommonApi.js';
import { InfoFilled } from '@element-plus/icons-vue';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
note: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update:visible', 'privacy-changed']);
const title = computed(() => {
if (!props.note) return '';
return props.note.isPrivate === 1 ? '设为公开笔记' : '设为私密笔记';
});
const explanation = computed(() => {
if (!props.note) return '';
return props.note.isPrivate === 1 ? '公开笔记:所有用户都可以查看内容' : '私密笔记:只有登录用户才能查看内容';
});
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
if (!props.note) return;
try {
const newPrivacyStatus = props.note.isPrivate === 1 ? 0 : 1;
const payload = {
...props.note,
isPrivate: newPrivacyStatus,
};
const updatedFile = await updateMarkdown(payload);
ElMessage.success(`笔记已${newPrivacyStatus === 1 ? '设为私密' : '设为公开'}`);
emit('privacy-changed', updatedFile);
handleClose();
} catch (error) {
ElMessage.error('修改笔记状态失败: ' + error.message);
}
};
</script>
<style scoped>
.privacy-explanation {
margin-top: 1rem;
padding: 0.75rem;
background-color: #f4f4f5;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
color: #909399;
}
.dark-theme .privacy-explanation {
background-color: #2c2c3e;
color: #a9a9a9;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<el-dialog
:model-value="visible"
title="重命名"
width="400px"
@close="handleClose"
:close-on-click-modal="false"
>
<el-input v-model="newName" placeholder="请输入新名称" @keyup.enter="handleSubmit"></el-input>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { updateMarkdownTitle, updateGroupingName } from '@/api/CommonApi.js';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
item: {
type: Object,
default: () => null,
},
});
const emit = defineEmits(['update:visible', 'renamed']);
const newName = ref('');
watch(() => props.item, (newItem) => {
if (newItem) {
newName.value = newItem.type === 'file' ? newItem.title : newItem.grouping;
}
});
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
if (!newName.value.trim()) {
ElMessage.error('名称不能为空');
return;
}
try {
if (props.item.type === 'file') {
await updateMarkdownTitle({ id: props.item.id, title: newName.value });
} else {
await updateGroupingName({ id: props.item.id, grouping: newName.value });
}
ElMessage.success('重命名成功');
emit('renamed');
handleClose();
} catch (error) {
ElMessage.error('重命名失败: ' + error.message);
}
};
</script>

View File

@@ -0,0 +1,88 @@
<template>
<el-dialog
:model-value="visible"
title="选择导入的分类"
width="400px"
@close="handleClose"
:close-on-click-modal="false"
>
<el-cascader
v-model="importGroupId"
:options="categoryOptions"
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'grouping' }"
clearable
placeholder="请选择要导入的分类"
style="width: 100%;"
></el-cascader>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { updateMarkdown } from '@/api/CommonApi.js';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
fileToImport: {
type: File,
default: null,
}
});
const emit = defineEmits(['update:visible', 'import-success']);
const importGroupId = ref(null);
watch(() => props.visible, (newVal) => {
if (!newVal) {
importGroupId.value = null;
}
});
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = () => {
if (!importGroupId.value) {
ElMessage.error('请选择要导入的分类');
return;
}
if (!props.fileToImport) {
ElMessage.error('没有需要导入的文件');
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target.result;
const payload = {
title: props.fileToImport.name.replace(/\.md$/, ''),
groupingId: importGroupId.value,
content: content,
fileName: props.fileToImport.name,
};
try {
await updateMarkdown(payload);
ElMessage.success('Markdown 文件导入成功');
emit('import-success');
handleClose();
} catch (error) {
ElMessage.error('导入失败: ' + error.message);
}
};
reader.readAsText(props.fileToImport);
};
</script>

View File

@@ -0,0 +1,98 @@
<template>
<el-dialog
:model-value="visible"
title="系统管理"
width="500px"
@close="handleClose"
:close-on-click-modal="false"
>
<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="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
getRegistrationStatus,
toggleRegistration,
generateRegistrationCode,
} from '@/api/CommonApi.js';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:visible']);
const isRegistrationEnabled = ref(true);
const generatedCode = ref('');
watch(() => props.visible, (newVal) => {
if (newVal) {
fetchRegistrationStatus();
generatedCode.value = ''; // Reset code when dialog opens
}
});
const fetchRegistrationStatus = async () => {
try {
isRegistrationEnabled.value = await getRegistrationStatus();
} catch (error) {
console.error("Failed to fetch registration status:", error);
ElMessage.error('获取注册状态失败');
}
};
const handleToggleRegistration = async (value) => {
try {
await toggleRegistration(value);
ElMessage.success(`注册功能已${value ? '开启' : '关闭'}`);
} catch (error) {
ElMessage.error('操作失败');
isRegistrationEnabled.value = !value; // Revert on failure
}
};
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('复制失败');
});
};
const handleClose = () => {
emit('update:visible', false);
};
</script>

View File

@@ -0,0 +1,97 @@
<template>
<el-dialog
:model-value="visible"
title="修改密码"
width="400px"
@close="handleClose"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="旧密码" prop="oldPassword">
<el-input v-model="form.oldPassword" type="password" show-password autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="form.newPassword" type="password" show-password autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="off"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { updatePassword } from '@/api/CommonApi.js';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:visible', 'password-updated']);
const userStore = useUserStore();
const router = useRouter();
const formRef = ref(null);
const form = ref({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
const validateConfirmPassword = (rule, value, callback) => {
if (value === '') {
callback(new Error('请再次输入新密码'));
} else if (value !== form.value.newPassword) {
callback(new Error("两次输入的新密码不一致"));
} else {
callback();
}
};
const rules = 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' }],
});
watch(() => props.visible, (newVal) => {
if (!newVal && formRef.value) {
formRef.value.resetFields();
}
});
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
try {
await updatePassword({
oldPassword: form.value.oldPassword,
newPassword: form.value.newPassword,
});
ElMessage.success('密码修改成功,请重新登录');
emit('password-updated');
handleClose();
// Logout logic will be handled by the parent component
} catch (error) {
ElMessage.error('密码修改失败: ' + error.message);
}
}
});
};
</script>