feat: 功能迭代

This commit is contained in:
2026-05-31 21:03:35 +08:00
parent ab26ea7ef9
commit e49865600b
4 changed files with 217 additions and 188 deletions

View File

@@ -15,7 +15,8 @@
"Bash(npx electron-vite *)", "Bash(npx electron-vite *)",
"WebSearch", "WebSearch",
"WebFetch(domain:primevue.org)", "WebFetch(domain:primevue.org)",
"Bash(curl -s http://localhost:5173)" "Bash(curl -s http://localhost:5173)",
"Bash(curl -s http://localhost:5173/)"
] ]
} }
} }

View File

@@ -61,6 +61,8 @@ async function onNavigate(id) {
if (id === 'trash') { if (id === 'trash') {
await notesStore.loadNotes({ isTrashed: 1 }) await notesStore.loadNotes({ isTrashed: 1 })
} else if (id === 'favorites') {
await notesStore.loadNotes({ isTrashed: 0, isFavorited: 1 })
} else { } else {
await notesStore.loadNotes({ isTrashed: 0 }) await notesStore.loadNotes({ isTrashed: 0 })
} }
@@ -71,6 +73,15 @@ function onSelectNote(note) {
notesStore.getNote(note.id) notesStore.getNote(note.id)
} }
async function onToggleFavorite(note) {
await notesStore.toggleFavorite(note.id)
// If we're in the favorites view, reload to remove unfavorited notes
if (currentNav.value === 'favorites') {
await notesStore.loadNotes({ isTrashed: 0, isFavorited: 1 })
computeOverviewNotes()
}
}
function onBackToOverview() { function onBackToOverview() {
notesStore.clearCurrentNote() notesStore.clearCurrentNote()
// Reload notes to reflect any changes // Reload notes to reflect any changes
@@ -241,6 +252,7 @@ async function onWorkspaceConfirm(path) {
:notes="overviewNotes" :notes="overviewNotes"
:title="getOverviewTitle()" :title="getOverviewTitle()"
@select-note="onSelectNote" @select-note="onSelectNote"
@toggle-favorite="onToggleFavorite"
/> />
<!-- Editor Panel (when a note is selected) --> <!-- Editor Panel (when a note is selected) -->

View File

@@ -3,14 +3,14 @@ import { ref, computed } from 'vue'
import Button from 'primevue/button' import Button from 'primevue/button'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import ScrollPanel from 'primevue/scrollpanel' import ScrollPanel from 'primevue/scrollpanel'
import { Search, LayoutGrid, List, FileText, Clock, Calendar, Folder } from '@lucide/vue' import { Search, LayoutGrid, List, FileText, Clock, Calendar, Folder, Star } from '@lucide/vue'
const props = defineProps({ const props = defineProps({
notes: { type: Array, required: true }, notes: { type: Array, required: true },
title: { type: String, default: '所有笔记' } title: { type: String, default: '所有笔记' }
}) })
const emit = defineEmits(['select-note']) const emit = defineEmits(['select-note', 'toggle-favorite'])
const viewMode = ref('card') // 'card' | 'list' const viewMode = ref('card') // 'card' | 'list'
@@ -18,6 +18,11 @@ function selectNote(note) {
emit('select-note', note) emit('select-note', note)
} }
function toggleFavorite(note, event) {
event.stopPropagation()
emit('toggle-favorite', note)
}
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '' if (!dateStr) return ''
const d = new Date(dateStr) const d = new Date(dateStr)
@@ -104,9 +109,16 @@ function getPreview(note) {
<!-- Card Header --> <!-- Card Header -->
<div class="flex items-start gap-2 mb-3"> <div class="flex items-start gap-2 mb-3">
<FileText :size="18" class="text-primary-400 shrink-0 mt-0.5" /> <FileText :size="18" class="text-primary-400 shrink-0 mt-0.5" />
<h3 class="text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors"> <h3 class="flex-1 text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors">
{{ note.title || '无标题' }} {{ note.title || '无标题' }}
</h3> </h3>
<button
class="shrink-0 p-0.5 rounded transition-colors"
:class="note.is_favorited ? 'text-amber-400 hover:text-amber-500' : 'text-surface-300 hover:text-amber-400 opacity-0 group-hover:opacity-100'"
@click="toggleFavorite(note, $event)"
>
<Star :size="16" :fill="note.is_favorited ? 'currentColor' : 'none'" />
</button>
</div> </div>
<!-- Preview --> <!-- Preview -->
@@ -150,9 +162,18 @@ function getPreview(note) {
<FileText :size="18" class="text-primary-400 shrink-0" /> <FileText :size="18" class="text-primary-400 shrink-0" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors"> <div class="flex items-center gap-2">
{{ note.title || '无标题' }} <h3 class="text-sm font-semibold text-surface-900 line-clamp-1 group-hover:text-primary-600 transition-colors">
</h3> {{ note.title || '无标题' }}
</h3>
<button
class="shrink-0 p-0.5 rounded transition-colors"
:class="note.is_favorited ? 'text-amber-400 hover:text-amber-500' : 'text-surface-300 hover:text-amber-400 opacity-0 group-hover:opacity-100'"
@click="toggleFavorite(note, $event)"
>
<Star :size="14" :fill="note.is_favorited ? 'currentColor' : 'none'" />
</button>
</div>
<p class="text-xs text-surface-500 line-clamp-2 mt-0.5">{{ getPreview(note) }}</p> <p class="text-xs text-surface-500 line-clamp-2 mt-0.5">{{ getPreview(note) }}</p>
</div> </div>

View File

@@ -17,13 +17,19 @@ import {
import {useThemeStore} from '../stores/theme' import {useThemeStore} from '../stores/theme'
import {useNotesStore} from '../stores/notes' import {useNotesStore} from '../stores/notes'
import {useNotebooksStore} from '../stores/notebooks' import {useNotebooksStore} from '../stores/notebooks'
import {useNotebookTree} from '../composables/useNotebookTree' import {useSidebarTree} from '../composables/useSidebarTree'
import ButtonIcon from "./ButtonIcon.vue"; import ButtonIcon from "./ButtonIcon.vue";
const themeStore = useThemeStore() const themeStore = useThemeStore()
const notesStore = useNotesStore() const notesStore = useNotesStore()
const notebooksStore = useNotebooksStore() const notebooksStore = useNotebooksStore()
const {notebookTree, flatNotebooks, expandedKeys, loadNotebooks, loadNotes} = useNotebookTree()
const props = defineProps({
notebooks: {type: Array, default: () => []},
tags: {type: Array, default: () => []}
})
const {sidebarTree, flatNotebooks, expandedKeys, toggleNodeExpanded, loadNotebooks, loadNotes} = useSidebarTree(computed(() => props.tags))
const theme = computed(() => themeStore.theme) const theme = computed(() => themeStore.theme)
const toggleTheme = themeStore.toggleTheme const toggleTheme = themeStore.toggleTheme
@@ -34,11 +40,6 @@ const newNotebookParentName = computed(() => {
return nb ? nb.name : '' return nb ? nb.name : ''
}) })
const props = defineProps({
notebooks: {type: Array, default: () => []},
tags: {type: Array, default: () => []}
})
const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag', 'select-note']) const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag', 'select-note'])
// Selected tree node // Selected tree node
@@ -60,17 +61,6 @@ function toggleCollapsed() {
// Navigation state // Navigation state
const activeNav = ref('all') const activeNav = ref('all')
// Section expand state
const expandedSections = ref({
notes: true,
notebooks: true,
tags: true
})
function toggleSection(section) {
expandedSections.value[section] = !expandedSections.value[section]
}
// Search // Search
const searchQuery = ref('') const searchQuery = ref('')
const searchResults = ref([]) const searchResults = ref([])
@@ -88,18 +78,6 @@ async function onSearchInput() {
}, 200) }, 200)
} }
// Navigation items
const navSections = [
{
id: 'notes',
items: [
{id: 'all', icon: File, label: '所有笔记'},
{id: 'favorites', icon: Star, label: '收藏夹'},
{id: 'trash', icon: Trash2, label: '回收站'},
]
}
]
// Methods // Methods
function handleNav(id) { function handleNav(id) {
activeNav.value = id activeNav.value = id
@@ -116,14 +94,23 @@ function selectNotebook(notebook) {
* PrimeVue v4 passes node directly (not wrapped in event object) * PrimeVue v4 passes node directly (not wrapped in event object)
*/ */
function onTreeSelect(node) { function onTreeSelect(node) {
if (node.data.type === 'notebook') { const { type, id } = node.data
if (type === 'nav') {
selectedTreeKey.value = node.key selectedTreeKey.value = node.key
activeNav.value = `notebook-${node.data.id}` handleNav(id)
} else if (type === 'notebook') {
selectedTreeKey.value = node.key
activeNav.value = `notebook-${id}`
emit('select-notebook', node.data) emit('select-notebook', node.data)
} else if (node.data.type === 'note') { } else if (type === 'note') {
selectedTreeKey.value = node.key selectedTreeKey.value = node.key
activeNav.value = `note-${node.data.id}` activeNav.value = `note-${id}`
emit('select-note', node.data) emit('select-note', node.data)
} else if (type === 'tag') {
selectedTreeKey.value = node.key
activeNav.value = `tag-${id}`
emit('select-tag', node.data)
} }
} }
@@ -218,6 +205,29 @@ function getNotebookName(note) {
const nb = props.notebooks.find(n => n.id === note.notebook_id) const nb = props.notebooks.find(n => n.id === note.notebook_id)
return nb ? nb.name : '' 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> </script>
<template> <template>
@@ -278,15 +288,30 @@ function getNotebookName(note) {
<!-- Collapsed: Icon-only nav --> <!-- Collapsed: Icon-only nav -->
<template v-if="collapsed"> <template v-if="collapsed">
<div class="py-3 flex flex-col items-center gap-1"> <div class="py-3 flex flex-col items-center gap-1">
<!-- Notes nav items (collapsed) -->
<div <div
v-for="item in navSections[0].items"
:key="item.id"
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors" class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
:class="activeNav === item.id ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'" :class="activeNav === 'all' ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'"
@click="handleNav(item.id)" @click="handleNav('all')"
v-tooltip.right="item.label" v-tooltip.right="'所有笔记'"
> >
<component :is="item.icon" :size="18"/> <File :size="18"/>
</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>
<div class="w-8 h-px bg-surface-200 my-2"/> <div class="w-8 h-px bg-surface-200 my-2"/>
@@ -319,145 +344,103 @@ function getNotebookName(note) {
</div> </div>
</template> </template>
<!-- Expanded: Full nav --> <!-- Expanded: Unified Tree -->
<template v-else> <template v-else>
<!-- Notes Section --> <div class="py-2">
<div class="py-3"> <Tree
<div :value="sidebarTree"
class="px-4 py-1.5 flex items-center justify-between cursor-pointer hover:bg-surface-100 transition-colors" :expandedKeys="expandedKeys"
@click="toggleSection('notes')" 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' }
}"
> >
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">笔记</span> <template #default="slotProps">
<ChevronDown <!-- Group node (non-selectable header) -->
v-if="expandedSections.notes"
:size="14"
class="text-surface-400 transition-transform"
/>
<ChevronRight
v-else
:size="14"
class="text-surface-400 transition-transform"
/>
</div>
<Transition name="collapse">
<div v-show="expandedSections.notes">
<div <div
v-for="item in navSections[0].items" v-if="slotProps.node.data.type === 'group'"
:key="item.id" 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"
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg" @click="toggleNodeExpanded(slotProps.node.key)"
:class="activeNav === item.id
? 'bg-surface-200/70 text-primary-700 font-medium shadow-sm'
: 'text-surface-600 hover:bg-surface-100'"
@click="handleNav(item.id)"
> >
<component :is="item.icon" :size="18"/> <span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">
<span class="text-sm">{{ item.label }}</span> {{ 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> </div>
</div>
</Transition>
</div>
<!-- Notebooks Section --> <!-- Nav node (笔记导航项) -->
<div class="py-1">
<div class="px-4 py-1.5 flex items-center justify-between">
<div
class="flex items-center gap-1 cursor-pointer hover:bg-surface-100 transition-colors rounded px-1 py-0.5 -ml-1"
@click="toggleSection('notebooks')"
>
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">笔记本</span>
<ChevronDown
v-if="expandedSections.notebooks"
:size="14"
class="text-surface-400 transition-transform"
/>
<ChevronRight
v-else
:size="14"
class="text-surface-400 transition-transform"
/>
</div>
<button
class="w-6 h-6 flex items-center justify-center rounded hover:bg-surface-200 text-surface-400 hover:text-surface-600 transition-colors"
@click.stop="startNewNotebook"
title="新建笔记本"
>
<Plus :size="14"/>
</button>
</div>
<Transition name="collapse">
<div v-show="expandedSections.notebooks">
<Tree
:value="notebookTree"
:expandedKeys="expandedKeys"
selectionMode="single"
:selectionKeys="selectedTreeKey ? { [selectedTreeKey]: true } : {}"
@node-select="onTreeSelect"
class="w-full notebook-tree"
:pt="{
root: { class: 'border-none bg-transparent w-full' },
node: { class: 'py-0.5', style: 'width: 100%' },
nodeContent: {
class: ({ instance }) => [
'rounded-lg transition-colors min-w-0',
instance.selected
? 'bg-primary-50 text-primary-700 font-medium dark:bg-primary-900/30 dark:text-primary-300'
: 'hover:bg-surface-100'
],
style: 'width: 100%'
},
nodeLabel: { class: 'text-sm truncate' },
toggler: { class: 'w-6 h-6 shrink-0' }
}"
>
<template #default="slotProps">
<div 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 }}1</span>
<span>1</span>
</div>
</template>
</Tree>
<Menu ref="notebookMenuRef" :model="notebookMenuItems" :popup="true"/>
</div>
</Transition>
</div>
<!-- Tags Section -->
<div class="py-1">
<div
class="px-4 py-1.5 flex items-center justify-between cursor-pointer hover:bg-surface-100 transition-colors"
@click="toggleSection('tags')"
>
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">标签</span>
<ChevronDown
v-if="expandedSections.tags"
:size="14"
class="text-surface-400 transition-transform"
/>
<ChevronRight
v-else
:size="14"
class="text-surface-400 transition-transform"
/>
</div>
<Transition name="collapse">
<div v-show="expandedSections.tags">
<div <div
v-for="tag in tags" v-else-if="slotProps.node.data.type === 'nav'"
:key="tag.id" class="flex items-center gap-3 py-1.5 px-1 w-full"
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg group" >
:class="activeNav === `tag-${tag.id}` <component
? 'bg-amber-50 text-amber-700 font-medium shadow-sm' :is="slotProps.node.data.id === 'all' ? File :
: 'text-surface-600 hover:bg-surface-100'" slotProps.node.data.id === 'favorites' ? Star : Trash2"
@click="selectTag(tag)" :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"/> <div class="w-3 h-3 rounded-full flex-shrink-0 bg-amber-400"/>
<span class="text-sm flex-1">{{ tag.name }}</span> <span class="text-sm flex-1 truncate">{{ slotProps.node.label }}</span>
<span class="text-xs text-surface-400 opacity-0 group-hover:opacity-100 transition-opacity">{{ <span class="text-xs text-surface-400 opacity-0 group-hover/tag:opacity-100 transition-opacity">
tag.note_count || 0 {{ slotProps.node.data.note_count || 0 }}
}}</span> </span>
</div> </div>
</div> </template>
</Transition> </Tree>
<Menu ref="notebookMenuRef" :model="notebookMenuItems" :popup="true"/>
</div> </div>
</template> </template>
</ScrollPanel> </ScrollPanel>
@@ -556,44 +539,56 @@ function getNotebookName(note) {
max-height: 500px; max-height: 500px;
} }
/* Custom styles for notebook tree */ /* Custom styles for sidebar tree */
.notebook-tree :deep(.p-tree) { .sidebar-tree :deep(.p-tree) {
padding: 0; padding: 0;
} }
.notebook-tree :deep(.p-tree-node) { .sidebar-tree :deep(.p-tree-node) {
padding: 0; padding: 0;
width: 100%;
} }
.notebook-tree :deep(.p-tree-node-content) { .sidebar-tree :deep(.p-tree-node-content) {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
transition: background-color 0.2s; transition: background-color 0.2s;
min-width: 0; min-width: 0;
width: 100%;
} }
.notebook-tree :deep(.p-tree-node-content:hover) { .sidebar-tree :deep(.p-tree-node-content:hover) {
background-color: var(--surface-100); background-color: var(--surface-100);
} }
.notebook-tree :deep(.p-tree-node-selected) { :root.dark .sidebar-tree :deep(.p-tree-node-content:hover) {
background-color: var(--surface-800);
}
.sidebar-tree :deep(.p-tree-node-selected) {
/* Handled by pt nodeContent dynamic class */ /* Handled by pt nodeContent dynamic class */
} }
.notebook-tree :deep(.p-tree-toggler) { .sidebar-tree :deep(.p-tree-toggler) {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
margin-right: 0; margin-right: 0;
} }
.notebook-tree :deep(.p-tree-node-icon) { .sidebar-tree :deep(.p-tree-node-label) {
margin-right: 0.5rem;
}
.notebook-tree :deep(.p-tree-node-label) {
font-size: 0.875rem; font-size: 0.875rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
width: 100%;
}
/* Group nodes should not show hover effect */
.sidebar-tree :deep(.p-tree-node-content:has(.text-xs.font-semibold)) {
cursor: default;
}
.sidebar-tree :deep(.p-tree-node-content:has(.text-xs.font-semibold):hover) {
background-color: transparent;
} }
</style> </style>