feat: 功能迭代

This commit is contained in:
2026-06-01 00:36:51 +08:00
parent acd311a0d6
commit d3606eb196
5 changed files with 285 additions and 403 deletions

11
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"electron": "^42.3.0",
"electron-vite": "^5.0.0",
"gray-matter": "^4.0.3",
"lucide-vue-next": "^1.0.0",
"nanoid": "^5.1.11",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
@@ -5478,6 +5479,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -26,6 +26,7 @@
"electron": "^42.3.0",
"electron-vite": "^5.0.0",
"gray-matter": "^4.0.3",
"lucide-vue-next": "^1.0.0",
"nanoid": "^5.1.11",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",

View File

@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import * as LucideIcons from 'lucide-vue-next';
const props = defineProps({
name: {
type: String,
required: true,
},
size: {
type: [Number, String],
default: 24,
},
color: {
type: String,
default: 'currentColor',
},
strokeWidth: {
type: [Number, String],
default: 2,
},
});
const icon = computed(() => {
// Lucide 图标通常是 PascalCase用户输入可能是 kebab-case
const pascalName = props.name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
return LucideIcons[pascalName] || LucideIcons[props.name] || null;
});
</script>
<template>
<component :is="icon" v-if="icon" :size="size" :color="color" :stroke-width="strokeWidth" class="lucide-icon" />
<span
v-else
class="lucide-icon-fallback"
:style="{ width: size + 'px', height: size + 'px', display: 'inline-block' }"
></span>
</template>

View File

@@ -3,22 +3,15 @@ import {ref, computed, onMounted, nextTick} from 'vue'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Dialog from 'primevue/dialog'
import Menu from 'primevue/menu'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import ScrollPanel from 'primevue/scrollpanel'
import Tree from 'primevue/tree'
import {
Search, Plus, File, Star, Trash2,
Settings, Sun, Moon, Monitor,
BookOpen, Tag, ChevronDown, ChevronRight, Hash,
MoreHorizontal
} from '@lucide/vue'
import {Plus, File, Sun, Moon, Monitor, Settings} from '@lucide/vue'
import {useThemeStore} from '../stores/theme'
import {useNotesStore} from '../stores/notes'
import {useNotebooksStore} from '../stores/notebooks'
import {useSidebarTree} from '../composables/useSidebarTree'
import ButtonIcon from "./ButtonIcon.vue";
import Menu from 'primevue/menu'
import LucideIcon from "./LucideIcon.vue";
const themeStore = useThemeStore()
const notesStore = useNotesStore()
@@ -29,7 +22,22 @@ const props = defineProps({
tags: {type: Array, default: () => []}
})
const {sidebarTree, flatNotebooks, expandedKeys, toggleNodeExpanded, loadNotebooks, loadNotes} = useSidebarTree(computed(() => props.tags))
async function loadNotebooks() {
await notebooksStore.loadNotebooks()
}
async function loadNotes() {
await notesStore.loadNotes()
}
const items11 = ref([
{label: 'Documents', icon: 'search' },
{label: '收藏夹', icon: 'search'},
{label: '标签', icon: 'search'},
{label: '笔记本', icon: 'search'},
]);
const theme = computed(() => themeStore.theme)
const toggleTheme = themeStore.toggleTheme
@@ -42,22 +50,11 @@ const newNotebookParentName = computed(() => {
const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag', 'select-note'])
// Selected tree node
const selectedTreeKey = ref(null)
// Load notebooks and notes on mount
onMounted(async () => {
await loadNotebooks()
await loadNotes()
})
// Collapse state
const collapsed = ref(false)
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
// Navigation state
const activeNav = ref('all')
@@ -78,7 +75,6 @@ async function onSearchInput() {
}, 200)
}
// Methods
function handleNav(id) {
activeNav.value = id
emit('navigate', id)
@@ -89,72 +85,122 @@ function selectNotebook(notebook) {
emit('select-notebook', notebook)
}
/**
* Handle Tree node selection
* PrimeVue v4 passes node directly (not wrapped in event object)
*/
function onTreeSelect(node) {
const { type, id } = node.data
if (type === 'nav') {
selectedTreeKey.value = node.key
handleNav(id)
} else if (type === 'notebook') {
selectedTreeKey.value = node.key
activeNav.value = `notebook-${id}`
emit('select-notebook', node.data)
} else if (type === 'note') {
selectedTreeKey.value = node.key
activeNav.value = `note-${id}`
emit('select-note', node.data)
} else if (type === 'tag') {
selectedTreeKey.value = node.key
activeNav.value = `tag-${id}`
emit('select-tag', node.data)
}
}
function selectTag(tag) {
activeNav.value = `tag-${tag.id}`
emit('select-tag', tag)
}
function selectSearchNote(note) {
emit('select-note', note)
searchQuery.value = ''
searchResults.value = []
}
function getNotebookName(note) {
if (note.notebook_name) return note.notebook_name
const nb = props.notebooks.find(n => n.id === note.notebook_id)
return nb ? nb.name : ''
}
function createNote(notebookId) {
closeMenu()
emit('create-note', notebookId)
}
// Notebook actions menu (PrimeVue Menu)
const notebookMenuRef = ref(null)
const activeMenuNotebookId = ref(null)
// Notebook tree for PanelMenu
const notebookTreeItems = computed(() => {
const notebookList = notebooksStore.notebooks
const noteList = notesStore.notes
const notebookMenuItems = computed(() => [
{
label: '新建子笔记本',
function buildNotebookChildren(parentId) {
const children = []
const childNotebooks = notebookList.filter(nb => (nb.parent_id || null) === parentId)
childNotebooks.sort((a, b) => a.name.localeCompare(b.name))
childNotebooks.forEach(nb => {
const nbChildren = buildNotebookChildren(nb.id)
children.push({
key: `notebook-${nb.id}`,
label: nb.name,
icon: 'pi pi-folder',
command: () => startNewNotebook(activeMenuNotebookId.value)
command: () => selectNotebook(nb),
items: nbChildren.length > 0 ? nbChildren : undefined
})
})
const notebookNotes = noteList.filter(n => n.notebook_id === parentId && !n.is_trashed)
notebookNotes.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
notebookNotes.forEach(note => {
children.push({
key: `note-${note.id}`,
label: note.title || '无标题',
icon: 'pi pi-file',
command: () => emit('select-note', note)
})
})
return children
}
const rootNotebooks = notebookList.filter(nb => !nb.parent_id)
rootNotebooks.sort((a, b) => a.name.localeCompare(b.name))
return rootNotebooks.map(nb => {
const children = buildNotebookChildren(nb.id)
return {
key: `notebook-${nb.id}`,
label: nb.name,
icon: 'pi pi-folder',
command: () => selectNotebook(nb),
items: children.length > 0 ? children : undefined
}
})
})
// PanelMenu items
const items = computed(() => [
{
label: '导航',
icon: 'pi pi-compass',
items: [
{
label: '全部笔记',
icon: 'pi pi-home',
command: () => handleNav('all')
},
{
label: '新建笔记',
icon: 'pi pi-file',
command: () => {
closeMenu();
emit('create-note', activeMenuNotebookId.value)
}
label: '收藏',
icon: 'pi pi-star',
command: () => handleNav('favorites')
},
{
label: '最近编辑',
icon: 'pi pi-clock',
command: () => handleNav('recent')
},
{
label: '回收站',
icon: 'pi pi-trash',
command: () => handleNav('trash')
}
]
},
{
label: '笔记本',
icon: 'pi pi-book',
items: notebookTreeItems.value
},
...(props.tags && props.tags.length > 0 ? [{
label: '标签',
icon: 'pi pi-tag',
items: props.tags.map(tag => ({
label: tag.name,
icon: 'pi pi-tag',
command: () => selectTag(tag)
}))
}] : [])
])
function toggleNotebookMenu(notebookId, event) {
event.stopPropagation()
activeMenuNotebookId.value = notebookId
notebookMenuRef.value.toggle(event)
}
function closeMenu() {
activeMenuNotebookId.value = null
}
// New notebook creation (supports parent_id for sub-notebooks)
// New notebook dialog
const showNewNotebookDialog = ref(false)
const newNotebookName = ref('')
const newNotebookInputRef = ref(null)
@@ -164,7 +210,6 @@ function startNewNotebook(parentId = null) {
newNotebookName.value = ''
newNotebookParentId.value = parentId
showNewNotebookDialog.value = true
closeMenu()
nextTick(() => {
newNotebookInputRef.value?.$el?.focus()
})
@@ -192,52 +237,14 @@ function cancelNewNotebook() {
newNotebookName.value = ''
newNotebookParentId.value = null
}
function selectSearchNote(note) {
emit('select-note', note)
searchQuery.value = ''
searchResults.value = []
}
// Get notebook display name for a note
function getNotebookName(note) {
if (note.notebook_name) return note.notebook_name
const nb = props.notebooks.find(n => n.id === note.notebook_id)
return nb ? nb.name : ''
}
/**
* Get the active style class based on node type
*/
function getNodeActiveClass(node) {
const { type, id } = node.data
if (type === 'nav') {
return activeNav.value === id
? 'bg-surface-200/70 text-primary-700 font-medium shadow-sm'
: 'text-surface-600 hover:bg-surface-100'
}
if (type === 'notebook') {
return activeNav.value === `notebook-${id}`
? 'bg-emerald-50 text-emerald-700 font-medium shadow-sm'
: 'text-surface-600 hover:bg-surface-100'
}
if (type === 'tag') {
return activeNav.value === `tag-${id}`
? 'bg-amber-50 text-amber-700 font-medium shadow-sm'
: 'text-surface-600 hover:bg-surface-100'
}
return ''
}
</script>
<template>
<div
class="sidebar-container flex flex-col h-full bg-surface-50 border-r border-app-border transition-all duration-300"
:class="collapsed ? 'w-14' : 'w-64'">
<div class="sidebar-container flex flex-col h-full bg-surface-50 border-r border-app-border overflow-hidden w-64">
<!-- Top Bar: Toggle + (Search & Add when expanded) -->
<div class="p-3 border-b border-app-border flex items-center gap-2">
<template v-if="!collapsed">
<!-- Search bar -->
<div class="p-3 border-b border-app-border flex-shrink-0">
<div class="flex items-center gap-2">
<div class="flex-1 relative">
<IconField>
<InputIcon class="pi pi-search"/>
@@ -270,203 +277,20 @@ function getNodeActiveClass(node) {
</div>
</div>
<Button icon="pi pi-plus" size="small" severity="secondary" aria-label="新建笔记" @click="createNote"/>
</template>
<template v-else>
<div class="w-full flex justify-center">
<Button severity="primary" size="small" @click="createNote" v-tooltip.right="'新建笔记'"
class="btn-filled w-8 h-8">
<template #icon>
<Plus :size="20"/>
</template>
</Button>
</div>
</template>
</div>
<!-- Navigation Content -->
<ScrollPanel class="flex-1">
<!-- Collapsed: Icon-only nav -->
<template v-if="collapsed">
<div class="py-3 flex flex-col items-center gap-1">
<!-- Notes nav items (collapsed) -->
<div
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
:class="activeNav === 'all' ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'"
@click="handleNav('all')"
v-tooltip.right="'所有笔记'"
>
<File :size="18"/>
<!-- PanelMenu -->
<div class="flex-1 px-2 gap-1 ">
<Menu :model="items11"/>
<div v-for="(item,index) in items11" :key="index" class="flex items-center gap-2 px-2 py-1 cursor-pointer hover:bg-[#f1f5f9]">
<LucideIcon :name="item.icon" size="16 " color="red"></LucideIcon>
{{ item.label }}
</div>
<div
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
:class="activeNav === 'favorites' ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'"
@click="handleNav('favorites')"
v-tooltip.right="'收藏夹'"
>
<Star :size="18"/>
</div>
<div
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
:class="activeNav === 'trash' ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'"
@click="handleNav('trash')"
v-tooltip.right="'回收站'"
>
<Trash2 :size="18"/>
</div>
<div class="w-8 h-px bg-surface-200 my-2"/>
<!-- Notebooks (collapsed) -->
<div
v-for="nb in flatNotebooks.filter(nb => !nb.parent_id)"
:key="nb.id"
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
:class="activeNav === `notebook-${nb.id}` ? 'bg-emerald-50 text-emerald-600' : 'text-surface-500 hover:bg-surface-100'"
@click="selectNotebook(nb)"
v-tooltip.right="nb.name"
>
<BookOpen :size="18"/>
</div>
<div class="w-8 h-px bg-surface-200 my-2"/>
<!-- Tags (collapsed) -->
<div
v-for="tag in tags"
:key="tag.id"
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
:class="activeNav === `tag-${tag.id}` ? 'bg-amber-50 text-amber-600' : 'text-surface-500 hover:bg-surface-100'"
@click="selectTag(tag)"
v-tooltip.right="tag.name"
>
<Hash :size="18"/>
</div>
</div>
</template>
<!-- Expanded: Unified Tree -->
<template v-else>
<div class="py-2">
<Tree
:value="sidebarTree"
:expandedKeys="expandedKeys"
selectionMode="single"
:selectionKeys="selectedTreeKey ? { [selectedTreeKey]: true } : {}"
@node-select="onTreeSelect"
class="w-full sidebar-tree"
:pt="{
root: { class: 'border-none bg-transparent w-full !p-0' },
node: { class: 'py-0.5 w-full', style: 'width: 100%' },
nodeContent: {
class: ({ instance }) => [
'rounded-lg transition-colors min-w-0 w-full',
instance.selected
? 'bg-primary-50 text-primary-700 font-medium dark:bg-primary-900/30 dark:text-primary-300'
: 'hover:bg-surface-100 dark:hover:bg-surface-800'
],
style: 'width: 100%'
},
nodeLabel: { class: 'text-sm truncate' },
nodeIcon: { class: 'hidden' },
toggler: { class: 'w-6 h-6 shrink-0' }
}"
>
<template #default="slotProps">
<!-- Group node (non-selectable header) -->
<div
v-if="slotProps.node.data.type === 'group'"
class="flex items-center gap-2 py-1 px-1 w-full cursor-pointer hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors"
@click="toggleNodeExpanded(slotProps.node.key)"
>
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">
{{ slotProps.node.label }}
</span>
<button
v-if="slotProps.node.data.id === 'notebooks'"
class="w-5 h-5 flex items-center justify-center rounded hover:bg-surface-200 text-surface-400 hover:text-surface-600 transition-colors ml-auto"
@click.stop="startNewNotebook"
title="新建笔记本"
>
<Plus :size="12"/>
</button>
</div>
<!-- Nav node (笔记导航项) -->
<div
v-else-if="slotProps.node.data.type === 'nav'"
class="flex items-center gap-3 py-1.5 px-1 w-full"
>
<component
:is="slotProps.node.data.id === 'all' ? File :
slotProps.node.data.id === 'favorites' ? Star : Trash2"
:size="18"
/>
<span class="text-sm">{{ slotProps.node.label }}</span>
</div>
<!-- Notebook node -->
<div
v-else-if="slotProps.node.data.type === 'notebook'"
class="relative flex items-center justify-between w-full gap-1 py-1 px-1 min-w-0 group/node pr-8"
>
<span class="text-sm truncate flex-1">{{ slotProps.node.label }}</span>
<button
class="absolute right-1 w-6 h-6 flex items-center justify-center rounded hover:bg-surface-200 text-surface-400 hover:text-surface-600 opacity-0 group-hover/node:opacity-100 transition-opacity"
@click.stop="toggleNotebookMenu(slotProps.node.data.id, $event)"
>
<MoreHorizontal :size="14"/>
</button>
</div>
<!-- Note node -->
<div
v-else-if="slotProps.node.data.type === 'note'"
class="flex items-center gap-2 py-1 px-1 w-full"
>
<File :size="14" class="text-surface-400 shrink-0"/>
<span class="text-sm truncate">{{ slotProps.node.label }}</span>
</div>
<!-- Tag node -->
<div
v-else-if="slotProps.node.data.type === 'tag'"
class="flex items-center gap-3 py-1.5 px-1 w-full group/tag"
>
<div class="w-3 h-3 rounded-full flex-shrink-0 bg-amber-400"/>
<span class="text-sm flex-1 truncate">{{ slotProps.node.label }}</span>
<span class="text-xs text-surface-400 opacity-0 group-hover/tag:opacity-100 transition-opacity">
{{ slotProps.node.data.note_count || 0 }}
</span>
</div>
</template>
</Tree>
<Menu ref="notebookMenuRef" :model="notebookMenuItems" :popup="true"/>
</div>
</template>
</ScrollPanel>
<!-- Bottom Actions -->
<div class="p-3 border-t border-app-border">
<template v-if="collapsed">
<div class="flex flex-col items-center gap-2">
<Button text severity="secondary" @click="toggleTheme"
v-tooltip.right="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'"
class="btn-icon w-10 h-10">
<template #icon>
<Sun v-if="theme === 'light'" :size="20"/>
<Moon v-else-if="theme === 'dark'" :size="20"/>
<Monitor v-else :size="20"/>
</template>
</Button>
<Button text severity="secondary" @click="handleNav('settings')" v-tooltip.right="'设置'"
class="btn-icon w-10 h-10">
<template #icon>
<Settings :size="20"/>
</template>
</Button>
</div>
</template>
<template v-else>
<!-- Bottom bar: theme + settings -->
<div class="border-t border-app-border p-3 flex-shrink-0">
<div class="flex items-center gap-2">
<Button text severity="secondary" @click="toggleTheme" class="btn-icon h-10 px-3 flex items-center gap-2">
<template #icon>
@@ -484,7 +308,6 @@ function getNodeActiveClass(node) {
<span class="text-sm">设置</span>
</Button>
</div>
</template>
</div>
<!-- New Notebook Dialog -->
@@ -521,74 +344,77 @@ function getNodeActiveClass(node) {
</template>
<style scoped>
.collapse-enter-active,
.collapse-leave-active {
transition: all 0.2s ease;
overflow: hidden;
.sidebar-container :deep(.p-menu) {
border: 0;
background: transparent;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
max-height: 0;
}
.collapse-enter-to,
.collapse-leave-from {
opacity: 1;
max-height: 500px;
}
/* Custom styles for sidebar tree */
.sidebar-tree :deep(.p-tree) {
/*
.sidebar-container :deep(.p-menu-list) {
padding: 0;
}
.sidebar-tree :deep(.p-tree-node) {
padding: 0;
width: 100%;
.sidebar-container :deep(.p-menu-item-content) {
border-radius: 0;
}
.sidebar-tree :deep(.p-tree-node-content) {
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
min-width: 0;
width: 100%;
}
.sidebar-tree :deep(.p-tree-node-content:hover) {
.sidebar-container :deep(.p-menu-item-content:hover) {
background-color: var(--surface-100);
}
:root.dark .sidebar-tree :deep(.p-tree-node-content:hover) {
:root.dark .sidebar-container :deep(.p-menu-item-content:hover) {
background-color: var(--surface-800);
}*/
.sidebar-container :deep(.p-panelmenu) {
border: 0;
background: transparent;
}
.sidebar-container :deep(.p-panelmenu-panel) {
border: 0;
border-radius: 0;
background: transparent;
}
.sidebar-container :deep(.p-panelmenu-header) {
border: 0;
border-radius: 0;
background: transparent;
}
.sidebar-container :deep(.p-panelmenu-header-content) {
border-radius: 0;
}
.sidebar-container :deep(.p-panelmenu-header-content:hover) {
background-color: var(--surface-100);
}
:root.dark .sidebar-container :deep(.p-panelmenu-header-content:hover) {
background-color: var(--surface-800);
}
.sidebar-tree :deep(.p-tree-node-selected) {
/* Handled by pt nodeContent dynamic class */
.sidebar-container :deep(.p-panelmenu-content) {
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
}
.sidebar-tree :deep(.p-tree-toggler) {
width: 1.5rem;
height: 1.5rem;
margin-right: 0;
.sidebar-container :deep(.p-panelmenu-item-content) {
border-radius: 0;
}
.sidebar-tree :deep(.p-tree-node-label) {
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
.sidebar-container :deep(.p-panelmenu-item-content:hover) {
background-color: var(--surface-100);
}
/* Group nodes should not show hover effect */
.sidebar-tree :deep(.p-tree-node-content:has(.text-xs.font-semibold)) {
cursor: default;
:root.dark .sidebar-container :deep(.p-panelmenu-item-content:hover) {
background-color: var(--surface-800);
}
.sidebar-tree :deep(.p-tree-node-content:has(.text-xs.font-semibold):hover) {
background-color: transparent;
.sidebar-container :deep(.p-panelmenu-submenu) {
padding-left: 0.25rem;
}
</style>

View File

@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import Tooltip from 'primevue/tooltip'
import Ripple from 'primevue/ripple'
import App from './App.vue'
import './assets/styles/main.css'
import 'primeicons/primeicons.css'
@@ -21,5 +22,6 @@ app.use(PrimeVue, {
app.use(pinia)
app.directive('tooltip', Tooltip)
app.directive('ripple', Ripple)
app.mount('#app')