feat: 功能迭代
This commit is contained in:
@@ -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
136
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
115
src/renderer/src/components/WorkspaceSetup.vue
Normal file
115
src/renderer/src/components/WorkspaceSetup.vue
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user