feat: 功能迭代

This commit is contained in:
2026-05-31 17:13:46 +08:00
parent d13a43a143
commit ab26ea7ef9
11 changed files with 868 additions and 191 deletions

View File

@@ -12,7 +12,10 @@
"Bash(ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node *)",
"Bash(pkill -f \"electron-vite\")",
"Bash(npm list *)",
"Bash(npx electron-vite *)"
"Bash(npx electron-vite *)",
"WebSearch",
"WebFetch(domain:primevue.org)",
"Bash(curl -s http://localhost:5173)"
]
}
}

136
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"electron-vite": "^5.0.0",
"gray-matter": "^4.0.3",
"nanoid": "^5.1.11",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"rehype-stringify": "^10.0.1",
@@ -2110,6 +2111,39 @@
"@vue/shared": "3.5.35"
}
},
"node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
@@ -2568,6 +2602,15 @@
"node": ">=6.0.0"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -3003,6 +3046,21 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -4767,6 +4825,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -4962,6 +5026,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/isbinaryfile": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
@@ -6326,6 +6402,12 @@
"node": ">= 18"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -6591,6 +6673,12 @@
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6609,6 +6697,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
@@ -6982,6 +7091,12 @@
"node": ">= 4"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
@@ -7248,6 +7363,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -7335,6 +7459,18 @@
"node": ">= 8.0"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -26,6 +26,7 @@
"electron-vite": "^5.0.0",
"gray-matter": "^4.0.3",
"nanoid": "^5.1.11",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"rehype-stringify": "^10.0.1",

View File

@@ -1,10 +1,12 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import { app, BrowserWindow, ipcMain, nativeTheme, dialog } from 'electron'
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { is } from '@electron-toolkit/utils'
import { StorageService } from './services/StorageService'
let mainWindow = null
let storageService = null
let workspaceReady = false
// 标题栏颜色配置
const titleBarColors = {
@@ -18,6 +20,39 @@ const titleBarColors = {
}
}
// ==================== Config Management ====================
const configPath = join(app.getPath('userData'), 'config.json')
function loadConfig() {
try {
if (existsSync(configPath)) {
const data = readFileSync(configPath, 'utf-8')
return JSON.parse(data)
}
} catch (err) {
console.error('Failed to load config:', err)
}
return {}
}
function saveConfig(config) {
try {
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8')
} catch (err) {
console.error('Failed to save config:', err)
}
}
function sendWorkspaceStatus() {
if (!mainWindow) return
const config = loadConfig()
mainWindow.webContents.send('workspace:status', {
ready: workspaceReady,
path: config.workspacePath || null
})
}
function createWindow() {
// 根据系统主题设置初始标题栏颜色
const isDark = nativeTheme.shouldUseDarkColors
@@ -47,6 +82,11 @@ function createWindow() {
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
// Send workspace status once renderer is ready
mainWindow.webContents.on('did-finish-load', () => {
sendWorkspaceStatus()
})
}
// 处理渲染进程的主题切换请求
@@ -54,11 +94,84 @@ ipcMain.handle('theme:set', (_, theme) => {
if (!mainWindow) return
const colors = theme === 'dark' ? titleBarColors.dark : titleBarColors.light
mainWindow.setTitleBarOverlay({
color: colors.color,
symbolColor: colors.symbolColor,
height: 36
try {
mainWindow.setTitleBarOverlay({
color: colors.color,
symbolColor: colors.symbolColor,
height: 36
})
} catch (err) {
// Titlebar overlay may not be enabled on all platforms
console.warn('Failed to set titlebar overlay:', err.message)
}
})
// ==================== Workspace IPC Handlers ====================
ipcMain.handle('workspace:selectFolder', async () => {
if (!mainWindow) return { canceled: true }
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory'],
title: '选择笔记存储文件夹',
buttonLabel: '选择此文件夹'
})
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true }
}
return { canceled: false, path: result.filePaths[0] }
})
ipcMain.handle('workspace:setPath', async (_, workspacePath) => {
console.log('[workspace:setPath] Setting workspace to:', workspacePath)
try {
// Check if this folder already has a Tunji workspace
const tunjiDir = join(workspacePath, '.tunji')
const isExistingWorkspace = existsSync(tunjiDir)
// Close existing storage service if any
if (storageService) {
storageService.flush()
storageService.close()
storageService = null
workspaceReady = false
}
// Save config
saveConfig({ workspacePath })
console.log('[workspace:setPath] Config saved')
// Initialize storage service with new path
storageService = new StorageService()
await storageService.init(workspacePath)
workspaceReady = true
console.log('[workspace:setPath] StorageService initialized, existing:', isExistingWorkspace)
// Notify renderer
sendWorkspaceStatus()
return { success: true, path: workspacePath, isExisting: isExistingWorkspace }
} catch (err) {
console.error('[workspace:setPath] Failed:', err)
workspaceReady = false
return { success: false, error: err.message }
}
})
ipcMain.handle('workspace:getStatus', () => {
const config = loadConfig()
return {
ready: workspaceReady,
path: config.workspacePath || null
}
})
ipcMain.handle('workspace:checkFolder', (_, folderPath) => {
const tunjiDir = join(folderPath, '.tunji')
const hasWorkspace = existsSync(tunjiDir)
return { hasWorkspace }
})
// ==================== Register IPC Handlers ====================
@@ -225,15 +338,23 @@ function registerIpcHandlers() {
// ==================== App Lifecycle ====================
app.whenReady().then(async () => {
// Initialize storage with user data directory
const workspacePath = join(app.getPath('documents'), 'Tunji')
storageService = new StorageService()
await storageService.init(workspacePath)
// Register IPC handlers
// Register IPC handlers first (workspace handlers are already registered above)
registerIpcHandlers()
// Create window
// Check if workspace is already configured
const config = loadConfig()
if (config.workspacePath && existsSync(config.workspacePath)) {
try {
storageService = new StorageService()
await storageService.init(config.workspacePath)
workspaceReady = true
} catch (err) {
console.error('Failed to initialize storage:', err)
workspaceReady = false
}
}
// Create window (will send workspace status on load)
createWindow()
})

View File

@@ -37,4 +37,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Stats
getStats: () => ipcRenderer.invoke('stats:get'),
// Workspace
workspace: {
selectFolder: () => ipcRenderer.invoke('workspace:selectFolder'),
setPath: (path) => ipcRenderer.invoke('workspace:setPath', path),
getStatus: () => ipcRenderer.invoke('workspace:getStatus'),
checkFolder: (path) => ipcRenderer.invoke('workspace:checkFolder', path),
onStatusChange: (callback) => {
ipcRenderer.on('workspace:status', (_, data) => callback(data))
},
},
})

View File

@@ -1,41 +1,33 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch, computed } from 'vue'
import SideBar from './components/SideBar.vue'
import NotesOverview from './components/NotesOverview.vue'
import EditorPanel from './components/EditorPanel.vue'
import { useTheme } from './composables/useTheme'
import { useNotes } from './composables/useNotes'
import { useNotebooks } from './composables/useNotebooks'
import { useTags } from './composables/useTags'
import WorkspaceSetup from './components/WorkspaceSetup.vue'
import { useNotesStore } from './stores/notes'
import { useNotebooksStore } from './stores/notebooks'
import { useTagsStore } from './stores/tags'
import { useWorkspaceStore } from './stores/workspace'
import { useThemeStore } from './stores/theme'
// 初始化 stores
const notesStore = useNotesStore()
const notebooksStore = useNotebooksStore()
const tagsStore = useTagsStore()
const workspaceStore = useWorkspaceStore()
const themeStore = useThemeStore()
// 初始化主题系统
const { theme, resolvedTheme, isDark, toggleTheme } = useTheme()
themeStore.initialize()
// 数据
const {
notes: allNotes,
currentNote,
loading: notesLoading,
loadNotes,
createNote,
getNote,
updateNote,
deleteNote,
searchNotes,
setCurrentNote,
clearCurrentNote
} = useNotes()
const {
notebooks,
loadNotebooks,
createNotebook
} = useNotebooks()
const {
tags,
loadTags
} = useTags()
// 从 stores 获取响应式数据
const allNotes = computed(() => notesStore.notes)
const currentNote = computed(() => notesStore.currentNote)
const notesLoading = computed(() => notesStore.loading)
const notebooks = computed(() => notebooksStore.notebooks)
const tags = computed(() => tagsStore.tags)
const workspaceReady = computed(() => workspaceStore.isReady)
const workspaceLoading = computed(() => workspaceStore.loading)
const currentNav = ref('all')
const filterType = ref('all')
@@ -65,50 +57,87 @@ async function onNavigate(id) {
currentNav.value = id
filterType.value = 'all'
filterValue.value = null
clearCurrentNote()
notesStore.clearCurrentNote()
if (id === 'trash') {
await loadNotes({ isTrashed: 1 })
await notesStore.loadNotes({ isTrashed: 1 })
} else {
await loadNotes({ isTrashed: 0 })
await notesStore.loadNotes({ isTrashed: 0 })
}
computeOverviewNotes()
}
function onSelectNote(note) {
getNote(note.id)
notesStore.getNote(note.id)
}
function onBackToOverview() {
clearCurrentNote()
notesStore.clearCurrentNote()
// Reload notes to reflect any changes
loadNotes({ isTrashed: filterType.value === 'trash' ? 1 : 0 }).then(() => {
notesStore.loadNotes({ isTrashed: filterType.value === 'trash' ? 1 : 0 }).then(() => {
computeOverviewNotes()
})
}
async function onCreateNote() {
const notebookId = filterType.value === 'notebook' && filterValue.value
? filterValue.value.id
: 'default'
/**
* 生成下一个可用的"无标题"序号
* 规则无标题、无标题1、无标题2、无标题3 ...
*/
function generateUntitledTitle(notebookId) {
const prefix = '无标题'
const notesInNotebook = notesStore.notes.filter(
n => n.notebook_id === notebookId && !n.is_trashed
)
const note = await createNote({
title: '无标题',
// 收集所有已占用的序号
const usedIndices = new Set()
for (const note of notesInNotebook) {
const title = note.title
if (title === prefix) {
usedIndices.add(0) // "无标题" 本身对应 index 0
} else {
const match = title.match(/^无标题(\d+)$/)
if (match) {
usedIndices.add(parseInt(match[1], 10))
}
}
}
// 找到最小的未占用序号
let index = 0
while (usedIndices.has(index)) {
index++
}
return index === 0 ? prefix : `${prefix}${index}`
}
async function onCreateNote(notebookId) {
if (!notebookId) {
notebookId = filterType.value === 'notebook' && filterValue.value
? filterValue.value.id
: 'default'
}
const title = generateUntitledTitle(notebookId)
const note = await notesStore.createNote({
title,
content: '',
notebookId
})
if (note) {
// Auto select the new note
getNote(note.id)
notesStore.getNote(note.id)
}
}
function onSelectNotebook(notebook) {
filterType.value = 'notebook'
filterValue.value = notebook
clearCurrentNote()
loadNotes({ notebookId: notebook.id, isTrashed: 0 }).then(() => {
notesStore.clearCurrentNote()
notesStore.loadNotes({ notebookId: notebook.id, isTrashed: 0 }).then(() => {
computeOverviewNotes()
})
}
@@ -116,8 +145,8 @@ function onSelectNotebook(notebook) {
function onSelectTag(tag) {
filterType.value = 'tag'
filterValue.value = tag
clearCurrentNote()
loadNotes({ tagId: tag.id, isTrashed: 0 }).then(() => {
notesStore.clearCurrentNote()
notesStore.loadNotes({ tagId: tag.id, isTrashed: 0 }).then(() => {
computeOverviewNotes()
})
}
@@ -142,18 +171,55 @@ function getOverviewTitle() {
}
// Initialize: load data on mount
onMounted(async () => {
const setupComplete = ref(false)
async function loadAllData() {
await Promise.all([
loadNotes({ isTrashed: 0 }),
loadNotebooks(),
loadTags()
notesStore.loadNotes({ isTrashed: 0 }),
notebooksStore.loadNotebooks(),
tagsStore.loadTags()
])
computeOverviewNotes()
}
onMounted(() => {
// 初始化 workspace store
workspaceStore.initialize()
// checkStatus is called automatically by workspaceStore on listen
// If already ready (e.g. config existed), load data immediately
if (workspaceReady.value) {
loadAllData()
}
})
// Watch for workspace becoming ready (either on mount or after setup)
watch(workspaceReady, (ready) => {
if (ready && !setupComplete.value) {
setupComplete.value = true
loadAllData()
}
})
async function onWorkspaceConfirm(path) {
const success = await workspaceStore.initWorkspace(path)
if (!success) {
// TODO: show error toast
console.error('Failed to initialize workspace')
}
}
</script>
<template>
<div class="h-screen flex flex-col overflow-hidden bg-surface-50">
<!-- Workspace Setup (first-time welcome) -->
<WorkspaceSetup
v-if="!workspaceReady"
:loading="workspaceLoading"
@confirm="onWorkspaceConfirm"
/>
<!-- Main App -->
<div v-else class="h-screen flex flex-col overflow-hidden bg-surface-50">
<!-- Main Layout: Two Columns -->
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->

View File

@@ -107,8 +107,8 @@ function getTagSeverity(tag) {
:class="selectedNoteId === note.id ? 'bg-primary-50' : 'hover:bg-surface-50'"
@click="selectNote(note)"
>
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-semibold text-surface-900 truncate">{{ note.title }}</span>
<div class="mb-1">
<span class="text-sm font-semibold text-surface-900 line-clamp-2">{{ note.title }}</span>
</div>
<p class="text-xs text-surface-500 line-clamp-2 mb-1.5">{{ note.preview }}</p>
<div class="flex items-center justify-between">

View File

@@ -150,10 +150,10 @@ function getPreview(note) {
<FileText :size="18" class="text-primary-400 shrink-0" />
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-surface-900 truncate group-hover:text-primary-600 transition-colors">
<h3 class="text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors">
{{ note.title || '无标题' }}
</h3>
<p class="text-xs text-surface-500 truncate mt-0.5">{{ getPreview(note) }}</p>
<p class="text-xs text-surface-500 line-clamp-2 mt-0.5">{{ getPreview(note) }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">

View File

@@ -1,29 +1,55 @@
<script setup>
import { ref, computed } from 'vue'
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
BookOpen, Tag, ChevronDown, ChevronRight, Hash,
MoreHorizontal
} from '@lucide/vue'
import { useTheme } from '../composables/useTheme'
import { useNotes } from '../composables/useNotes'
import {useThemeStore} from '../stores/theme'
import {useNotesStore} from '../stores/notes'
import {useNotebooksStore} from '../stores/notebooks'
import {useNotebookTree} from '../composables/useNotebookTree'
import ButtonIcon from "./ButtonIcon.vue";
const { theme, toggleTheme } = useTheme()
const { searchNotes } = useNotes()
const themeStore = useThemeStore()
const notesStore = useNotesStore()
const notebooksStore = useNotebooksStore()
const {notebookTree, flatNotebooks, expandedKeys, loadNotebooks, loadNotes} = useNotebookTree()
const theme = computed(() => themeStore.theme)
const toggleTheme = themeStore.toggleTheme
const newNotebookParentName = computed(() => {
if (!newNotebookParentId.value) return ''
const nb = notebooksStore.notebooks.find(n => n.id === newNotebookParentId.value)
return nb ? nb.name : ''
})
const props = defineProps({
notebooks: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] }
notebooks: {type: Array, default: () => []},
tags: {type: Array, default: () => []}
})
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)
@@ -58,7 +84,7 @@ async function onSearchInput() {
return
}
searchTimer = setTimeout(async () => {
searchResults.value = await searchNotes(q)
searchResults.value = await notesStore.searchNotes(q)
}, 200)
}
@@ -67,9 +93,9 @@ const navSections = [
{
id: 'notes',
items: [
{ id: 'all', icon: File, label: '所有笔记' },
{ id: 'favorites', icon: Star, label: '收藏夹' },
{ id: 'trash', icon: Trash2, label: '回收站' },
{id: 'all', icon: File, label: '所有笔记'},
{id: 'favorites', icon: Star, label: '收藏夹'},
{id: 'trash', icon: Trash2, label: '回收站'},
]
}
]
@@ -85,13 +111,99 @@ function selectNotebook(notebook) {
emit('select-notebook', notebook)
}
/**
* Handle Tree node selection
* PrimeVue v4 passes node directly (not wrapped in event object)
*/
function onTreeSelect(node) {
if (node.data.type === 'notebook') {
selectedTreeKey.value = node.key
activeNav.value = `notebook-${node.data.id}`
emit('select-notebook', node.data)
} else if (node.data.type === 'note') {
selectedTreeKey.value = node.key
activeNav.value = `note-${node.data.id}`
emit('select-note', node.data)
}
}
function selectTag(tag) {
activeNav.value = `tag-${tag.id}`
emit('select-tag', tag)
}
function createNote() {
emit('create-note')
function createNote(notebookId) {
closeMenu()
emit('create-note', notebookId)
}
// Notebook actions menu (PrimeVue Menu)
const notebookMenuRef = ref(null)
const activeMenuNotebookId = ref(null)
const notebookMenuItems = computed(() => [
{
label: '新建子笔记本',
icon: 'pi pi-folder',
command: () => startNewNotebook(activeMenuNotebookId.value)
},
{
label: '新建笔记',
icon: 'pi pi-file',
command: () => {
closeMenu();
emit('create-note', activeMenuNotebookId.value)
}
}
])
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)
const showNewNotebookDialog = ref(false)
const newNotebookName = ref('')
const newNotebookInputRef = ref(null)
const newNotebookParentId = ref(null)
function startNewNotebook(parentId = null) {
newNotebookName.value = ''
newNotebookParentId.value = parentId
showNewNotebookDialog.value = true
closeMenu()
nextTick(() => {
newNotebookInputRef.value?.$el?.focus()
})
}
async function confirmNewNotebook() {
const name = newNotebookName.value.trim()
if (!name) return
const data = {name}
if (newNotebookParentId.value) {
data.parentId = newNotebookParentId.value
}
const notebook = await notebooksStore.createNotebook(data)
if (notebook) {
await loadNotebooks()
await loadNotes()
}
showNewNotebookDialog.value = false
newNotebookName.value = ''
newNotebookParentId.value = null
}
function cancelNewNotebook() {
showNewNotebookDialog.value = false
newNotebookName.value = ''
newNotebookParentId.value = null
}
function selectSearchNote(note) {
@@ -109,36 +221,37 @@ function getNotebookName(note) {
</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 transition-all duration-300"
:class="collapsed ? 'w-14' : '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">
<div class="flex-1 relative">
<IconField>
<InputIcon class="pi pi-search" />
<InputIcon class="pi pi-search"/>
<InputText
v-model="searchQuery"
placeholder="搜索笔记..."
class="w-full"
size="small"
@input="onSearchInput"
v-model="searchQuery"
placeholder="搜索笔记..."
class="w-full"
size="small"
@input="onSearchInput"
/>
</IconField>
<!-- Search Results Dropdown -->
<div
v-if="searchResults.length > 0"
class="absolute top-full left-0 right-0 mt-1 bg-surface-0 border border-app-border rounded-lg shadow-lg z-50 max-h-48 overflow-auto"
v-if="searchResults.length > 0"
class="absolute top-full left-0 right-0 mt-1 bg-surface-0 border border-app-border rounded-lg shadow-lg z-50 max-h-48 overflow-auto"
>
<div
v-for="note in searchResults"
:key="note.id"
class="px-3 py-2 hover:bg-surface-50 cursor-pointer flex items-center gap-2"
@click="selectSearchNote(note)"
v-for="note in searchResults"
:key="note.id"
class="px-3 py-2 hover:bg-surface-50 cursor-pointer flex items-center gap-2"
@click="selectSearchNote(note)"
>
<File :size="14" class="text-surface-400" />
<File :size="14" class="text-surface-400"/>
<div class="flex-1 min-w-0">
<div class="text-sm text-surface-800 truncate">{{ note.title }}</div>
<div class="text-xs text-surface-400">{{ getNotebookName(note) }}</div>
@@ -146,13 +259,14 @@ function getNotebookName(note) {
</div>
</div>
</div>
<Button icon="pi pi-plus" size="small" severity="secondary" aria-label="新建笔记" @click="createNote" />
<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">
<Button severity="primary" size="small" @click="createNote" v-tooltip.right="'新建笔记'"
class="btn-filled w-8 h-8">
<template #icon>
<Plus :size="20" />
<Plus :size="20"/>
</template>
</Button>
</div>
@@ -165,42 +279,42 @@ function getNotebookName(note) {
<template v-if="collapsed">
<div class="py-3 flex flex-col items-center gap-1">
<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="activeNav === item.id ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'"
@click="handleNav(item.id)"
v-tooltip.right="item.label"
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="activeNav === item.id ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'"
@click="handleNav(item.id)"
v-tooltip.right="item.label"
>
<component :is="item.icon" :size="18" />
<component :is="item.icon" :size="18"/>
</div>
<div class="w-8 h-px bg-surface-200 my-2" />
<div class="w-8 h-px bg-surface-200 my-2"/>
<!-- Notebooks (collapsed) -->
<div
v-for="nb in notebooks"
: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"
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" />
<BookOpen :size="18"/>
</div>
<div class="w-8 h-px bg-surface-200 my-2" />
<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"
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" />
<Hash :size="18"/>
</div>
</div>
</template>
@@ -210,33 +324,33 @@ function getNotebookName(note) {
<!-- Notes Section -->
<div class="py-3">
<div
class="px-4 py-1.5 flex items-center justify-between cursor-pointer hover:bg-surface-100 transition-colors"
@click="toggleSection('notes')"
class="px-4 py-1.5 flex items-center justify-between cursor-pointer hover:bg-surface-100 transition-colors"
@click="toggleSection('notes')"
>
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">笔记</span>
<ChevronDown
v-if="expandedSections.notes"
:size="14"
class="text-surface-400 transition-transform"
v-if="expandedSections.notes"
:size="14"
class="text-surface-400 transition-transform"
/>
<ChevronRight
v-else
:size="14"
class="text-surface-400 transition-transform"
v-else
:size="14"
class="text-surface-400 transition-transform"
/>
</div>
<Transition name="collapse">
<div v-show="expandedSections.notes">
<div
v-for="item in navSections[0].items"
:key="item.id"
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg"
:class="activeNav === item.id
v-for="item in navSections[0].items"
:key="item.id"
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg"
: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)"
@click="handleNav(item.id)"
>
<component :is="item.icon" :size="18" />
<component :is="item.icon" :size="18"/>
<span class="text-sm">{{ item.label }}</span>
</div>
</div>
@@ -245,36 +359,64 @@ function getNotebookName(note) {
<!-- Notebooks 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('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 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">
<div
v-for="nb in notebooks"
:key="nb.id"
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg group"
:class="activeNav === `notebook-${nb.id}`
? 'bg-emerald-50 text-emerald-700 font-medium shadow-sm'
: 'text-surface-600 hover:bg-surface-100'"
@click="selectNotebook(nb)"
<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' }
}"
>
<BookOpen :size="18" />
<span class="text-sm flex-1">{{ nb.name }}</span>
</div>
<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>
@@ -282,35 +424,37 @@ function getNotebookName(note) {
<!-- 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')"
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"
v-if="expandedSections.tags"
:size="14"
class="text-surface-400 transition-transform"
/>
<ChevronRight
v-else
:size="14"
class="text-surface-400 transition-transform"
v-else
:size="14"
class="text-surface-400 transition-transform"
/>
</div>
<Transition name="collapse">
<div v-show="expandedSections.tags">
<div
v-for="tag in tags"
:key="tag.id"
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg group"
:class="activeNav === `tag-${tag.id}`
v-for="tag in tags"
:key="tag.id"
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg group"
:class="activeNav === `tag-${tag.id}`
? 'bg-amber-50 text-amber-700 font-medium shadow-sm'
: 'text-surface-600 hover:bg-surface-100'"
@click="selectTag(tag)"
@click="selectTag(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-xs text-surface-400 opacity-0 group-hover:opacity-100 transition-opacity">{{ tag.note_count || 0 }}</span>
<span class="text-xs text-surface-400 opacity-0 group-hover:opacity-100 transition-opacity">{{
tag.note_count || 0
}}</span>
</div>
</div>
</Transition>
@@ -322,16 +466,19 @@ function getNotebookName(note) {
<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">
<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" />
<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">
<Button text severity="secondary" @click="handleNav('settings')" v-tooltip.right="'设置'"
class="btn-icon w-10 h-10">
<template #icon>
<Settings :size="20" />
<Settings :size="20"/>
</template>
</Button>
</div>
@@ -340,21 +487,53 @@ function getNotebookName(note) {
<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>
<Sun v-if="theme === 'light'" :size="18" />
<Moon v-else-if="theme === 'dark'" :size="18" />
<Monitor v-else :size="18" />
<Sun v-if="theme === 'light'" :size="18"/>
<Moon v-else-if="theme === 'dark'" :size="18"/>
<Monitor v-else :size="18"/>
</template>
<span class="text-sm">{{ theme === 'light' ? '浅色' : theme === 'dark' ? '深色' : '系统' }}</span>
</Button>
<Button text severity="secondary" @click="handleNav('settings')" class="btn-icon h-10 px-3 flex items-center gap-2 flex-1">
<Button text severity="secondary" @click="handleNav('settings')"
class="btn-icon h-10 px-3 flex items-center gap-2 flex-1">
<template #icon>
<Settings :size="18" />
<Settings :size="18"/>
</template>
<span class="text-sm">设置</span>
</Button>
</div>
</template>
</div>
<!-- New Notebook Dialog -->
<Dialog
v-model:visible="showNewNotebookDialog"
:header="newNotebookParentId ? '新建子笔记本' : '新建笔记本'"
:modal="true"
:closable="true"
:draggable="false"
class="w-80"
@hide="cancelNewNotebook"
>
<div class="flex flex-col gap-3">
<div v-if="newNotebookParentId" class="text-sm text-surface-400">
父笔记本<span class="text-surface-600">{{ newNotebookParentName }}</span>
</div>
<label for="new-notebook-name" class="text-sm text-surface-600">笔记本名称</label>
<InputText
id="new-notebook-name"
ref="newNotebookInputRef"
v-model="newNotebookName"
placeholder="请输入笔记本名称"
class="w-full"
autofocus
@keyup.enter="confirmNewNotebook"
/>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="cancelNewNotebook"/>
<Button label="创建" @click="confirmNewNotebook" :disabled="!newNotebookName.trim()"/>
</template>
</Dialog>
</div>
</template>
@@ -376,4 +555,45 @@ function getNotebookName(note) {
opacity: 1;
max-height: 500px;
}
/* Custom styles for notebook tree */
.notebook-tree :deep(.p-tree) {
padding: 0;
}
.notebook-tree :deep(.p-tree-node) {
padding: 0;
}
.notebook-tree :deep(.p-tree-node-content) {
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
min-width: 0;
}
.notebook-tree :deep(.p-tree-node-content:hover) {
background-color: var(--surface-100);
}
.notebook-tree :deep(.p-tree-node-selected) {
/* Handled by pt nodeContent dynamic class */
}
.notebook-tree :deep(.p-tree-toggler) {
width: 1.5rem;
height: 1.5rem;
margin-right: 0;
}
.notebook-tree :deep(.p-tree-node-icon) {
margin-right: 0.5rem;
}
.notebook-tree :deep(.p-tree-node-label) {
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup>
import {ref} from 'vue'
import Button from 'primevue/button'
import {FolderOpen, FolderInput, ArrowRight, CheckCircle, BookOpen, AlertTriangle} from '@lucide/vue'
const props = defineProps({
loading: {type: Boolean, default: false}
})
const emit = defineEmits(['confirm'])
const selectedPath = ref(null)
const folderHasWorkspace = ref(false)
async function onSelectFolder() {
const result = await window.electronAPI.workspace.selectFolder()
if (result && !result.canceled && result.path) {
selectedPath.value = result.path
// Check if folder already has a Tunji workspace
try {
const check = await window.electronAPI.workspace.checkFolder(result.path)
folderHasWorkspace.value = check?.hasWorkspace || false
} catch {
folderHasWorkspace.value = false
}
}
}
function onConfirm() {
if (selectedPath.value && !folderHasWorkspace.value) {
emit('confirm', selectedPath.value)
}
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-app">
<div class="flex flex-col items-center max-w-lg px-8">
<!-- Logo -->
<div class="mb-8 flex items-center gap-3">
<div class="w-14 h-14 rounded-2xl bg-primary-500 flex items-center justify-center shadow-lg">
<BookOpen :size="28" class="text-white"/>
</div>
<h1 class="text-4xl font-bold text-app-text tracking-tight">Tunji</h1>
</div>
<!-- Welcome text -->
<h2 class="text-xl font-semibold text-app-text mb-2">欢迎使用 Tunji</h2>
<p class="text-sm text-app-text-secondary text-center mb-8 leading-relaxed">
请选择一个文件夹作为您的笔记存储库<br/>
您的笔记将以 Markdown 文件的形式保存在该文件夹中
</p>
<!-- Folder selection area -->
<div class="w-full">
<!-- No folder selected yet single button -->
<div v-if="!selectedPath" class="flex it0ems-center justify-center">
<Button icon="pi pi-folder" label="选择存储文件夹" @click="onSelectFolder" :loading="loading"></Button>
</div>
<!-- Folder selected path card + action buttons in one row -->
<div v-else class="flex flex-col gap-4">
<div
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-50 border border-primary-200 dark:bg-primary-500/10 dark:border-primary-500/20">
<CheckCircle :size="18" class="text-primary-500 flex-shrink-0"/>
<div class="flex-1 min-w-0">
<div class="text-xs text-primary-600 dark:text-primary-400 mb-0.5">已选择文件夹</div>
<div class="text-sm text-app-text font-medium truncate">{{ selectedPath }}</div>
</div>
</div>
<!-- Warning: folder already has a workspace -->
<div v-if="folderHasWorkspace"
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-yellow-50 border border-yellow-200 dark:bg-yellow-500/10 dark:border-yellow-500/20">
<AlertTriangle :size="18" class="text-yellow-600 dark:text-yellow-400 flex-shrink-0"/>
<div class="flex-1 min-w-0">
<div class="text-xs text-yellow-700 dark:text-yellow-300 mb-0.5">该文件夹已存在工作区</div>
<div class="text-sm text-yellow-800 dark:text-yellow-200">请选择一个空文件夹来创建新的笔记库</div>
</div>
</div>
<div class="flex items-center justify-center gap-3">
<Button
severity="secondary"
size="large"
outlined
icon="pi pi-folder"
class="px-6 py-3"
label="重新选择"
@click="onSelectFolder"
>
</Button>
<Button
severity="primary"
size="large"
icon="pi pi-arrow-right"
class="btn-filled px-8 py-3 text-base"
:loading="loading"
:disabled="folderHasWorkspace"
@click="onConfirm"
icon-pos="right"
label="开始使用"
>
</Button>
</div>
</div>
</div>
<!-- Hint -->
<p class="mt-8 text-xs text-app-text-muted">
后续可在设置中更改存储位置
</p>
</div>
</div>
</template>

View File

@@ -1,11 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import Tooltip from 'primevue/tooltip'
import App from './App.vue'
import './assets/styles/main.css'
import 'primeicons/primeicons.css'
const app = createApp(App)
const pinia = createPinia()
app.use(PrimeVue, {
theme: {
@@ -16,6 +19,7 @@ app.use(PrimeVue, {
}
})
app.use(pinia)
app.directive('tooltip', Tooltip)
app.mount('#app')