feat: 功能迭代

This commit is contained in:
2026-05-31 14:05:29 +08:00
parent dedd837b18
commit d13a43a143
11 changed files with 845 additions and 207 deletions

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

181
package-lock.json generated
View File

@@ -13,13 +13,18 @@
"@lucide/vue": "^1.17.0", "@lucide/vue": "^1.17.0",
"@primeuix/themes": "^2.0.3", "@primeuix/themes": "^2.0.3",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"chokidar": "^5.0.0",
"electron": "^42.3.0", "electron": "^42.3.0",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"gray-matter": "^4.0.3",
"nanoid": "^5.1.11",
"primeicons": "^7.0.0",
"primevue": "^4.5.5", "primevue": "^4.5.5",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"sql.js": "^1.14.1",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"vue": "^3.5.35" "vue": "^3.5.35"
@@ -2828,6 +2833,21 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -4162,6 +4182,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -4181,6 +4214,18 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extract-zip": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -4577,6 +4622,49 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/gray-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/gray-matter/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -4843,6 +4931,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -5003,6 +5100,15 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/lazy-val": { "node_modules/lazy-val": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
@@ -6241,9 +6347,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.12", "version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -6252,10 +6358,10 @@
], ],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"nanoid": "bin/nanoid.cjs" "nanoid": "bin/nanoid.js"
}, },
"engines": { "engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^18 || >=20"
} }
}, },
"node_modules/node-abi": { "node_modules/node-abi": {
@@ -6553,6 +6659,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/postject": { "node_modules/postject": {
"version": "1.0.0-alpha.6", "version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
@@ -6583,6 +6707,12 @@
"node": "^12.20.0 || >=14" "node": "^12.20.0 || >=14"
} }
}, },
"node_modules/primeicons": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
"license": "MIT"
},
"node_modules/primevue": { "node_modules/primevue": {
"version": "4.5.5", "version": "4.5.5",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.5.5.tgz", "resolved": "https://registry.npmjs.org/primevue/-/primevue-4.5.5.tgz",
@@ -6700,6 +6830,19 @@
"read-binary-file-arch": "cli.js" "read-binary-file-arch": "cli.js"
} }
}, },
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rehype-stringify": { "node_modules/rehype-stringify": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
@@ -6944,6 +7087,19 @@
"node": ">=11.0.0" "node": ">=11.0.0"
} }
}, },
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
@@ -7100,6 +7256,12 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true "optional": true
}, },
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/stat-mode": { "node_modules/stat-mode": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
@@ -7152,6 +7314,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sumchecker": { "node_modules/sumchecker": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",

View File

@@ -21,13 +21,18 @@
"@lucide/vue": "^1.17.0", "@lucide/vue": "^1.17.0",
"@primeuix/themes": "^2.0.3", "@primeuix/themes": "^2.0.3",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"chokidar": "^5.0.0",
"electron": "^42.3.0", "electron": "^42.3.0",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"gray-matter": "^4.0.3",
"nanoid": "^5.1.11",
"primeicons": "^7.0.0",
"primevue": "^4.5.5", "primevue": "^4.5.5",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"sql.js": "^1.14.1",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"vue": "^3.5.35" "vue": "^3.5.35"

View File

@@ -1,8 +1,10 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import { join } from 'path' import { join } from 'path'
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { StorageService } from './services/StorageService'
let mainWindow = null let mainWindow = null
let storageService = null
// 标题栏颜色配置 // 标题栏颜色配置
const titleBarColors = { const titleBarColors = {
@@ -59,9 +61,189 @@ ipcMain.handle('theme:set', (_, theme) => {
}) })
}) })
app.whenReady().then(createWindow) // ==================== Register IPC Handlers ====================
function registerIpcHandlers() {
// --- Notes ---
ipcMain.handle('notes:create', async (_, data) => {
try {
return await storageService.createNote(data)
} catch (err) {
console.error('Failed to create note:', err)
throw err
}
})
ipcMain.handle('notes:get', async (_, id) => {
try {
return await storageService.getNote(id)
} catch (err) {
console.error('Failed to get note:', err)
throw err
}
})
ipcMain.handle('notes:update', async (_, id, data) => {
try {
return await storageService.updateNote(id, data)
} catch (err) {
console.error('Failed to update note:', err)
throw err
}
})
ipcMain.handle('notes:delete', async (_, id, permanent) => {
try {
return await storageService.deleteNote(id, permanent)
} catch (err) {
console.error('Failed to delete note:', err)
throw err
}
})
ipcMain.handle('notes:restore', async (_, id) => {
try {
return await storageService.restoreNote(id)
} catch (err) {
console.error('Failed to restore note:', err)
throw err
}
})
ipcMain.handle('notes:list', async (_, filter) => {
try {
return await storageService.listNotes(filter)
} catch (err) {
console.error('Failed to list notes:', err)
throw err
}
})
ipcMain.handle('notes:search', async (_, query) => {
try {
return await storageService.searchNotes(query)
} catch (err) {
console.error('Failed to search notes:', err)
throw err
}
})
// --- Notebooks ---
ipcMain.handle('notebooks:create', async (_, data) => {
try {
return await storageService.createNotebook(data)
} catch (err) {
console.error('Failed to create notebook:', err)
throw err
}
})
ipcMain.handle('notebooks:list', async () => {
try {
return await storageService.listNotebooks()
} catch (err) {
console.error('Failed to list notebooks:', err)
throw err
}
})
ipcMain.handle('notebooks:rename', async (_, id, name) => {
try {
return await storageService.renameNotebook(id, name)
} catch (err) {
console.error('Failed to rename notebook:', err)
throw err
}
})
ipcMain.handle('notebooks:delete', async (_, id) => {
try {
return await storageService.deleteNotebook(id)
} catch (err) {
console.error('Failed to delete notebook:', err)
throw err
}
})
// --- Tags ---
ipcMain.handle('tags:create', async (_, name) => {
try {
return await storageService.createTag(name)
} catch (err) {
console.error('Failed to create tag:', err)
throw err
}
})
ipcMain.handle('tags:list', async () => {
try {
return await storageService.listTags()
} catch (err) {
console.error('Failed to list tags:', err)
throw err
}
})
ipcMain.handle('tags:delete', async (_, id) => {
try {
return await storageService.deleteTag(id)
} catch (err) {
console.error('Failed to delete tag:', err)
throw err
}
})
ipcMain.handle('tags:addToNote', async (_, noteId, tagName) => {
try {
return await storageService.addTagToNote(noteId, tagName)
} catch (err) {
console.error('Failed to add tag to note:', err)
throw err
}
})
ipcMain.handle('tags:removeFromNote', async (_, noteId, tagName) => {
try {
return await storageService.removeTagFromNote(noteId, tagName)
} catch (err) {
console.error('Failed to remove tag from note:', err)
throw err
}
})
// --- Stats ---
ipcMain.handle('stats:get', async () => {
try {
return await storageService.getStats()
} catch (err) {
console.error('Failed to get stats:', err)
throw err
}
})
}
// ==================== 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
registerIpcHandlers()
// Create window
createWindow()
})
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
// Save all pending changes before quitting
if (storageService) {
storageService.flush()
storageService.close()
}
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() app.quit()
} }

View File

@@ -3,5 +3,38 @@ import { contextBridge, ipcRenderer } from 'electron'
// 安全地暴露 API 给渲染进程 // 安全地暴露 API 给渲染进程
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform, platform: process.platform,
setTheme: (theme) => ipcRenderer.invoke('theme:set', theme)
// Theme
setTheme: (theme) => ipcRenderer.invoke('theme:set', theme),
// Notes
notes: {
create: (data) => ipcRenderer.invoke('notes:create', data),
get: (id) => ipcRenderer.invoke('notes:get', id),
update: (id, data) => ipcRenderer.invoke('notes:update', id, data),
delete: (id, permanent) => ipcRenderer.invoke('notes:delete', id, permanent),
restore: (id) => ipcRenderer.invoke('notes:restore', id),
list: (filter) => ipcRenderer.invoke('notes:list', filter),
search: (query) => ipcRenderer.invoke('notes:search', query),
},
// Notebooks
notebooks: {
create: (data) => ipcRenderer.invoke('notebooks:create', data),
list: () => ipcRenderer.invoke('notebooks:list'),
rename: (id, name) => ipcRenderer.invoke('notebooks:rename', id, name),
delete: (id) => ipcRenderer.invoke('notebooks:delete', id),
},
// Tags
tags: {
create: (name) => ipcRenderer.invoke('tags:create', name),
list: () => ipcRenderer.invoke('tags:list'),
delete: (id) => ipcRenderer.invoke('tags:delete', id),
addToNote: (noteId, tagName) => ipcRenderer.invoke('tags:addToNote', noteId, tagName),
removeFromNote: (noteId, tagName) => ipcRenderer.invoke('tags:removeFromNote', noteId, tagName),
},
// Stats
getStats: () => ipcRenderer.invoke('stats:get'),
}) })

View File

@@ -1,80 +1,127 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import SideBar from './components/SideBar.vue' import SideBar from './components/SideBar.vue'
import NotesOverview from './components/NotesOverview.vue' import NotesOverview from './components/NotesOverview.vue'
import EditorPanel from './components/EditorPanel.vue' import EditorPanel from './components/EditorPanel.vue'
import { useTheme } from './composables/useTheme' import { useTheme } from './composables/useTheme'
import { useNotes } from './composables/useNotes'
import { useNotebooks } from './composables/useNotebooks'
import { useTags } from './composables/useTags'
// 初始化主题系统 // 初始化主题系统
const { theme, resolvedTheme, isDark, toggleTheme } = useTheme() const { theme, resolvedTheme, isDark, toggleTheme } = useTheme()
// 数据层
const {
notes: allNotes,
currentNote,
loading: notesLoading,
loadNotes,
createNote,
getNote,
updateNote,
deleteNote,
searchNotes,
setCurrentNote,
clearCurrentNote
} = useNotes()
const {
notebooks,
loadNotebooks,
createNotebook
} = useNotebooks()
const {
tags,
loadTags
} = useTags()
const currentNav = ref('all') const currentNav = ref('all')
const currentNote = ref(null)
const filterType = ref('all') const filterType = ref('all')
const filterValue = ref(null) const filterValue = ref(null)
// Mock data shared between overview and sidebar // 过滤后的笔记列表
const allNotes = ref([
{ id: 1, title: '项目需求文档', preview: '本文档描述了Tunji编辑器的核心功能需求和设计方案...', date: '2026-05-31', notebook: '工作', tags: ['重要', '参考'] },
{ id: 2, title: '读书笔记 - 深入理解计算机系统', preview: '第一章:计算机系统是由硬件和系统软件组成的...', date: '2026-05-30', notebook: '学习', tags: ['参考'] },
{ id: 3, title: '周报 - 第22周', preview: '本周完成了编辑器基础布局搭建,实现了三栏式布局...', date: '2026-05-29', notebook: '工作', tags: ['待办'] },
{ id: 4, title: 'Vue 3 学习笔记', preview: 'Composition API 提供了更灵活的代码组织方式setup函数...', date: '2026-05-28', notebook: '学习', tags: ['参考'] },
{ id: 5, title: '旅行计划 - 日本', preview: '东京 -> 京都 -> 大阪预计行程7天预算约15000元...', date: '2026-05-27', notebook: '生活', tags: ['灵感'] },
{ id: 6, title: 'Markdown 语法速查表', preview: '标题用#号,加粗用**,斜体用*,代码用反引号...', date: '2026-05-26', notebook: '技术', tags: ['参考'] },
{ id: 7, title: '会议纪要 - 产品评审', preview: '参会人员产品、设计、开发。讨论了v1.0的优先级...', date: '2026-05-25', notebook: '工作', tags: ['会议', '重要'] },
{ id: 8, title: '健身计划', preview: '周一:胸+三头,周二:背+二头,周三:休息,周四:腿...', date: '2026-05-24', notebook: '生活', tags: ['待办'] },
])
const overviewNotes = ref([]) const overviewNotes = ref([])
function computeOverviewNotes() { function computeOverviewNotes() {
let result = allNotes.value let result = allNotes.value
if (filterType.value === 'notebook' && filterValue.value) { if (filterType.value === 'notebook' && filterValue.value) {
result = result.filter(n => n.notebook === filterValue.value.name) result = result.filter(n => n.notebook_id === filterValue.value.id)
} else if (filterType.value === 'tag' && filterValue.value) { } else if (filterType.value === 'tag' && filterValue.value) {
result = result.filter(n => n.tags.includes(filterValue.value.name)) result = result.filter(n => {
const noteTags = n.tags || []
return noteTags.some(t => t.name === filterValue.value.name)
})
} else if (filterType.value === 'search' && filterValue.value) {
result = result.filter(n =>
n.title.toLowerCase().includes(filterValue.value.toLowerCase())
)
} }
overviewNotes.value = result overviewNotes.value = result
} }
function onNavigate(id) { async function onNavigate(id) {
currentNav.value = id currentNav.value = id
filterType.value = 'all' filterType.value = 'all'
filterValue.value = null filterValue.value = null
currentNote.value = null clearCurrentNote()
if (id === 'trash') {
await loadNotes({ isTrashed: 1 })
} else {
await loadNotes({ isTrashed: 0 })
}
computeOverviewNotes() computeOverviewNotes()
} }
function onSelectNote(note) { function onSelectNote(note) {
currentNote.value = note getNote(note.id)
} }
function onBackToOverview() { function onBackToOverview() {
currentNote.value = null clearCurrentNote()
// Reload notes to reflect any changes
loadNotes({ isTrashed: filterType.value === 'trash' ? 1 : 0 }).then(() => {
computeOverviewNotes()
})
} }
function onCreateNote() { async function onCreateNote() {
// TODO: Implement note creation const notebookId = filterType.value === 'notebook' && filterValue.value
console.log('Creating new note...') ? filterValue.value.id
: 'default'
const note = await createNote({
title: '无标题',
content: '',
notebookId
})
if (note) {
// Auto select the new note
getNote(note.id)
}
} }
function onSelectNotebook(notebook) { function onSelectNotebook(notebook) {
filterType.value = 'notebook' filterType.value = 'notebook'
filterValue.value = notebook filterValue.value = notebook
currentNote.value = null clearCurrentNote()
computeOverviewNotes() loadNotes({ notebookId: notebook.id, isTrashed: 0 }).then(() => {
computeOverviewNotes()
})
} }
function onSelectTag(tag) { function onSelectTag(tag) {
filterType.value = 'tag' filterType.value = 'tag'
filterValue.value = tag filterValue.value = tag
currentNote.value = null clearCurrentNote()
computeOverviewNotes() loadNotes({ tagId: tag.id, isTrashed: 0 }).then(() => {
computeOverviewNotes()
})
} }
// Initialize
computeOverviewNotes()
function getOverviewTitle() { function getOverviewTitle() {
if (filterType.value === 'notebook' && filterValue.value) { if (filterType.value === 'notebook' && filterValue.value) {
return filterValue.value.name return filterValue.value.name
@@ -82,6 +129,9 @@ function getOverviewTitle() {
if (filterType.value === 'tag' && filterValue.value) { if (filterType.value === 'tag' && filterValue.value) {
return `标签: ${filterValue.value.name}` return `标签: ${filterValue.value.name}`
} }
if (filterType.value === 'search' && filterValue.value) {
return `搜索: ${filterValue.value}`
}
const titleMap = { const titleMap = {
all: '所有笔记', all: '所有笔记',
recent: '最近编辑', recent: '最近编辑',
@@ -90,6 +140,16 @@ function getOverviewTitle() {
} }
return titleMap[currentNav.value] || '所有笔记' return titleMap[currentNav.value] || '所有笔记'
} }
// Initialize: load data on mount
onMounted(async () => {
await Promise.all([
loadNotes({ isTrashed: 0 }),
loadNotebooks(),
loadTags()
])
computeOverviewNotes()
})
</script> </script>
<template> <template>
@@ -98,6 +158,8 @@ function getOverviewTitle() {
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">
<!-- Sidebar --> <!-- Sidebar -->
<SideBar <SideBar
:notebooks="notebooks"
:tags="tags"
@navigate="onNavigate" @navigate="onNavigate"
@create-note="onCreateNote" @create-note="onCreateNote"
@select-notebook="onSelectNotebook" @select-notebook="onSelectNotebook"
@@ -120,9 +182,9 @@ function getOverviewTitle() {
v-else v-else
:note="currentNote" :note="currentNote"
@back="onBackToOverview" @back="onBackToOverview"
@save="({ title, content }) => updateNote(currentNote.id, { title, content })"
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -140,42 +140,6 @@
--color-app-ring: var(--theme-ring); --color-app-ring: var(--theme-ring);
} }
/* ----- 统一按钮样式 ----- */
/* PrimeVue Button 统一 12px 圆角 */
.p-button {
border-radius: 12px !important;
}
/* 按钮统一交互过渡 */
.btn-icon {
transition: background-color 0.15s ease, color 0.15s ease;
}
/* 激活态按钮 */
.btn-active {
background: var(--theme-bg-elevated) !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
color: var(--theme-primary-600) !important;
}
/* 实心主色按钮 */
.btn-filled:not(.p-button-text) {
background: #00bf66 !important;
border-color: #00bf66 !important;
color: #ffffff !important;
}
.btn-filled:not(.p-button-text):hover {
background: #00a555 !important;
border-color: #00a555 !important;
}
.btn-filled:not(.p-button-text):active {
background: #00914b !important;
border-color: #00914b !important;
}
/* ----- 全局基础样式 ----- */ /* ----- 全局基础样式 ----- */
/* 主题切换平滑过渡 */ /* 主题切换平滑过渡 */
@@ -207,3 +171,8 @@ html {
background-color: rgba(129, 140, 248, 0.3); background-color: rgba(129, 140, 248, 0.3);
color: #e2e8f0; color: #e2e8f0;
} }
/* Tooltip 样式 */
.p-tooltip-text {
font-size: 0.75rem !important;
}

View File

@@ -20,10 +20,12 @@ const props = defineProps({
note: { type: Object, default: null } note: { type: Object, default: null }
}) })
const emit = defineEmits(['back']) const emit = defineEmits(['back', 'save'])
const noteTitle = ref('') const noteTitle = ref('')
const noteContent = ref('') const noteContent = ref('')
const isSaving = ref(false)
const lastSavedAt = ref(null)
// View mode: edit | preview | split // View mode: edit | preview | split
const viewMode = ref('edit') const viewMode = ref('edit')
@@ -42,14 +44,49 @@ const headings = ref([])
// Rendered HTML for preview // Rendered HTML for preview
const renderedHtml = ref('') const renderedHtml = ref('')
// Auto-save timer
let saveTimer = null
const SAVE_DELAY = 1000 // 1 second debounce
// Update local state when note prop changes // Update local state when note prop changes
watch(() => props.note, (newNote) => { watch(() => props.note, (newNote) => {
if (newNote) { if (newNote) {
noteTitle.value = newNote.title || '' noteTitle.value = newNote.title || ''
noteContent.value = newNote.content || getDefaultContent(newNote.title) noteContent.value = newNote.content || ''
} }
}, { immediate: true }) }, { immediate: true })
// Auto-save when content changes
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(async () => {
await doSave()
}, SAVE_DELAY)
}
async function doSave() {
if (!props.note) return
isSaving.value = true
try {
emit('save', {
title: noteTitle.value,
content: noteContent.value
})
lastSavedAt.value = new Date()
} finally {
isSaving.value = false
}
}
// Watch for content changes and trigger auto-save
watch(noteContent, () => {
scheduleSave()
})
watch(noteTitle, () => {
scheduleSave()
})
// Parse headings from markdown content // Parse headings from markdown content
watch(noteContent, (content) => { watch(noteContent, (content) => {
const result = [] const result = []
@@ -83,14 +120,6 @@ watch(noteContent, async (content) => {
} }
}, { immediate: true }) }, { immediate: true })
function getDefaultContent(title) {
return `# ${title}
在这里开始写作...
`
}
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '' if (!dateStr) return ''
const d = new Date(dateStr) const d = new Date(dateStr)
@@ -98,6 +127,9 @@ function formatDate(dateStr) {
} }
function goBack() { function goBack() {
// Save before going back
if (saveTimer) clearTimeout(saveTimer)
doSave()
emit('back') emit('back')
} }
@@ -125,6 +157,17 @@ const toolbarItems3 = [
{ icon: Link, title: '链接' }, { icon: Link, title: '链接' },
{ icon: Image, title: '图片' }, { icon: Image, title: '图片' },
] ]
// Tag names for display
const tagNames = computed(() => {
if (!props.note?.tags) return []
return props.note.tags.map(t => typeof t === 'string' ? t : t.name)
})
// Notebook name
const notebookName = computed(() => {
return props.note?.notebook_name || props.note?.notebook || ''
})
</script> </script>
<template> <template>
@@ -143,9 +186,11 @@ const toolbarItems3 = [
placeholder="输入标题..." placeholder="输入标题..."
/> />
<div class="flex items-center gap-3 mt-1 text-xs text-surface-400"> <div class="flex items-center gap-3 mt-1 text-xs text-surface-400">
<span v-if="note?.date">最后编辑: {{ formatDate(note.date) }}</span> <span v-if="note?.updated_at">最后编辑: {{ formatDate(note.updated_at) }}</span>
<span v-if="note?.date && note?.notebook">|</span> <span v-if="note?.updated_at && notebookName">|</span>
<span v-if="note?.notebook">{{ note.notebook }}</span> <span v-if="notebookName">{{ notebookName }}</span>
<span v-if="isSaving" class="text-primary-500">保存中...</span>
<span v-else-if="lastSavedAt" class="text-surface-300">已保存</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import Button from 'primevue/button' import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import ScrollPanel from 'primevue/scrollpanel' import ScrollPanel from 'primevue/scrollpanel'
import { Search, LayoutGrid, List, FileText, Clock, Calendar, Folder } from '@lucide/vue' import { Search, LayoutGrid, List, FileText, Clock, Calendar, Folder } from '@lucide/vue'
@@ -15,29 +12,21 @@ const props = defineProps({
const emit = defineEmits(['select-note']) const emit = defineEmits(['select-note'])
const searchQuery = ref('')
const viewMode = ref('card') // 'card' | 'list' const viewMode = ref('card') // 'card' | 'list'
const filteredNotes = computed(() => {
if (!searchQuery.value) return props.notes
const q = searchQuery.value.toLowerCase()
return props.notes.filter(
n => n.title.toLowerCase().includes(q) || n.preview.toLowerCase().includes(q)
)
})
function selectNote(note) { function selectNote(note) {
emit('select-note', note) emit('select-note', note)
} }
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr) const d = new Date(dateStr)
const month = d.getMonth() + 1 const month = d.getMonth() + 1
const day = d.getDate() const day = d.getDate()
return `${month}${day}` return `${month}${day}`
} }
function getTagSeverity(tag) { function getTagSeverity(tagName) {
const severityMap = { const severityMap = {
'重要': 'danger', '重要': 'danger',
'待办': 'warning', '待办': 'warning',
@@ -45,7 +34,38 @@ function getTagSeverity(tag) {
'灵感': 'success', '灵感': 'success',
'会议': 'secondary' '会议': 'secondary'
} }
return severityMap[tag] || 'secondary' return severityMap[tagName] || 'secondary'
}
// Get tag names from note's tags array
function getTagNames(note) {
if (!note.tags) return []
return note.tags.map(t => typeof t === 'string' ? t : t.name)
}
// Get notebook display name
function getNotebookName(note) {
return note.notebook_name || note.notebook || ''
}
// Get preview text from note (truncated content or word count)
function getPreview(note) {
if (note.content) {
// Strip markdown syntax for preview
const text = note.content
.replace(/#{1,6}\s+/g, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.replace(/\n+/g, ' ')
.trim()
return text.substring(0, 150) + (text.length > 150 ? '...' : '')
}
if (note.word_count) {
return `${note.word_count}`
}
return ''
} }
</script> </script>
@@ -56,34 +76,18 @@ function getTagSeverity(tag) {
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold text-surface-900">{{ title }}</h1> <h1 class="text-xl font-bold text-surface-900">{{ title }}</h1>
<div class="flex items-center gap-1 bg-surface-100 rounded-lg p-0.5"> <div class="flex items-center gap-1 bg-surface-100 rounded-lg p-0.5">
<Button text :severity="viewMode === 'card' ? 'primary' : 'secondary'" size="small" @click="viewMode = 'card'" v-tooltip.top="'卡片视图'" :class="['btn-icon w-8 h-8', viewMode === 'card' && 'btn-active']"> <Button :severity="viewMode === 'card' ? 'primary' : 'secondary'" size="small" @click="viewMode = 'card'" v-tooltip.top="'卡片视图'">
<template #icon> <template #icon>
<LayoutGrid :size="16" /> <LayoutGrid :size="18" />
</template> </template>
</Button> </Button>
<Button text :severity="viewMode === 'list' ? 'primary' : 'secondary'" size="small" @click="viewMode = 'list'" v-tooltip.top="'列表视图'" :class="['btn-icon w-8 h-8', viewMode === 'list' && 'btn-active']"> <Button :severity="viewMode === 'list' ? 'primary' : 'secondary'" size="small" @click="viewMode = 'list'" v-tooltip.top="'列表视图'">
<template #icon> <template #icon>
<List :size="16" /> <List :size="18" />
</template> </template>
</Button> </Button>
</div> </div>
</div> </div>
<!-- Search -->
<div class="flex items-center gap-3">
<IconField class="flex-1">
<InputIcon>
<Search :size="16" />
</InputIcon>
<InputText
v-model="searchQuery"
placeholder="搜索笔记..."
class="w-full"
size="small"
/>
</IconField>
<span class="text-sm text-surface-400 shrink-0">{{ filteredNotes.length }} 篇笔记</span>
</div>
</div> </div>
<!-- Content --> <!-- Content -->
@@ -92,31 +96,31 @@ function getTagSeverity(tag) {
<!-- Card View --> <!-- Card View -->
<div v-if="viewMode === 'card'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pt-1"> <div v-if="viewMode === 'card'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pt-1">
<div <div
v-for="note in filteredNotes" v-for="note in notes"
:key="note.id" :key="note.id"
class="group bg-surface-0 rounded-xl border border-surface-200 p-4 cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary-200 hover:-translate-y-0.5" class="group bg-surface-0 rounded-xl border border-app-border p-4 cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary-300 hover:-translate-y-0.5"
@click="selectNote(note)" @click="selectNote(note)"
> >
<!-- Card Header --> <!-- Card Header -->
<div class="flex items-start gap-2 mb-3"> <div class="flex items-start gap-2 mb-3">
<FileText :size="18" class="text-primary-400 shrink-0 mt-0.5" /> <FileText :size="18" class="text-primary-400 shrink-0 mt-0.5" />
<h3 class="text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors"> <h3 class="text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors">
{{ note.title }} {{ note.title || '无标题' }}
</h3> </h3>
</div> </div>
<!-- Preview --> <!-- Preview -->
<p class="text-xs text-surface-500 line-clamp-3 mb-3 leading-relaxed"> <p class="text-xs text-surface-500 line-clamp-3 mb-3 leading-relaxed">
{{ note.preview }} {{ getPreview(note) }}
</p> </p>
<!-- Tags --> <!-- Tags -->
<div class="flex flex-wrap gap-1 mb-3"> <div class="flex flex-wrap gap-1 mb-3">
<Tag <Tag
v-for="tag in note.tags.slice(0, 3)" v-for="tagName in getTagNames(note).slice(0, 3)"
:key="tag" :key="tagName"
:value="tag" :value="tagName"
:severity="getTagSeverity(tag)" :severity="getTagSeverity(tagName)"
class="text-xs" class="text-xs"
/> />
</div> </div>
@@ -125,11 +129,11 @@ function getTagSeverity(tag) {
<div class="flex items-center justify-between pt-2 border-t border-surface-100"> <div class="flex items-center justify-between pt-2 border-t border-surface-100">
<div class="flex items-center gap-1.5 text-xs text-surface-400"> <div class="flex items-center gap-1.5 text-xs text-surface-400">
<Clock :size="12" /> <Clock :size="12" />
<span>{{ formatDate(note.date) }}</span> <span>{{ formatDate(note.updated_at) }}</span>
</div> </div>
<div class="flex items-center gap-1.5 text-xs text-surface-400"> <div class="flex items-center gap-1.5 text-xs text-surface-400">
<Folder :size="12" /> <Folder :size="12" />
<span>{{ note.notebook }}</span> <span>{{ getNotebookName(note) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -138,26 +142,26 @@ function getTagSeverity(tag) {
<!-- List View --> <!-- List View -->
<div v-else class="flex flex-col gap-1"> <div v-else class="flex flex-col gap-1">
<div <div
v-for="note in filteredNotes" v-for="note in notes"
:key="note.id" :key="note.id"
class="group flex items-center gap-4 bg-surface-0 rounded-lg border border-surface-200 px-4 py-3 cursor-pointer transition-all duration-200 hover:shadow-sm hover:border-primary-200" class="group flex items-center gap-4 bg-surface-0 rounded-lg border border-app-border px-4 py-3 cursor-pointer transition-all duration-200 hover:shadow-sm hover:border-primary-300"
@click="selectNote(note)" @click="selectNote(note)"
> >
<FileText :size="18" class="text-primary-400 shrink-0" /> <FileText :size="18" class="text-primary-400 shrink-0" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-surface-900 truncate group-hover:text-primary-600 transition-colors"> <h3 class="text-sm font-semibold text-surface-900 truncate group-hover:text-primary-600 transition-colors">
{{ note.title }} {{ note.title || '无标题' }}
</h3> </h3>
<p class="text-xs text-surface-500 truncate mt-0.5">{{ note.preview }}</p> <p class="text-xs text-surface-500 truncate mt-0.5">{{ getPreview(note) }}</p>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<Tag <Tag
v-for="tag in note.tags.slice(0, 2)" v-for="tagName in getTagNames(note).slice(0, 2)"
:key="tag" :key="tagName"
:value="tag" :value="tagName"
:severity="getTagSeverity(tag)" :severity="getTagSeverity(tagName)"
class="text-xs" class="text-xs"
/> />
</div> </div>
@@ -165,18 +169,18 @@ function getTagSeverity(tag) {
<div class="flex items-center gap-4 shrink-0 text-xs text-surface-400"> <div class="flex items-center gap-4 shrink-0 text-xs text-surface-400">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Folder :size="12" /> <Folder :size="12" />
<span>{{ note.notebook }}</span> <span>{{ getNotebookName(note) }}</span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Calendar :size="12" /> <Calendar :size="12" />
<span>{{ formatDate(note.date) }}</span> <span>{{ formatDate(note.updated_at) }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-if="filteredNotes.length === 0" class="flex flex-col items-center justify-center py-20 text-surface-400"> <div v-if="notes.length === 0" class="flex flex-col items-center justify-center py-20 text-surface-400">
<FileText :size="48" class="mb-4 opacity-30" /> <FileText :size="48" class="mb-4 opacity-30" />
<p class="text-sm">没有找到笔记</p> <p class="text-sm">没有找到笔记</p>
</div> </div>

View File

@@ -6,15 +6,23 @@ import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon' import InputIcon from 'primevue/inputicon'
import ScrollPanel from 'primevue/scrollpanel' import ScrollPanel from 'primevue/scrollpanel'
import { import {
Search, Plus, File, Star, Search, Plus, File, Star, Trash2,
Trash2, Settings, Sun, Moon, Monitor Settings, Sun, Moon, Monitor,
BookOpen, Tag, ChevronDown, ChevronRight, Hash
} from '@lucide/vue' } from '@lucide/vue'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { useNotes } from '../composables/useNotes'
import ButtonIcon from "./ButtonIcon.vue"; import ButtonIcon from "./ButtonIcon.vue";
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const { searchNotes } = useNotes()
const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag']) const props = defineProps({
notebooks: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] }
})
const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag', 'select-note'])
// Collapse state // Collapse state
const collapsed = ref(false) const collapsed = ref(false)
@@ -26,27 +34,33 @@ function toggleCollapsed() {
// Navigation state // Navigation state
const activeNav = ref('all') const activeNav = ref('all')
// Section expand state
const expandedSections = ref({
notes: true,
notebooks: true,
tags: true
})
function toggleSection(section) {
expandedSections.value[section] = !expandedSections.value[section]
}
// Search // Search
const searchQuery = ref('') const searchQuery = ref('')
const searchResults = ref([])
let searchTimer = null
// Mock data - notes for search async function onSearchInput() {
const allNotes = ref([ if (searchTimer) clearTimeout(searchTimer)
{ id: 1, title: '项目需求文档', notebook: '工作' }, const q = searchQuery.value
{ id: 2, title: '读书笔记 - 深入理解计算机系统', notebook: '学习' }, if (!q || q.length < 1) {
{ id: 3, title: '周报 - 第22周', notebook: '工作' }, searchResults.value = []
{ id: 4, title: 'Vue 3 学习笔记', notebook: '学习' }, return
{ id: 5, title: '旅行计划 - 日本', notebook: '生活' }, }
{ id: 6, title: 'Markdown 语法速查表', notebook: '技术' }, searchTimer = setTimeout(async () => {
{ id: 7, title: '会议纪要 - 产品评审', notebook: '工作' }, searchResults.value = await searchNotes(q)
{ id: 8, title: '健身计划', notebook: '生活' }, }, 200)
]) }
// Filtered notes for search dropdown
const filteredNotes = computed(() => {
if (!searchQuery.value) return []
const q = searchQuery.value.toLowerCase()
return allNotes.value.filter(n => n.title.toLowerCase().includes(q))
})
// Navigation items // Navigation items
const navSections = [ const navSections = [
@@ -55,6 +69,7 @@ const navSections = [
items: [ items: [
{ id: 'all', icon: File, label: '所有笔记' }, { id: 'all', icon: File, label: '所有笔记' },
{ id: 'favorites', icon: Star, label: '收藏夹' }, { id: 'favorites', icon: Star, label: '收藏夹' },
{ id: 'trash', icon: Trash2, label: '回收站' },
] ]
} }
] ]
@@ -65,6 +80,16 @@ function handleNav(id) {
emit('navigate', id) emit('navigate', id)
} }
function selectNotebook(notebook) {
activeNav.value = `notebook-${notebook.id}`
emit('select-notebook', notebook)
}
function selectTag(tag) {
activeNav.value = `tag-${tag.id}`
emit('select-tag', tag)
}
function createNote() { function createNote() {
emit('create-note') emit('create-note')
} }
@@ -72,6 +97,14 @@ function createNote() {
function selectSearchNote(note) { function selectSearchNote(note) {
emit('select-note', note) emit('select-note', note)
searchQuery.value = '' searchQuery.value = ''
searchResults.value = []
}
// Get notebook display name for a note
function getNotebookName(note) {
if (note.notebook_name) return note.notebook_name
const nb = props.notebooks.find(n => n.id === note.notebook_id)
return nb ? nb.name : ''
} }
</script> </script>
@@ -84,23 +117,23 @@ function selectSearchNote(note) {
<template v-if="!collapsed"> <template v-if="!collapsed">
<div class="flex-1 relative"> <div class="flex-1 relative">
<IconField> <IconField>
<InputIcon> <InputIcon class="pi pi-search" />
<Search :size="16" />
</InputIcon>
<InputText <InputText
v-model="searchQuery" v-model="searchQuery"
placeholder="搜索笔记..." placeholder="搜索笔记..."
class="w-full" class="w-full"
size="small" size="small"
@input="onSearchInput"
/> />
</IconField> </IconField>
<!-- Search Results Dropdown --> <!-- Search Results Dropdown -->
<div <div
v-if="filteredNotes.length > 0" 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" 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 <div
v-for="note in filteredNotes" v-for="note in searchResults"
:key="note.id" :key="note.id"
class="px-3 py-2 hover:bg-surface-50 cursor-pointer flex items-center gap-2" class="px-3 py-2 hover:bg-surface-50 cursor-pointer flex items-center gap-2"
@click="selectSearchNote(note)" @click="selectSearchNote(note)"
@@ -108,13 +141,12 @@ function 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="flex-1 min-w-0">
<div class="text-sm text-surface-800 truncate">{{ note.title }}</div> <div class="text-sm text-surface-800 truncate">{{ note.title }}</div>
<div class="text-xs text-surface-400">{{ note.notebook }}</div> <div class="text-xs text-surface-400">{{ getNotebookName(note) }}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<Button icon="pi pi-plus" size="small" severity="secondary" aria-label="新建笔记" @click="createNote" />
<Button icon="pi pi-search" size="small" severity="success" aria-label="Search" />
</template> </template>
<template v-else> <template v-else>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
@@ -142,77 +174,206 @@ function selectSearchNote(note) {
> >
<component :is="item.icon" :size="18" /> <component :is="item.icon" :size="18" />
</div> </div>
</div>
<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"
>
<BookOpen :size="18" />
</div>
<div class="w-8 h-px bg-surface-200 my-2" />
<!-- Tags (collapsed) -->
<div
v-for="tag in tags"
:key="tag.id"
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
:class="activeNav === `tag-${tag.id}` ? 'bg-amber-50 text-amber-600' : 'text-surface-500 hover:bg-surface-100'"
@click="selectTag(tag)"
v-tooltip.right="tag.name"
>
<Hash :size="18" />
</div>
</div>
</template> </template>
<!-- Expanded: Full nav --> <!-- Expanded: Full nav -->
<template v-else> <template v-else>
<!-- Notes Section --> <!-- Notes Section -->
<div class="py-3"> <div class="py-3">
<div class="px-4 py-1 text-xs font-semibold text-surface-400 uppercase tracking-wider">
笔记
</div>
<div <div
v-for="item in navSections[0].items" class="px-4 py-1.5 flex items-center justify-between cursor-pointer hover:bg-surface-100 transition-colors"
:key="item.id" @click="toggleSection('notes')"
class="flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors"
:class="activeNav === item.id ? 'bg-primary-50 text-primary-600' : 'text-surface-700 hover:bg-surface-100'"
@click="handleNav(item.id)"
> >
<component :is="item.icon" :size="18" /> <span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">笔记</span>
<span class="text-sm">{{ item.label }}</span> <ChevronDown
v-if="expandedSections.notes"
:size="14"
class="text-surface-400 transition-transform"
/>
<ChevronRight
v-else
:size="14"
class="text-surface-400 transition-transform"
/>
</div> </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
? 'bg-surface-200/70 text-primary-700 font-medium shadow-sm'
: 'text-surface-600 hover:bg-surface-100'"
@click="handleNav(item.id)"
>
<component :is="item.icon" :size="18" />
<span class="text-sm">{{ item.label }}</span>
</div>
</div>
</Transition>
</div> </div>
<!-- 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>
<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)"
>
<BookOpen :size="18" />
<span class="text-sm flex-1">{{ nb.name }}</span>
</div>
</div>
</Transition>
</div>
<!-- Tags Section -->
<div class="py-1">
<div
class="px-4 py-1.5 flex items-center justify-between cursor-pointer hover:bg-surface-100 transition-colors"
@click="toggleSection('tags')"
>
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">标签</span>
<ChevronDown
v-if="expandedSections.tags"
:size="14"
class="text-surface-400 transition-transform"
/>
<ChevronRight
v-else
:size="14"
class="text-surface-400 transition-transform"
/>
</div>
<Transition name="collapse">
<div v-show="expandedSections.tags">
<div
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)"
>
<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>
</div>
</div>
</Transition>
</div>
</template> </template>
</ScrollPanel> </ScrollPanel>
<!-- Bottom Actions --> <!-- Bottom Actions -->
<div class="p-3 border-t border-app-border"> <div class="p-3 border-t border-app-border">
<template v-if="collapsed"> <template v-if="collapsed">
<div class="flex flex-col items-center gap-1"> <div class="flex flex-col items-center gap-2">
<Button text severity="secondary" size="small" @click="toggleTheme" v-tooltip.right="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'" class="btn-icon w-8 h-8"> <Button text severity="secondary" @click="toggleTheme" v-tooltip.right="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'" class="btn-icon w-10 h-10">
<template #icon> <template #icon>
<Sun v-if="theme === 'light'" :size="16" /> <Sun v-if="theme === 'light'" :size="20" />
<Moon v-else-if="theme === 'dark'" :size="16" /> <Moon v-else-if="theme === 'dark'" :size="20" />
<Monitor v-else :size="16" /> <Monitor v-else :size="20" />
</template> </template>
</Button> </Button>
<Button text severity="secondary" size="small" @click="handleNav('trash')" v-tooltip.right="'回收站'" :class="['btn-icon w-8 h-8', activeNav === 'trash' && 'btn-active']"> <Button text severity="secondary" @click="handleNav('settings')" v-tooltip.right="'设置'" class="btn-icon w-10 h-10">
<template #icon> <template #icon>
<Trash2 :size="16" /> <Settings :size="20" />
</template>
</Button>
<Button text severity="secondary" size="small" @click="handleNav('settings')" v-tooltip.right="'设置'" class="btn-icon w-8 h-8">
<template #icon>
<Settings :size="16" />
</template> </template>
</Button> </Button>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button text severity="secondary" size="small" @click="handleNav('trash')" :class="['btn-icon flex-1', activeNav === 'trash' && 'btn-active']"> <Button text severity="secondary" @click="toggleTheme" class="btn-icon h-10 px-3 flex items-center gap-2">
<template #icon> <template #icon>
<Trash2 :size="16" class="mr-2" /> <Sun v-if="theme === 'light'" :size="18" />
<Moon v-else-if="theme === 'dark'" :size="18" />
<Monitor v-else :size="18" />
</template> </template>
<span class="text-sm">回收站</span> <span class="text-sm">{{ theme === 'light' ? '浅色' : theme === 'dark' ? '深色' : '系统' }}</span>
</Button> </Button>
<Button text severity="secondary" size="small" @click="toggleTheme" v-tooltip.top="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'" class="btn-icon w-8 h-8"> <Button text severity="secondary" @click="handleNav('settings')" class="btn-icon h-10 px-3 flex items-center gap-2 flex-1">
<template #icon> <template #icon>
<Sun v-if="theme === 'light'" :size="16" /> <Settings :size="18" />
<Moon v-else-if="theme === 'dark'" :size="16" />
<Monitor v-else :size="16" />
</template>
</Button>
<Button text severity="secondary" size="small" @click="handleNav('settings')" class="btn-icon w-8 h-8">
<template #icon>
<Settings :size="16" />
</template> </template>
<span class="text-sm">设置</span>
</Button> </Button>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.collapse-enter-active,
.collapse-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
max-height: 0;
}
.collapse-enter-to,
.collapse-leave-from {
opacity: 1;
max-height: 500px;
}
</style>

View File

@@ -4,7 +4,7 @@ import Aura from '@primeuix/themes/aura'
import Tooltip from 'primevue/tooltip' import Tooltip from 'primevue/tooltip'
import App from './App.vue' import App from './App.vue'
import './assets/styles/main.css' import './assets/styles/main.css'
import 'primeicons/primeicons.css'
const app = createApp(App) const app = createApp(App)
app.use(PrimeVue, { app.use(PrimeVue, {