diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..c3d1a7b
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..61cffc3
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,4 @@
+dist
+out
+node_modules
+*.md
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..baa6e5e
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,13 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "useTabs": false,
+ "trailingComma": "all",
+ "printWidth": 160,
+ "endOfLine": "lf",
+ "bracketSpacing": true,
+ "arrowParens": "always",
+ "vueIndentScriptAndStyle": false,
+ "singleAttributePerLine": false
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..be0e5d3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,40 @@
+o# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Tunji is a Markdown editor desktop app inspired by Typora, built with Electron + Vue 3. The UI is in Chinese (Simplified). The app is in early development — all data is mock/hardcoded with no persistence layer yet.
+
+## Commands
+
+- `npm run dev` — Start development with hot reload (electron-vite dev)
+- `npm run build` — Production build to `out/`
+- `npm run preview` — Preview production build
+- `npm run package` — Package with electron-builder
+
+No test framework is installed.
+
+## Architecture
+
+This is an **electron-vite** project with three build targets configured in `electron.vite.config.mjs`:
+
+- **Main process** (`src/main/index.js`) — BrowserWindow creation, IPC for theme sync, titleBarOverlay color management
+- **Preload** (`src/main/indexpreload.js`) — Exposes `window.electronAPI` (platform, setTheme) via contextBridge
+- **Renderer** (`src/renderer/`) — Vue 3 SPA, path alias `@` → `src/renderer/src`
+
+### Key Tech Decisions
+
+- **Tailwind CSS v4** — uses `@tailwindcss/vite` plugin and `@theme` directive syntax (not `tailwind.config.js`)
+- **PrimeVue** — component library with Aura theme preset; dark mode selector is `.dark`
+- **Icons** — use `@lucide/vue` for all icons, not PrimeVue's built-in icon components
+- **Theme system** — `composables/useTheme.js` manages light/dark/system modes, persisted to localStorage (`tunji-theme`), synced to Electron main process via IPC for titleBarOverlay colors. Dark palette is Catppuccin Mocha-inspired.
+- **Markdown rendering** — unified/remark-parse/remark-gfm/remark-rehype/rehype-stringify pipeline in EditorPanel.vue
+
+### Component Layout
+
+`App.vue` orchestrates three panels: `SideBar` (navigation, search, notebooks, tags, theme toggle) → `NotesOverview` (card/list grid of notes) → `EditorPanel` (markdown editor with edit/preview/split modes, formatting toolbar, auto-generated outline from headings). `ButtonIcon.vue` is a shared icon button component.
+
+### Styling
+
+Custom CSS properties are defined in `src/renderer/src/assets/styles/main.css` using Tailwind v4's `@theme` syntax for primary (indigo), neutral, semantic, and app-level colors. Light and dark mode palettes are both defined there.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c67d70c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,363 @@
+# Tunji - Markdown 编辑器
+
+一个受 Typora 启发的 Markdown 编辑器桌面应用,使用 Electron + Vue 3 构建。
+
+## 功能特性
+
+### 核心功能
+- **Markdown 编辑**:支持实时预览、分屏编辑、源码模式
+- **笔记本管理**:支持多级笔记本结构
+- **标签系统**:灵活的标签管理
+- **主题切换**:浅色、深色、跟随系统
+- **工作区管理**:支持自定义工作区路径
+
+### 最新功能:笔记本树形结构
+
+#### 功能概述
+使用 PrimeVue Tree 组件实现笔记本的树形结构显示,支持:
+
+1. **树形结构展示**:笔记本和文档以树形结构展示
+2. **层级关系**:支持父子笔记本关系
+3. **展开/折叠**:可以展开或折叠笔记本节点
+4. **节点选择**:点击笔记本或文档节点可进行选择
+5. **自定义图标**:笔记本使用书本图标,文档使用文件图标
+
+#### 使用方式
+
+##### 基本使用
+```vue
+
+
+
+
+
+```
+
+##### 高级功能
+```javascript
+import { useFilteredNotebookTree } from '@/composables/useNotebookTreeExample'
+
+const filterText = ref('')
+const { filteredTree } = useFilteredNotebookTree(filterText)
+```
+
+#### 技术实现
+
+##### 数据结构
+```javascript
+// 树节点格式
+{
+ key: 'notebook-{id}',
+ label: '节点名称',
+ data: {
+ type: 'notebook' | 'note',
+ id: '节点ID',
+ name: '笔记本名称',
+ title: '文档标题',
+ notebook_id: '所属笔记本ID'
+ },
+ icon: 'pi pi-book' | 'pi pi-file',
+ children: [],
+ selectable: true
+}
+```
+
+##### 核心逻辑
+- **树形结构构建**:将扁平的笔记本列表转换为树形结构
+- **展开状态管理**:默认展开所有笔记本节点
+- **节点选择处理**:支持笔记本和文档节点的选择
+
+##### 样式定制
+```css
+.notebook-tree :deep(.p-tree-node-content) {
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.5rem;
+ transition: background-color 0.2s;
+}
+
+.notebook-tree :deep(.p-tree-node-selected) {
+ background-color: var(--emerald-50);
+ color: var(--emerald-700);
+ font-weight: 500;
+}
+```
+
+## 项目结构
+
+```
+src/
+├── main/ # Electron 主进程
+│ ├── index.js # 主窗口创建
+│ ├── indexpreload.js # 预加载脚本
+│ └── services/ # 服务层
+│ ├── StorageService.js # 统一存储服务
+│ ├── MetaStore.js # SQLite 元数据存储
+│ └── FileStorage.js # 文件存储
+├── renderer/ # Vue 渲染进程
+│ ├── src/
+│ │ ├── components/ # Vue 组件
+│ │ │ ├── SideBar.vue # 侧边栏组件
+│ │ │ ├── NotesOverview.vue # 笔记概览
+│ │ │ └── EditorPanel.vue # 编辑器面板
+│ │ ├── composables/ # Vue 组合式函数
+│ │ │ ├── useNotebookTree.js # 笔记本树形结构
+│ │ │ ├── useNotebookTreeExample.js # 使用示例
+│ │ │ ├── useNotebooks.js # 笔记本管理
+│ │ │ ├── useNotes.js # 笔记管理
+│ │ │ └── useTheme.js # 主题管理
+│ │ ├── stores/ # Pinia 状态管理
+│ │ │ ├── notebooks.js # 笔记本状态
+│ │ │ ├── notes.js # 笔记状态
+│ │ │ ├── tags.js # 标签状态
+│ │ │ └── theme.js # 主题状态
+│ │ └── assets/ # 静态资源
+│ └── index.html # 入口 HTML
+└── electron.vite.config.mjs # Electron Vite 配置
+```
+
+## 开发指南
+
+### 环境要求
+- Node.js >= 16
+- npm >= 8
+
+### 安装依赖
+```bash
+npm install
+```
+
+### 开发模式
+```bash
+npm run dev
+```
+
+### 构建生产版本
+```bash
+npm run build
+```
+
+### 打包应用
+```bash
+npm run package
+```
+
+## 技术栈
+
+### 前端框架
+- **Vue 3**:渐进式 JavaScript 框架
+- **Pinia**:Vue 状态管理库
+- **PrimeVue**:Vue UI 组件库
+- **Tailwind CSS v4**:实用优先的 CSS 框架
+
+### 桌面框架
+- **Electron**:跨平台桌面应用框架
+- **electron-vite**:Electron 构建工具
+
+### 数据存储
+- **SQLite (sql.js)**:轻量级数据库
+- **文件系统**:Markdown 文件存储
+
+### 开发工具
+- **Vite**:前端构建工具
+- **ESLint**:代码检查工具
+- **Prettier**:代码格式化工具
+
+## 功能模块
+
+### 1. 笔记本管理
+- 创建、重命名、删除笔记本
+- 支持多级笔记本结构
+- 树形结构展示
+
+### 2. 笔记管理
+- 创建、编辑、删除笔记
+- Markdown 实时预览
+- 支持多种编辑模式
+
+### 3. 标签系统
+- 创建、删除标签
+- 为笔记添加标签
+- 按标签筛选笔记
+
+### 4. 主题系统
+- 浅色主题
+- 深色主题
+- 跟随系统主题
+
+### 5. 工作区管理
+- 自定义工作区路径
+- 工作区初始化
+- 数据导入导出
+
+## 组件说明
+
+### SideBar.vue
+侧边栏组件,包含:
+- 搜索功能
+- 导航菜单
+- 笔记本树形结构
+- 标签列表
+- 主题切换
+- 设置入口
+
+### NotesOverview.vue
+笔记概览组件,显示:
+- 笔记列表
+- 笔记预览
+- 筛选和排序功能
+
+### EditorPanel.vue
+编辑器面板,提供:
+- Markdown 编辑
+- 实时预览
+- 分屏模式
+- 工具栏
+
+## 状态管理
+
+### notebooks.js
+笔记本状态管理:
+- 笔记本列表
+- 创建、重命名、删除操作
+- 笔记本查询
+
+### notes.js
+笔记状态管理:
+- 笔记列表
+- 创建、编辑、删除操作
+- 笔记搜索
+
+### tags.js
+标签状态管理:
+- 标签列表
+- 创建、删除操作
+- 标签关联
+
+### theme.js
+主题状态管理:
+- 主题切换
+- 主题持久化
+- 系统主题同步
+
+## 组合式函数
+
+### useNotebookTree.js
+笔记本树形结构:
+- 树形数据构建
+- 展开状态管理
+- 节点查找功能
+
+### useNotebooks.js
+笔记本管理:
+- 笔记本 CRUD 操作
+- 笔记本查询
+- 笔记本排序
+
+### useNotes.js
+笔记管理:
+- 笔记 CRUD 操作
+- 笔记搜索
+- 笔记筛选
+
+### useTheme.js
+主题管理:
+- 主题切换
+- 主题持久化
+- 系统主题检测
+
+## 存储架构
+
+### MetaStore.js
+SQLite 元数据存储:
+- 笔记本元数据
+- 笔记元数据
+- 标签元数据
+- 关联关系
+
+### FileStorage.js
+文件存储:
+- Markdown 文件读写
+- 文件目录管理
+- 文件同步
+
+### StorageService.js
+统一存储服务:
+- 协调 MetaStore 和 FileStorage
+- 提供统一 API
+- 数据一致性保证
+
+## 构建配置
+
+### electron.vite.config.mjs
+Electron Vite 配置:
+- 主进程构建
+- 预加载脚本构建
+- 渲染进程构建
+
+### package.json
+项目配置:
+- 依赖管理
+- 脚本命令
+- 构建配置
+
+## 部署说明
+
+### 开发环境
+```bash
+npm run dev
+```
+
+### 生产环境
+```bash
+npm run build
+npm run package
+```
+
+### 平台支持
+- Windows
+- macOS
+- Linux
+
+## 贡献指南
+
+### 开发规范
+- 使用 Vue 3 Composition API
+- 遵循 ESLint 规则
+- 使用 Prettier 格式化代码
+
+### 提交规范
+- 使用 Conventional Commits
+- 每个提交解决一个问题
+- 提交前运行测试
+
+### 代码审查
+- 至少一人审查
+- 通过所有测试
+- 无代码异味
+
+## 许可证
+
+MIT License
+
+## 联系方式
+- 项目地址:[GitHub Repository]
+- 问题反馈:[Issues]
+- 文档:[Documentation]
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 6cb86b3..d2e330f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
"autoprefixer": "^10.5.0",
"electron-builder": "^26.8.1",
"postcss": "^8.5.15",
+ "prettier": "^3.8.3",
"vite": "^7.3.3"
}
},
@@ -6816,6 +6817,22 @@
"node": "^12.20.0 || >=14"
}
},
+ "node_modules/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/primeicons": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
diff --git a/package.json b/package.json
index 95eaa15..aa59034 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
- "package": "electron-builder"
+ "package": "electron-builder",
+ "format": "prettier --write ."
},
"keywords": [
"markdown",
@@ -43,6 +44,7 @@
"autoprefixer": "^10.5.0",
"electron-builder": "^26.8.1",
"postcss": "^8.5.15",
+ "prettier": "^3.8.3",
"vite": "^7.3.3"
}
}
diff --git a/src/main/services/FileStorage.js b/src/main/services/FileStorage.js
new file mode 100644
index 0000000..3dd99ed
--- /dev/null
+++ b/src/main/services/FileStorage.js
@@ -0,0 +1,198 @@
+import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, readdirSync } from 'fs'
+import { join, dirname, extname, relative } from 'path'
+import { mkdirSync } from 'fs'
+import matter from 'gray-matter'
+
+/**
+ * FileStorage - Manages .md files on the file system
+ * Each note is stored as a .md file with YAML frontmatter for metadata.
+ */
+export class FileStorage {
+ constructor(workspacePath) {
+ this.workspacePath = workspacePath
+ this.notebooksDir = join(workspacePath, 'notebooks')
+ }
+
+ init() {
+ if (!existsSync(this.notebooksDir)) {
+ mkdirSync(this.notebooksDir, { recursive: true })
+ }
+ }
+
+ /**
+ * Create a new .md file for a note
+ * @param {Object} options
+ * @param {string} options.id - Note UUID
+ * @param {string} options.title - Note title
+ * @param {string} options.content - Markdown content (without frontmatter)
+ * @param {string} options.notebookName - Notebook folder name
+ * @param {string[]} options.tags - Tag names
+ * @returns {{ filePath: string, absolutePath: string }}
+ */
+ createFile({ id, title, content = '', notebookName = '默认笔记本', tags = [] }) {
+ const dir = this._ensureNotebookDir(notebookName)
+ const fileName = this._sanitizeFileName(title) || '无标题'
+ const filePath = this._getUniqueFilePath(dir, fileName)
+ const absolutePath = join(dir, filePath)
+ const relativePath = join('notebooks', notebookName, filePath)
+
+ const frontmatter = {
+ id,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ tags
+ }
+
+ const fileContent = matter.stringify(content, frontmatter)
+ writeFileSync(absolutePath, fileContent, 'utf-8')
+
+ return { filePath: relativePath, absolutePath }
+ }
+
+ /**
+ * Read a .md file and parse its frontmatter
+ * @param {string} relativePath - Path relative to workspace
+ * @returns {{ meta: Object, content: string } | null}
+ */
+ readFile(relativePath) {
+ const absolutePath = join(this.workspacePath, relativePath)
+ if (!existsSync(absolutePath)) return null
+
+ const raw = readFileSync(absolutePath, 'utf-8')
+ const parsed = matter(raw)
+
+ return {
+ meta: parsed.data,
+ content: parsed.content
+ }
+ }
+
+ /**
+ * Update a .md file's content and/or frontmatter
+ * @param {string} relativePath - Path relative to workspace
+ * @param {Object} options
+ * @param {string} options.content - New markdown content
+ * @param {Object} options.meta - Frontmatter updates
+ */
+ updateFile(relativePath, { content, meta }) {
+ const absolutePath = join(this.workspacePath, relativePath)
+ if (!existsSync(absolutePath)) return false
+
+ const existing = this.readFile(relativePath)
+ if (!existing) return false
+
+ const newMeta = { ...existing.meta, ...meta, updated: new Date().toISOString() }
+ const newContent = content !== undefined ? content : existing.content
+
+ const fileContent = matter.stringify(newContent, newMeta)
+ writeFileSync(absolutePath, fileContent, 'utf-8')
+ return true
+ }
+
+ /**
+ * Delete a .md file
+ * @param {string} relativePath - Path relative to workspace
+ */
+ deleteFile(relativePath) {
+ const absolutePath = join(this.workspacePath, relativePath)
+ if (existsSync(absolutePath)) {
+ unlinkSync(absolutePath)
+ }
+ }
+
+ /**
+ * Rename/move a note file
+ * @param {string} oldPath - Current relative path
+ * @param {string} newTitle - New title (used for filename)
+ * @returns {string} New relative path
+ */
+ renameFile(oldPath, newTitle) {
+ const absoluteOld = join(this.workspacePath, oldPath)
+ if (!existsSync(absoluteOld)) return oldPath
+
+ const dir = dirname(absoluteOld)
+ const fileName = this._sanitizeFileName(newTitle) || '无标题'
+ const newFileName = this._getUniqueFilePath(dir, fileName, absoluteOld)
+ const absoluteNew = join(dir, newFileName)
+
+ renameSync(absoluteOld, absoluteNew)
+ const relativePath = relative(this.workspacePath, absoluteNew)
+ return relativePath
+ }
+
+ /**
+ * List all .md files in a notebook directory (recursively)
+ * @param {string} notebookName - Notebook folder name
+ * @returns {string[]} Array of relative paths
+ */
+ listFiles(notebookName) {
+ const dir = join(this.notebooksDir, notebookName)
+ if (!existsSync(dir)) return []
+ return this._listMdFilesRecursive(dir)
+ }
+
+ /**
+ * List all .md files in the workspace
+ * @returns {string[]} Array of relative paths
+ */
+ listAllFiles() {
+ if (!existsSync(this.notebooksDir)) return []
+ return this._listMdFilesRecursive(this.notebooksDir)
+ }
+
+ // ==================== Internal Helpers ====================
+
+ _ensureNotebookDir(notebookName) {
+ const dir = join(this.notebooksDir, notebookName)
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true })
+ }
+ return dir
+ }
+
+ /**
+ * Sanitize a title into a valid filename
+ * Removes characters not allowed in file names
+ */
+ _sanitizeFileName(title) {
+ if (!title) return ''
+ return title
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove invalid chars
+ .replace(/\s+/g, ' ') // Normalize spaces
+ .trim()
+ .substring(0, 200) // Limit length
+ }
+
+ /**
+ * Get a unique filename, appending (1), (2), etc. if needed
+ */
+ _getUniqueFilePath(dir, baseName, excludePath = null) {
+ const ext = '.md'
+ let candidate = `${baseName}${ext}`
+ let counter = 1
+ const fullPath = join(dir, candidate)
+
+ while (existsSync(fullPath) && fullPath !== excludePath) {
+ candidate = `${baseName} (${counter})${ext}`
+ counter++
+ }
+
+ return candidate
+ }
+
+ /**
+ * Recursively list all .md files
+ */
+ _listMdFilesRecursive(dir, results = []) {
+ const entries = readdirSync(dir, { withFileTypes: true })
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name)
+ if (entry.isDirectory()) {
+ this._listMdFilesRecursive(fullPath, results)
+ } else if (extname(entry.name) === '.md') {
+ results.push(relative(this.workspacePath, fullPath))
+ }
+ }
+ return results
+ }
+}
diff --git a/src/main/services/MetaStore.js b/src/main/services/MetaStore.js
new file mode 100644
index 0000000..951cde3
--- /dev/null
+++ b/src/main/services/MetaStore.js
@@ -0,0 +1,354 @@
+import initSqlJs from 'sql.js'
+import { readFileSync, writeFileSync, existsSync } from 'fs'
+import { dirname } from 'path'
+import { mkdirSync } from 'fs'
+
+/**
+ * MetaStore - SQLite-based metadata storage for notes
+ * Uses sql.js (WASM) to avoid native compilation requirements.
+ * Database is loaded into memory and persisted to disk on changes.
+ */
+export class MetaStore {
+ constructor(dbPath) {
+ this.dbPath = dbPath
+ this.db = null
+ this._dirty = false
+ this._saveTimer = null
+ }
+
+ async init() {
+ const SQL = await initSqlJs()
+
+ if (existsSync(this.dbPath)) {
+ const buffer = readFileSync(this.dbPath)
+ this.db = new SQL.Database(buffer)
+ this._migrate()
+ } else {
+ this.db = new SQL.Database()
+ this._createTables()
+ this._seedDefaults()
+ this._save()
+ }
+
+ // Enable WAL-like behavior for better concurrency
+ this.db.run('PRAGMA journal_mode=DELETE')
+ this.db.run('PRAGMA foreign_keys=ON')
+ }
+
+ _createTables() {
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS notebooks (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ parent_id TEXT,
+ sort_order INTEGER DEFAULT 0,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (parent_id) REFERENCES notebooks(id) ON DELETE SET NULL
+ )
+ `)
+
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS notes (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL DEFAULT '',
+ file_path TEXT NOT NULL UNIQUE,
+ notebook_id TEXT NOT NULL,
+ word_count INTEGER DEFAULT 0,
+ is_pinned INTEGER DEFAULT 0,
+ is_favorited INTEGER DEFAULT 0,
+ is_trashed INTEGER DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (notebook_id) REFERENCES notebooks(id) ON DELETE CASCADE
+ )
+ `)
+
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS tags (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE
+ )
+ `)
+
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS note_tags (
+ note_id TEXT NOT NULL,
+ tag_id TEXT NOT NULL,
+ PRIMARY KEY (note_id, tag_id),
+ FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
+ FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
+ )
+ `)
+
+ this.db.run(`
+ CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id)
+ `)
+ this.db.run(`
+ CREATE INDEX IF NOT EXISTS idx_notes_trashed ON notes(is_trashed)
+ `)
+ this.db.run(`
+ CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at)
+ `)
+ }
+
+ _migrate() {
+ // Add is_favorited column if it doesn't exist (for existing databases)
+ try {
+ const columns = this._all("PRAGMA table_info(notes)")
+ const hasFavorited = columns.some(c => c.name === 'is_favorited')
+ if (!hasFavorited) {
+ this.db.run('ALTER TABLE notes ADD COLUMN is_favorited INTEGER DEFAULT 0')
+ this._markDirty()
+ }
+ } catch (e) {
+ // Table might not exist yet, ignore
+ }
+ }
+
+ _seedDefaults() {
+ const now = new Date().toISOString()
+ // Create a default notebook
+ this.db.run(
+ 'INSERT INTO notebooks (id, name, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
+ ['default', '默认笔记本', null, 0, now]
+ )
+ }
+
+ _save() {
+ if (this._saveTimer) clearTimeout(this._saveTimer)
+ this._saveTimer = setTimeout(() => {
+ const data = this.db.export()
+ const dir = dirname(this.dbPath)
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
+ writeFileSync(this.dbPath, Buffer.from(data))
+ this._dirty = false
+ }, 100) // debounce 100ms
+ }
+
+ _markDirty() {
+ this._dirty = true
+ this._save()
+ }
+
+ // --- Helper: run a write query and auto-save ---
+ _run(sql, params = []) {
+ this.db.run(sql, params)
+ this._markDirty()
+ }
+
+ // --- Helper: get all rows ---
+ _all(sql, params = []) {
+ const stmt = this.db.prepare(sql)
+ if (params.length) stmt.bind(params)
+ const rows = []
+ while (stmt.step()) {
+ rows.push(stmt.getAsObject())
+ }
+ stmt.free()
+ return rows
+ }
+
+ // --- Helper: get one row ---
+ _get(sql, params = []) {
+ const rows = this._all(sql, params)
+ return rows.length > 0 ? rows[0] : null
+ }
+
+ // ==================== Notes ====================
+
+ createNote({ id, title, filePath, notebookId, wordCount = 0 }) {
+ const now = new Date().toISOString()
+ this._run(
+ 'INSERT INTO notes (id, title, file_path, notebook_id, word_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [id, title, filePath, notebookId, wordCount, now, now]
+ )
+ return this.getNote(id)
+ }
+
+ getNote(id) {
+ const note = this._get('SELECT * FROM notes WHERE id = ?', [id])
+ if (!note) return null
+ note.tags = this._getNoteTags(id)
+ return note
+ }
+
+ getNoteByPath(filePath) {
+ const note = this._get('SELECT * FROM notes WHERE file_path = ?', [filePath])
+ if (!note) return null
+ note.tags = this._getNoteTags(note.id)
+ return note
+ }
+
+ updateNote(id, { title, notebookId, wordCount, isPinned, isFavorited, isTrashed, filePath }) {
+ const fields = []
+ const params = []
+
+ if (title !== undefined) { fields.push('title = ?'); params.push(title) }
+ if (filePath !== undefined) { fields.push('file_path = ?'); params.push(filePath) }
+ if (notebookId !== undefined) { fields.push('notebook_id = ?'); params.push(notebookId) }
+ if (wordCount !== undefined) { fields.push('word_count = ?'); params.push(wordCount) }
+ if (isPinned !== undefined) { fields.push('is_pinned = ?'); params.push(isPinned ? 1 : 0) }
+ if (isFavorited !== undefined) { fields.push('is_favorited = ?'); params.push(isFavorited ? 1 : 0) }
+ if (isTrashed !== undefined) { fields.push('is_trashed = ?'); params.push(isTrashed ? 1 : 0) }
+
+ if (fields.length === 0) return
+
+ fields.push('updated_at = ?')
+ params.push(new Date().toISOString())
+ params.push(id)
+
+ this._run(`UPDATE notes SET ${fields.join(', ')} WHERE id = ?`, params)
+ }
+
+ deleteNote(id) {
+ this._run('DELETE FROM notes WHERE id = ?', [id])
+ }
+
+ listNotes({ notebookId, tagId, isTrashed = 0, isFavorited, search, sort = 'updated_at' } = {}) {
+ let sql = 'SELECT n.* FROM notes n'
+ const params = []
+ const conditions = ['n.is_trashed = ?']
+ params.push(isTrashed ? 1 : 0)
+
+ if (isFavorited !== undefined) {
+ conditions.push('n.is_favorited = ?')
+ params.push(isFavorited ? 1 : 0)
+ }
+
+ if (tagId) {
+ sql += ' INNER JOIN note_tags nt ON n.id = nt.note_id'
+ conditions.push('nt.tag_id = ?')
+ params.push(tagId)
+ }
+
+ if (notebookId) {
+ conditions.push('n.notebook_id = ?')
+ params.push(notebookId)
+ }
+
+ if (search) {
+ conditions.push('(n.title LIKE ?)')
+ params.push(`%${search}%`)
+ }
+
+ sql += ` WHERE ${conditions.join(' AND ')}`
+
+ const validSorts = ['updated_at', 'created_at', 'title', 'word_count']
+ const sortCol = validSorts.includes(sort) ? sort : 'updated_at'
+ sql += ` GROUP BY n.id ORDER BY n.is_pinned DESC, n.${sortCol} DESC`
+
+ const notes = this._all(sql, params)
+ // Attach tags to each note
+ for (const note of notes) {
+ note.tags = this._getNoteTags(note.id)
+ }
+ return notes
+ }
+
+ _getNoteTags(noteId) {
+ return this._all(
+ `SELECT t.id, t.name FROM tags t
+ INNER JOIN note_tags nt ON t.id = nt.tag_id
+ WHERE nt.note_id = ?`,
+ [noteId]
+ )
+ }
+
+ // ==================== Notebooks ====================
+
+ createNotebook({ id, name, parentId = null, sortOrder = 0 }) {
+ const now = new Date().toISOString()
+ this._run(
+ 'INSERT INTO notebooks (id, name, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
+ [id, name, parentId, sortOrder, now]
+ )
+ return this.getNotebook(id)
+ }
+
+ getNotebook(id) {
+ return this._get('SELECT * FROM notebooks WHERE id = ?', [id])
+ }
+
+ listNotebooks() {
+ return this._all('SELECT * FROM notebooks ORDER BY sort_order, name')
+ }
+
+ renameNotebook(id, name) {
+ this._run('UPDATE notebooks SET name = ? WHERE id = ?', [name, id])
+ }
+
+ deleteNotebook(id) {
+ // Move notes to default notebook before deleting
+ this._run("UPDATE notes SET notebook_id = 'default' WHERE notebook_id = ?", [id])
+ this._run('DELETE FROM notebooks WHERE id = ?', [id])
+ }
+
+ // ==================== Tags ====================
+
+ createTag({ id, name }) {
+ this._run('INSERT INTO tags (id, name) VALUES (?, ?)', [id, name])
+ return { id, name }
+ }
+
+ getTag(id) {
+ return this._get('SELECT * FROM tags WHERE id = ?', [id])
+ }
+
+ listTags() {
+ return this._all('SELECT t.*, COUNT(nt.note_id) as note_count FROM tags t LEFT JOIN note_tags nt ON t.id = nt.tag_id GROUP BY t.id ORDER BY t.name')
+ }
+
+ deleteTag(id) {
+ this._run('DELETE FROM tags WHERE id = ?', [id])
+ }
+
+ addTagToNote(noteId, tagId) {
+ this._run('INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)', [noteId, tagId])
+ }
+
+ removeTagFromNote(noteId, tagId) {
+ this._run('DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?', [noteId, tagId])
+ }
+
+ setNoteTags(noteId, tagIds) {
+ this._run('DELETE FROM note_tags WHERE note_id = ?', [noteId])
+ for (const tagId of tagIds) {
+ this._run('INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)', [noteId, tagId])
+ }
+ }
+
+ // ==================== Search ====================
+
+ search(query) {
+ return this._all(
+ "SELECT * FROM notes WHERE title LIKE ? AND is_trashed = 0 ORDER BY updated_at DESC",
+ [`%${query}%`]
+ )
+ }
+
+ // ==================== Stats ====================
+
+ getStats() {
+ const noteCount = this._get('SELECT COUNT(*) as count FROM notes WHERE is_trashed = 0')
+ const notebookCount = this._get('SELECT COUNT(*) as count FROM notebooks')
+ const tagCount = this._get('SELECT COUNT(*) as count FROM tags')
+ return {
+ notes: noteCount?.count || 0,
+ notebooks: notebookCount?.count || 0,
+ tags: tagCount?.count || 0
+ }
+ }
+
+ // ==================== Lifecycle ====================
+
+ close() {
+ if (this._saveTimer) clearTimeout(this._saveTimer)
+ // Final save
+ if (this.db) {
+ const data = this.db.export()
+ writeFileSync(this.dbPath, Buffer.from(data))
+ this.db.close()
+ this.db = null
+ }
+ }
+}
diff --git a/src/main/services/StorageService.js b/src/main/services/StorageService.js
new file mode 100644
index 0000000..34d2613
--- /dev/null
+++ b/src/main/services/StorageService.js
@@ -0,0 +1,425 @@
+import { join, dirname } from 'path'
+import { existsSync, mkdirSync, renameSync } from 'fs'
+import { app } from 'electron'
+import { nanoid } from 'nanoid'
+import { MetaStore } from './MetaStore'
+import { FileStorage } from './FileStorage'
+
+/**
+ * StorageService - Unified storage layer combining FileStorage + MetaStore
+ * Coordinates between .md files (content) and SQLite (metadata).
+ */
+export class StorageService {
+ constructor() {
+ this.metaStore = null
+ this.fileStorage = null
+ this.workspacePath = null
+ this._listeners = new Set()
+ }
+
+ async init(workspacePath) {
+ this.workspacePath = workspacePath
+
+ // Ensure .tunji directory exists
+ const tunjiDir = join(workspacePath, '.tunji')
+ if (!existsSync(tunjiDir)) {
+ mkdirSync(tunjiDir, { recursive: true })
+ }
+
+ // Initialize sub-services
+ this.metaStore = new MetaStore(join(tunjiDir, 'tunji.db'))
+ await this.metaStore.init()
+
+ this.fileStorage = new FileStorage(workspacePath)
+ this.fileStorage.init()
+
+ // Sync file system with database on startup
+ await this._syncFileSystem()
+ }
+
+ // ==================== Notes ====================
+
+ /**
+ * Create a new note
+ */
+ async createNote({ title = '无标题', content = '', notebookId = 'default', tags = [] } = {}) {
+ const id = nanoid(12)
+ const notebook = this.metaStore.getNotebook(notebookId)
+ const notebookName = notebook ? notebook.name : '默认笔记本'
+
+ // Create .md file
+ const { filePath } = this.fileStorage.createFile({
+ id,
+ title,
+ content,
+ notebookName,
+ tags
+ })
+
+ // Create database record
+ const note = this.metaStore.createNote({
+ id,
+ title,
+ filePath,
+ notebookId,
+ wordCount: this._countWords(content)
+ })
+
+ // Set tags in junction table
+ if (tags.length > 0) {
+ this._ensureTags(tags)
+ const tagRecords = tags.map(name => {
+ const t = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
+ return t?.id
+ }).filter(Boolean)
+ this.metaStore.setNoteTags(id, tagRecords)
+ }
+
+ return this.getNote(id)
+ }
+
+ /**
+ * Get a single note with full content
+ */
+ async getNote(id) {
+ const note = this.metaStore.getNote(id)
+ if (!note) return null
+
+ // Read content from file
+ const fileData = this.fileStorage.readFile(note.file_path)
+ if (fileData) {
+ note.content = fileData.content
+ // Sync title from frontmatter if different
+ if (fileData.meta.tags && fileData.meta.tags.length > 0) {
+ note.tags = fileData.meta.tags.map(name => ({ id: name, name }))
+ }
+ } else {
+ note.content = ''
+ }
+
+ // Get notebook name
+ const notebook = this.metaStore.getNotebook(note.notebook_id)
+ note.notebook_name = notebook ? notebook.name : '未知'
+
+ return note
+ }
+
+ /**
+ * Update a note's content and/or metadata
+ */
+ async updateNote(id, { title, content, notebookId, tags, isPinned, isFavorited, isTrashed }) {
+ const note = this.metaStore.getNote(id)
+ if (!note) return null
+
+ const updates = {}
+
+ // Update file content if provided
+ if (content !== undefined || title !== undefined || tags !== undefined) {
+ const meta = {}
+ if (tags !== undefined) meta.tags = tags
+
+ this.fileStorage.updateFile(note.file_path, {
+ content: content !== undefined ? content : undefined,
+ meta
+ })
+
+ if (content !== undefined) {
+ updates.wordCount = this._countWords(content)
+ }
+
+ // If title changed, rename the file
+ if (title !== undefined && title !== note.title) {
+ const oldPath = note.file_path
+ const notebook = this.metaStore.getNotebook(note.notebook_id)
+ const notebookName = notebook ? notebook.name : '默认笔记本'
+ const newRelativePath = `notebooks/${notebookName}/${this.fileStorage._sanitizeFileName(title) || '无标题'}.md`
+
+ // Only rename if the path would actually change
+ if (newRelativePath !== oldPath) {
+ const absoluteOld = join(this.workspacePath, oldPath)
+ const absoluteNew = join(this.workspacePath, newRelativePath)
+ if (existsSync(absoluteOld)) {
+ const dir = dirname(absoluteNew)
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
+ renameSync(absoluteOld, absoluteNew)
+ updates.filePath = newRelativePath
+ }
+ }
+ }
+
+ updates.title = title !== undefined ? title : note.title
+ }
+
+ if (notebookId !== undefined) updates.notebookId = notebookId
+ if (isPinned !== undefined) updates.isPinned = isPinned
+ if (isFavorited !== undefined) updates.isFavorited = isFavorited
+ if (isTrashed !== undefined) updates.isTrashed = isTrashed
+
+ // Update database
+ this.metaStore.updateNote(id, updates)
+
+ // Update tags in junction table
+ if (tags !== undefined) {
+ this._ensureTags(tags)
+ const tagRecords = tags.map(name => {
+ const t = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
+ return t?.id
+ }).filter(Boolean)
+ this.metaStore.setNoteTags(id, tagRecords)
+ }
+
+ return this.getNote(id)
+ }
+
+ /**
+ * Delete a note (moves to trash, or permanently if already trashed)
+ */
+ async deleteNote(id, permanent = false) {
+ const note = this.metaStore.getNote(id)
+ if (!note) return false
+
+ if (permanent) {
+ this.fileStorage.deleteFile(note.file_path)
+ this.metaStore.deleteNote(id)
+ } else {
+ this.metaStore.updateNote(id, { isTrashed: true })
+ }
+
+ return true
+ }
+
+ /**
+ * Restore a note from trash
+ */
+ async restoreNote(id) {
+ this.metaStore.updateNote(id, { isTrashed: false })
+ return this.getNote(id)
+ }
+
+ /**
+ * List notes with optional filters
+ */
+ async listNotes(filter = {}) {
+ return this.metaStore.listNotes(filter)
+ }
+
+ /**
+ * Search notes by query
+ */
+ async searchNotes(query) {
+ if (!query || !query.trim()) return []
+ return this.metaStore.search(query.trim())
+ }
+
+ // ==================== Notebooks ====================
+
+ async createNotebook({ name, parentId = null }) {
+ const id = nanoid(12)
+ const notebook = this.metaStore.createNotebook({ id, name, parentId })
+
+ // Create the directory on disk
+ this.fileStorage._ensureNotebookDir(name)
+
+ return notebook
+ }
+
+ async listNotebooks() {
+ return this.metaStore.listNotebooks()
+ }
+
+ async renameNotebook(id, name) {
+ const notebook = this.metaStore.getNotebook(id)
+ if (!notebook) return null
+
+ // Rename directory on disk
+ const oldDir = join(this.workspacePath, 'notebooks', notebook.name)
+ const newDir = join(this.workspacePath, 'notebooks', name)
+ if (existsSync(oldDir) && !existsSync(newDir)) {
+ renameSync(oldDir, newDir)
+
+ // Update file paths in database for all notes in this notebook
+ const notes = this.metaStore.listNotes({ notebookId: id })
+ for (const note of notes) {
+ const oldPath = note.file_path
+ const newPath = oldPath.replace(`notebooks/${notebook.name}/`, `notebooks/${name}/`)
+ this.metaStore.updateNote(note.id, { filePath: newPath })
+ }
+ }
+
+ this.metaStore.renameNotebook(id, name)
+ return this.metaStore.getNotebook(id)
+ }
+
+ async deleteNotebook(id) {
+ if (id === 'default') return false // Cannot delete default notebook
+ this.metaStore.deleteNotebook(id)
+ return true
+ }
+
+ // ==================== Tags ====================
+
+ async createTag(name) {
+ const existing = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
+ if (existing) return { id: existing.id, name }
+
+ const id = nanoid(8)
+ return this.metaStore.createTag({ id, name })
+ }
+
+ async listTags() {
+ return this.metaStore.listTags()
+ }
+
+ async deleteTag(id) {
+ this.metaStore.deleteTag(id)
+ return true
+ }
+
+ async addTagToNote(noteId, tagName) {
+ this._ensureTags([tagName])
+ const tag = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [tagName])
+ if (!tag) return false
+ this.metaStore.addTagToNote(noteId, tag.id)
+ return true
+ }
+
+ async removeTagFromNote(noteId, tagName) {
+ const tag = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [tagName])
+ if (!tag) return false
+ this.metaStore.removeTagFromNote(noteId, tag.id)
+ return true
+ }
+
+ // ==================== Utilities ====================
+
+ /**
+ * Get workspace statistics
+ */
+ async getStats() {
+ return this.metaStore.getStats()
+ }
+
+ /**
+ * Force save all pending changes
+ */
+ flush() {
+ if (this.metaStore) {
+ this.metaStore._save()
+ }
+ }
+
+ /**
+ * Close and cleanup
+ */
+ close() {
+ if (this.metaStore) {
+ this.metaStore.close()
+ }
+ }
+
+ // ==================== Internal ====================
+
+ /**
+ * Ensure tag records exist for given tag names
+ */
+ _ensureTags(tagNames) {
+ for (const name of tagNames) {
+ const existing = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
+ if (!existing) {
+ this.metaStore.createTag({ id: nanoid(8), name })
+ }
+ }
+ }
+
+ /**
+ * Count words in content (Chinese + English)
+ */
+ _countWords(text) {
+ if (!text) return 0
+ // Count Chinese characters individually, English words by spaces
+ const chinese = (text.match(/[一-鿿]/g) || []).length
+ const english = text.replace(/[一-鿿]/g, '').trim().split(/\s+/).filter(w => w.length > 0).length
+ return chinese + english
+ }
+
+ /**
+ * On startup: scan file system and sync with database
+ * - Add new files found on disk but not in DB
+ * - Update modified files
+ * - Remove DB entries for deleted files
+ */
+ async _syncFileSystem() {
+ const filesOnDisk = this.fileStorage.listAllFiles()
+ const filesInDb = this.metaStore.listNotes().map(n => n.file_path)
+ const dbFileSet = new Set(filesInDb)
+
+ // Build a set of existing note IDs to avoid UNIQUE constraint violations
+ const existingIds = new Set(this.metaStore.listNotes().map(n => n.id))
+
+ for (const filePath of filesOnDisk) {
+ if (!dbFileSet.has(filePath)) {
+ // New file found on disk - import it
+ const fileData = this.fileStorage.readFile(filePath)
+ if (fileData) {
+ // Always generate a fresh ID to avoid UNIQUE constraint conflicts
+ // with IDs that may already exist in the database
+ let id = fileData.meta.id
+ if (!id || existingIds.has(id)) {
+ id = nanoid(12)
+ }
+ existingIds.add(id)
+
+ const title = fileData.meta.title || this._titleFromPath(filePath)
+ const tags = fileData.meta.tags || []
+
+ // Determine notebook from path
+ const pathParts = filePath.split('/')
+ const notebookName = pathParts.length > 1 ? pathParts[1] : '默认笔记本'
+ let notebook = this.metaStore._get('SELECT id FROM notebooks WHERE name = ?', [notebookName])
+ if (!notebook) {
+ const nbId = nanoid(12)
+ this.metaStore.createNotebook({ id: nbId, name: notebookName })
+ notebook = { id: nbId }
+ }
+
+ this.metaStore.createNote({
+ id,
+ title,
+ filePath,
+ notebookId: notebook.id,
+ wordCount: this._countWords(fileData.content)
+ })
+
+ if (tags.length > 0) {
+ this._ensureTags(tags)
+ const tagRecords = tags.map(name => {
+ const t = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
+ return t?.id
+ }).filter(Boolean)
+ this.metaStore.setNoteTags(id, tagRecords)
+ }
+ }
+ }
+ }
+
+ // Remove DB entries for files that no longer exist on disk
+ for (const dbFile of filesInDb) {
+ const absolutePath = join(this.workspacePath, dbFile)
+ if (!existsSync(absolutePath)) {
+ const note = this.metaStore.getNoteByPath(dbFile)
+ if (note) {
+ this.metaStore.deleteNote(note.id)
+ }
+ }
+ }
+ }
+
+ /**
+ * Extract a title from file path
+ */
+ _titleFromPath(filePath) {
+ const parts = filePath.split('/')
+ const fileName = parts[parts.length - 1]
+ return fileName.replace(/\.md$/, '')
+ }
+}
diff --git a/src/main/services/utils.js b/src/main/services/utils.js
new file mode 100644
index 0000000..a66f133
--- /dev/null
+++ b/src/main/services/utils.js
@@ -0,0 +1,10 @@
+/**
+ * Debounce a function call
+ */
+export function debounce(fn, delay) {
+ let timer = null
+ return function (...args) {
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(() => fn.apply(this, args), delay)
+ }
+}
diff --git a/src/renderer/src/composables/useNotebookTree.js b/src/renderer/src/composables/useNotebookTree.js
new file mode 100644
index 0000000..ab54e32
--- /dev/null
+++ b/src/renderer/src/composables/useNotebookTree.js
@@ -0,0 +1,154 @@
+import { computed } from 'vue'
+import { useNotebooksStore } from '../stores/notebooks'
+import { useNotesStore } from '../stores/notes'
+
+/**
+ * Composable for building a tree structure of notebooks and notes
+ * for use with PrimeVue Tree component
+ */
+export function useNotebookTree() {
+ const notebooksStore = useNotebooksStore()
+ const notesStore = useNotesStore()
+
+ const notebooks = computed(() => notebooksStore.notebooks)
+ const notes = computed(() => notesStore.notes)
+
+ /**
+ * Build tree structure from flat notebooks and notes lists
+ */
+ const notebookTree = computed(() => {
+ // Create a map for quick lookup
+ const notebookMap = new Map()
+ notebooks.value.forEach(nb => {
+ notebookMap.set(nb.id, {
+ key: `notebook-${nb.id}`,
+ label: nb.name,
+ data: { type: 'notebook', id: nb.id, name: nb.name },
+ icon: 'pi pi-book',
+ children: [],
+ selectable: true
+ })
+ })
+
+ // Build tree structure
+ const rootNodes = []
+ notebooks.value.forEach(nb => {
+ const node = notebookMap.get(nb.id)
+ if (nb.parent_id && notebookMap.has(nb.parent_id)) {
+ // Add as child to parent
+ notebookMap.get(nb.parent_id).children.push(node)
+ } else {
+ // Add as root node
+ rootNodes.push(node)
+ }
+ })
+
+ // Add notes to their respective notebooks
+ notes.value.forEach(note => {
+ if (note.is_trashed) return // Skip trashed notes
+
+ const noteNode = {
+ key: `note-${note.id}`,
+ label: note.title || '无标题',
+ data: { type: 'note', id: note.id, title: note.title, notebook_id: note.notebook_id },
+ icon: 'pi pi-file',
+ selectable: true
+ }
+
+ const parentNode = notebookMap.get(note.notebook_id)
+ if (parentNode) {
+ parentNode.children.push(noteNode)
+ } else {
+ // If notebook not found, add to root (shouldn't happen normally)
+ rootNodes.push(noteNode)
+ }
+ })
+
+ // Sort children: notebooks first, then notes
+ const sortChildren = (nodes) => {
+ nodes.forEach(node => {
+ if (node.children && node.children.length > 0) {
+ node.children.sort((a, b) => {
+ // Notebooks first, then notes
+ if (a.data.type === 'notebook' && b.data.type !== 'notebook') return -1
+ if (a.data.type !== 'notebook' && b.data.type === 'notebook') return 1
+ // Within same type, sort alphabetically
+ return a.label.localeCompare(b.label)
+ })
+ // Recursively sort children
+ sortChildren(node.children)
+ }
+ })
+ }
+
+ sortChildren(rootNodes)
+
+ return rootNodes
+ })
+
+ /**
+ * Flatten tree for searching or other purposes
+ */
+ const flatNotebooks = computed(() => {
+ const result = []
+ const flatten = (nodes) => {
+ nodes.forEach(node => {
+ if (node.data.type === 'notebook') {
+ result.push(node.data)
+ }
+ if (node.children && node.children.length > 0) {
+ flatten(node.children)
+ }
+ })
+ }
+ flatten(notebookTree.value)
+ return result
+ })
+
+ /**
+ * Find a notebook node by ID
+ */
+ function findNotebookNode(notebookId) {
+ const find = (nodes) => {
+ for (const node of nodes) {
+ if (node.data.type === 'notebook' && node.data.id === notebookId) {
+ return node
+ }
+ if (node.children && node.children.length > 0) {
+ const found = find(node.children)
+ if (found) return found
+ }
+ }
+ return null
+ }
+ return find(notebookTree.value)
+ }
+
+ /**
+ * Get all notebook IDs for expansion
+ */
+ const expandedKeys = computed(() => {
+ const keys = {}
+ const collectKeys = (nodes) => {
+ nodes.forEach(node => {
+ if (node.data.type === 'notebook') {
+ keys[node.key] = true
+ }
+ if (node.children && node.children.length > 0) {
+ collectKeys(node.children)
+ }
+ })
+ }
+ collectKeys(notebookTree.value)
+ return keys
+ })
+
+ return {
+ notebookTree,
+ flatNotebooks,
+ findNotebookNode,
+ expandedKeys,
+ loadNotebooks: notebooksStore.loadNotebooks,
+ loadNotes: notesStore.loadNotes
+ }
+}
\ No newline at end of file
diff --git a/src/renderer/src/composables/useNotebookTreeExample.js b/src/renderer/src/composables/useNotebookTreeExample.js
new file mode 100644
index 0000000..1b99afd
--- /dev/null
+++ b/src/renderer/src/composables/useNotebookTreeExample.js
@@ -0,0 +1,245 @@
+/**
+ * Example usage of useNotebookTree composable
+ * This file shows how to use the notebook tree in other components
+ */
+
+import { useNotebookTree } from './useNotebookTree'
+
+/**
+ * Example 1: Basic usage in a component
+ */
+export function useNotebookTreeExample() {
+ const {
+ notebookTree,
+ flatNotebooks,
+ findNotebookNode,
+ expandedKeys,
+ loadNotebooks,
+ loadNotes
+ } = useNotebookTree()
+
+ // Load data on component mount
+ async function initialize() {
+ await Promise.all([
+ loadNotebooks(),
+ loadNotes()
+ ])
+ }
+
+ // Find a specific notebook
+ function findNotebook(notebookId) {
+ return findNotebookNode(notebookId)
+ }
+
+ // Get all notebook IDs for expansion
+ function getAllNotebookKeys() {
+ return Object.keys(expandedKeys.value)
+ }
+
+ // Get flat list of all notebooks
+ function getAllNotebooks() {
+ return flatNotebooks.value
+ }
+
+ return {
+ notebookTree,
+ flatNotebooks,
+ expandedKeys,
+ initialize,
+ findNotebook,
+ getAllNotebookKeys,
+ getAllNotebooks
+ }
+}
+
+/**
+ * Example 2: Custom tree filtering
+ */
+export function useFilteredNotebookTree(filterText) {
+ const { notebookTree, flatNotebooks } = useNotebookTree()
+
+ const filteredTree = computed(() => {
+ if (!filterText.value) {
+ return notebookTree.value
+ }
+
+ const filter = filterText.value.toLowerCase()
+
+ const filterNodes = (nodes) => {
+ return nodes.filter(node => {
+ // Check if node label matches filter
+ const labelMatch = node.label.toLowerCase().includes(filter)
+
+ // Check if any children match
+ const childrenMatch = node.children && filterNodes(node.children).length > 0
+
+ // Include node if label matches or has matching children
+ return labelMatch || childrenMatch
+ }).map(node => {
+ // If node has children, filter them recursively
+ if (node.children) {
+ return {
+ ...node,
+ children: filterNodes(node.children)
+ }
+ }
+ return node
+ })
+ }
+
+ return filterNodes(notebookTree.value)
+ })
+
+ return {
+ filteredTree,
+ flatNotebooks
+ }
+}
+
+/**
+ * Example 3: Tree statistics
+ */
+export function useNotebookTreeStats() {
+ const { notebookTree, flatNotebooks } = useNotebookTree()
+
+ const stats = computed(() => {
+ let notebookCount = 0
+ let noteCount = 0
+
+ const countNodes = (nodes) => {
+ nodes.forEach(node => {
+ if (node.data.type === 'notebook') {
+ notebookCount++
+ } else if (node.data.type === 'note') {
+ noteCount++
+ }
+
+ if (node.children) {
+ countNodes(node.children)
+ }
+ })
+ }
+
+ countNodes(notebookTree.value)
+
+ return {
+ notebooks: notebookCount,
+ notes: noteCount,
+ total: notebookCount + noteCount
+ }
+ })
+
+ return {
+ stats,
+ flatNotebooks
+ }
+}
+
+/**
+ * Example 4: Tree navigation helpers
+ */
+export function useNotebookTreeNavigation() {
+ const { notebookTree, findNotebookNode } = useNotebookTree()
+
+ // Get parent notebook of a note
+ function getParentNotebook(noteId) {
+ const findParent = (nodes) => {
+ for (const node of nodes) {
+ if (node.children) {
+ for (const child of node.children) {
+ if (child.data.type === 'note' && child.data.id === noteId) {
+ return node.data
+ }
+ }
+ const found = findParent(node.children)
+ if (found) return found
+ }
+ }
+ return null
+ }
+
+ return findParent(notebookTree.value)
+ }
+
+ // Get all notes in a notebook
+ function getNotesInNotebook(notebookId) {
+ const node = findNotebookNode(notebookId)
+ if (!node || !node.children) return []
+
+ return node.children
+ .filter(child => child.data.type === 'note')
+ .map(child => child.data)
+ }
+
+ // Get all child notebooks
+ function getChildNotebooks(notebookId) {
+ const node = findNotebookNode(notebookId)
+ if (!node || !node.children) return []
+
+ return node.children
+ .filter(child => child.data.type === 'notebook')
+ .map(child => child.data)
+ }
+
+ // Get notebook path (breadcrumb)
+ function getNotebookPath(notebookId) {
+ const path = []
+
+ const findPath = (nodes, targetId) => {
+ for (const node of nodes) {
+ if (node.data.type === 'notebook' && node.data.id === targetId) {
+ path.push(node.data)
+ return true
+ }
+
+ if (node.children && findPath(node.children, targetId)) {
+ if (node.data.type === 'notebook') {
+ path.push(node.data)
+ }
+ return true
+ }
+ }
+ return false
+ }
+
+ findPath(notebookTree.value, notebookId)
+ return path.reverse()
+ }
+
+ return {
+ getParentNotebook,
+ getNotesInNotebook,
+ getChildNotebooks,
+ getNotebookPath
+ }
+}
+
+/**
+ * Example component usage:
+ *
+ *
+ *
+ *
+ *
+ *
+ */
\ No newline at end of file
diff --git a/src/renderer/src/composables/useNotebooks.js b/src/renderer/src/composables/useNotebooks.js
new file mode 100644
index 0000000..b4cbf20
--- /dev/null
+++ b/src/renderer/src/composables/useNotebooks.js
@@ -0,0 +1,72 @@
+import { ref } from 'vue'
+
+const notebooks = ref([])
+const loading = ref(false)
+
+/**
+ * Composable for notebook management
+ * Wraps IPC calls to the main process StorageService
+ */
+export function useNotebooks() {
+
+ async function loadNotebooks() {
+ loading.value = true
+ try {
+ notebooks.value = await window.electronAPI.notebooks.list()
+ } catch (err) {
+ console.error('Failed to load notebooks:', err)
+ notebooks.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function createNotebook(name) {
+ try {
+ const notebook = await window.electronAPI.notebooks.create({ name })
+ if (notebook) {
+ notebooks.value.push(notebook)
+ }
+ return notebook
+ } catch (err) {
+ console.error('Failed to create notebook:', err)
+ return null
+ }
+ }
+
+ async function renameNotebook(id, name) {
+ try {
+ const notebook = await window.electronAPI.notebooks.rename(id, name)
+ if (notebook) {
+ const idx = notebooks.value.findIndex(nb => nb.id === id)
+ if (idx !== -1) {
+ notebooks.value[idx] = notebook
+ }
+ }
+ return notebook
+ } catch (err) {
+ console.error('Failed to rename notebook:', err)
+ return null
+ }
+ }
+
+ async function deleteNotebook(id) {
+ try {
+ await window.electronAPI.notebooks.delete(id)
+ notebooks.value = notebooks.value.filter(nb => nb.id !== id)
+ return true
+ } catch (err) {
+ console.error('Failed to delete notebook:', err)
+ return false
+ }
+ }
+
+ return {
+ notebooks,
+ loading,
+ loadNotebooks,
+ createNotebook,
+ renameNotebook,
+ deleteNotebook
+ }
+}
diff --git a/src/renderer/src/composables/useNotes.js b/src/renderer/src/composables/useNotes.js
new file mode 100644
index 0000000..74bb730
--- /dev/null
+++ b/src/renderer/src/composables/useNotes.js
@@ -0,0 +1,137 @@
+import { ref } from 'vue'
+
+const notes = ref([])
+const currentNote = ref(null)
+const loading = ref(false)
+
+/**
+ * Composable for note CRUD operations
+ * Wraps IPC calls to the main process StorageService
+ */
+export function useNotes() {
+
+ async function loadNotes(filter = {}) {
+ loading.value = true
+ try {
+ notes.value = await window.electronAPI.notes.list(filter)
+ } catch (err) {
+ console.error('Failed to load notes:', err)
+ notes.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function createNote(data = {}) {
+ try {
+ const note = await window.electronAPI.notes.create(data)
+ if (note) {
+ notes.value.unshift(note)
+ }
+ return note
+ } catch (err) {
+ console.error('Failed to create note:', err)
+ return null
+ }
+ }
+
+ async function getNote(id) {
+ try {
+ const note = await window.electronAPI.notes.get(id)
+ currentNote.value = note
+ return note
+ } catch (err) {
+ console.error('Failed to get note:', err)
+ return null
+ }
+ }
+
+ async function updateNote(id, data) {
+ try {
+ const note = await window.electronAPI.notes.update(id, data)
+ if (note) {
+ // Update in local list
+ const idx = notes.value.findIndex(n => n.id === id)
+ if (idx !== -1) {
+ notes.value[idx] = { ...notes.value[idx], ...note }
+ }
+ // Update current note if it's the one being edited
+ if (currentNote.value?.id === id) {
+ currentNote.value = note
+ }
+ }
+ return note
+ } catch (err) {
+ console.error('Failed to update note:', err)
+ return null
+ }
+ }
+
+ async function deleteNote(id, permanent = false) {
+ try {
+ await window.electronAPI.notes.delete(id, permanent)
+ if (permanent) {
+ notes.value = notes.value.filter(n => n.id !== id)
+ } else {
+ // Move to trash - update local state
+ const idx = notes.value.findIndex(n => n.id === id)
+ if (idx !== -1) {
+ notes.value[idx].is_trashed = 1
+ }
+ }
+ if (currentNote.value?.id === id) {
+ currentNote.value = null
+ }
+ return true
+ } catch (err) {
+ console.error('Failed to delete note:', err)
+ return false
+ }
+ }
+
+ async function restoreNote(id) {
+ try {
+ await window.electronAPI.notes.restore(id)
+ const idx = notes.value.findIndex(n => n.id === id)
+ if (idx !== -1) {
+ notes.value[idx].is_trashed = 0
+ }
+ return true
+ } catch (err) {
+ console.error('Failed to restore note:', err)
+ return false
+ }
+ }
+
+ async function searchNotes(query) {
+ try {
+ return await window.electronAPI.notes.search(query)
+ } catch (err) {
+ console.error('Failed to search notes:', err)
+ return []
+ }
+ }
+
+ function setCurrentNote(note) {
+ currentNote.value = note
+ }
+
+ function clearCurrentNote() {
+ currentNote.value = null
+ }
+
+ return {
+ notes,
+ currentNote,
+ loading,
+ loadNotes,
+ createNote,
+ getNote,
+ updateNote,
+ deleteNote,
+ restoreNote,
+ searchNotes,
+ setCurrentNote,
+ clearCurrentNote
+ }
+}
diff --git a/src/renderer/src/composables/useSidebarTree.js b/src/renderer/src/composables/useSidebarTree.js
new file mode 100644
index 0000000..4d4e1a7
--- /dev/null
+++ b/src/renderer/src/composables/useSidebarTree.js
@@ -0,0 +1,258 @@
+import { computed, ref, watch } from 'vue'
+import { useNotebooksStore } from '../stores/notebooks'
+import { useNotesStore } from '../stores/notes'
+
+/**
+ * Composable for building a unified tree structure
+ * combining notes navigation, notebooks, and tags
+ * for use with PrimeVue Tree component
+ */
+export function useSidebarTree(tagsRef) {
+ const notebooksStore = useNotebooksStore()
+ const notesStore = useNotesStore()
+
+ const notebooks = computed(() => notebooksStore.notebooks)
+ const notes = computed(() => notesStore.notes)
+ const tags = computed(() => tagsRef?.value || [])
+
+ /**
+ * Build notes navigation nodes (static items)
+ */
+ function buildNotesNavNodes() {
+ return [
+ {
+ key: 'nav-all',
+ label: '所有笔记',
+ data: { type: 'nav', id: 'all' },
+ selectable: true,
+ leaf: true
+ },
+ {
+ key: 'nav-favorites',
+ label: '收藏夹',
+ data: { type: 'nav', id: 'favorites' },
+ selectable: true,
+ leaf: true
+ },
+ {
+ key: 'nav-trash',
+ label: '回收站',
+ data: { type: 'nav', id: 'trash' },
+ selectable: true,
+ leaf: true
+ }
+ ]
+ }
+
+ /**
+ * Build notebook tree nodes with nested notes
+ */
+ function buildNotebookNodes() {
+ // Create a map for quick lookup
+ const notebookMap = new Map()
+ notebooks.value.forEach(nb => {
+ notebookMap.set(nb.id, {
+ key: `notebook-${nb.id}`,
+ label: nb.name,
+ data: { type: 'notebook', id: nb.id, name: nb.name },
+ children: [],
+ selectable: true
+ })
+ })
+
+ // Build tree structure
+ const rootNodes = []
+ notebooks.value.forEach(nb => {
+ const node = notebookMap.get(nb.id)
+ if (nb.parent_id && notebookMap.has(nb.parent_id)) {
+ // Add as child to parent
+ notebookMap.get(nb.parent_id).children.push(node)
+ } else {
+ // Add as root node
+ rootNodes.push(node)
+ }
+ })
+
+ // Add notes to their respective notebooks
+ notes.value.forEach(note => {
+ if (note.is_trashed) return // Skip trashed notes
+
+ const noteNode = {
+ key: `note-${note.id}`,
+ label: note.title || '无标题',
+ data: { type: 'note', id: note.id, title: note.title, notebook_id: note.notebook_id },
+ selectable: true
+ }
+
+ const parentNode = notebookMap.get(note.notebook_id)
+ if (parentNode) {
+ parentNode.children.push(noteNode)
+ } else {
+ // If notebook not found, add to root (shouldn't happen normally)
+ rootNodes.push(noteNode)
+ }
+ })
+
+ // Sort children: notebooks first, then notes
+ const sortChildren = (nodes) => {
+ nodes.forEach(node => {
+ if (node.children && node.children.length > 0) {
+ node.children.sort((a, b) => {
+ // Notebooks first, then notes
+ if (a.data.type === 'notebook' && b.data.type !== 'notebook') return -1
+ if (a.data.type !== 'notebook' && b.data.type === 'notebook') return 1
+ // Within same type, sort alphabetically
+ return a.label.localeCompare(b.label)
+ })
+ // Recursively sort children
+ sortChildren(node.children)
+ }
+ })
+ }
+
+ sortChildren(rootNodes)
+
+ return rootNodes
+ }
+
+ /**
+ * Build tag nodes
+ */
+ function buildTagNodes() {
+ return tags.value.map(tag => ({
+ key: `tag-${tag.id}`,
+ label: tag.name,
+ data: { type: 'tag', id: tag.id, name: tag.name, note_count: tag.note_count || 0 },
+ selectable: true,
+ leaf: true
+ }))
+ }
+
+ /**
+ * Build complete unified tree structure
+ */
+ const sidebarTree = computed(() => {
+ return [
+ {
+ key: 'group-notes',
+ label: '笔记',
+ data: { type: 'group', id: 'notes' },
+ selectable: false,
+ children: buildNotesNavNodes()
+ },
+ {
+ key: 'group-notebooks',
+ label: '笔记本',
+ data: { type: 'group', id: 'notebooks' },
+ selectable: false,
+ children: buildNotebookNodes()
+ },
+ {
+ key: 'group-tags',
+ label: '标签',
+ data: { type: 'group', id: 'tags' },
+ selectable: false,
+ children: buildTagNodes()
+ }
+ ]
+ })
+
+ /**
+ * Flatten notebooks for searching or other purposes
+ */
+ const flatNotebooks = computed(() => {
+ const result = []
+ const flatten = (nodes) => {
+ nodes.forEach(node => {
+ if (node.data.type === 'notebook') {
+ result.push(node.data)
+ }
+ if (node.children && node.children.length > 0) {
+ flatten(node.children)
+ }
+ })
+ }
+ // Only flatten the notebooks group
+ const notebooksGroup = sidebarTree.value.find(g => g.data.id === 'notebooks')
+ if (notebooksGroup) {
+ flatten(notebooksGroup.children)
+ }
+ return result
+ })
+
+ /**
+ * Find a notebook node by ID
+ */
+ function findNotebookNode(notebookId) {
+ const find = (nodes) => {
+ for (const node of nodes) {
+ if (node.data.type === 'notebook' && node.data.id === notebookId) {
+ return node
+ }
+ if (node.children && node.children.length > 0) {
+ const found = find(node.children)
+ if (found) return found
+ }
+ }
+ return null
+ }
+ const notebooksGroup = sidebarTree.value.find(g => g.data.id === 'notebooks')
+ return notebooksGroup ? find(notebooksGroup.children) : null
+ }
+
+ /**
+ * Get all expandable node keys for expansion state
+ */
+ const expandedKeys = ref({})
+
+ // Initialize expanded keys when sidebarTree changes
+ watch(sidebarTree, (newTree) => {
+ const keys = {}
+
+ // Expand all group nodes by default
+ keys['group-notes'] = true
+ keys['group-notebooks'] = true
+ keys['group-tags'] = true
+
+ // Expand all notebook nodes by default
+ const collectNotebookKeys = (nodes) => {
+ nodes.forEach(node => {
+ if (node.data.type === 'notebook') {
+ keys[node.key] = true
+ }
+ if (node.children && node.children.length > 0) {
+ collectNotebookKeys(node.children)
+ }
+ })
+ }
+ const notebooksGroup = newTree.find(g => g.data.id === 'notebooks')
+ if (notebooksGroup) {
+ collectNotebookKeys(notebooksGroup.children)
+ }
+
+ expandedKeys.value = keys
+ }, { immediate: true })
+
+ /**
+ * Toggle expanded state of a node
+ */
+ function toggleNodeExpanded(nodeKey) {
+ const newKeys = { ...expandedKeys.value }
+ if (newKeys[nodeKey]) {
+ delete newKeys[nodeKey]
+ } else {
+ newKeys[nodeKey] = true
+ }
+ expandedKeys.value = newKeys
+ }
+
+ return {
+ sidebarTree,
+ flatNotebooks,
+ findNotebookNode,
+ expandedKeys,
+ toggleNodeExpanded,
+ loadNotebooks: notebooksStore.loadNotebooks,
+ loadNotes: notesStore.loadNotes
+ }
+}
diff --git a/src/renderer/src/composables/useTags.js b/src/renderer/src/composables/useTags.js
new file mode 100644
index 0000000..bcaf4be
--- /dev/null
+++ b/src/renderer/src/composables/useTags.js
@@ -0,0 +1,75 @@
+import { ref } from 'vue'
+
+const tags = ref([])
+const loading = ref(false)
+
+/**
+ * Composable for tag management
+ * Wraps IPC calls to the main process StorageService
+ */
+export function useTags() {
+
+ async function loadTags() {
+ loading.value = true
+ try {
+ tags.value = await window.electronAPI.tags.list()
+ } catch (err) {
+ console.error('Failed to load tags:', err)
+ tags.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function createTag(name) {
+ try {
+ const tag = await window.electronAPI.tags.create(name)
+ if (tag && !tags.value.find(t => t.id === tag.id)) {
+ tags.value.push(tag)
+ }
+ return tag
+ } catch (err) {
+ console.error('Failed to create tag:', err)
+ return null
+ }
+ }
+
+ async function deleteTag(id) {
+ try {
+ await window.electronAPI.tags.delete(id)
+ tags.value = tags.value.filter(t => t.id !== id)
+ return true
+ } catch (err) {
+ console.error('Failed to delete tag:', err)
+ return false
+ }
+ }
+
+ async function addTagToNote(noteId, tagName) {
+ try {
+ return await window.electronAPI.tags.addToNote(noteId, tagName)
+ } catch (err) {
+ console.error('Failed to add tag to note:', err)
+ return false
+ }
+ }
+
+ async function removeTagFromNote(noteId, tagName) {
+ try {
+ return await window.electronAPI.tags.removeFromNote(noteId, tagName)
+ } catch (err) {
+ console.error('Failed to remove tag from note:', err)
+ return false
+ }
+ }
+
+ return {
+ tags,
+ loading,
+ loadTags,
+ createTag,
+ deleteTag,
+ addTagToNote,
+ removeTagFromNote
+ }
+}
diff --git a/src/renderer/src/composables/useWorkspace.js b/src/renderer/src/composables/useWorkspace.js
new file mode 100644
index 0000000..589fb32
--- /dev/null
+++ b/src/renderer/src/composables/useWorkspace.js
@@ -0,0 +1,91 @@
+import { ref } from 'vue'
+
+/**
+ * Workspace 管理 composable
+ * 处理首次使用引导和存储文件夹选择
+ */
+
+// 全局状态(所有组件共享)
+const isReady = ref(false)
+const workspacePath = ref(null)
+const loading = ref(false)
+let listenerRegistered = false
+
+/**
+ * 检查 workspace 状态(通过 IPC 查询主进程)
+ */
+async function checkStatus() {
+ try {
+ const status = await window.electronAPI.workspace.getStatus()
+ isReady.value = status.ready
+ workspacePath.value = status.path
+ } catch (err) {
+ console.error('Failed to check workspace status:', err)
+ }
+}
+
+/**
+ * 打开系统文件夹选择对话框
+ * @returns {object|null} { canceled, path } 或 null
+ */
+async function selectFolder() {
+ try {
+ return await window.electronAPI.workspace.selectFolder()
+ } catch (err) {
+ console.error('Failed to select folder:', err)
+ return null
+ }
+}
+
+/**
+ * 设置 workspace 路径并初始化存储服务
+ * @param {string} path - 要设置的文件夹路径
+ * @returns {boolean} 是否成功
+ */
+async function initWorkspace(path) {
+ loading.value = true
+ try {
+ const result = await window.electronAPI.workspace.setPath(path)
+ if (result && result.success) {
+ isReady.value = true
+ workspacePath.value = result.path
+ return true
+ }
+ console.error('Failed to init workspace:', result?.error || 'unknown error')
+ return false
+ } catch (err) {
+ console.error('Failed to init workspace:', err)
+ return false
+ } finally {
+ loading.value = false
+ }
+}
+
+/**
+ * 监听主进程推送的 workspace 状态变更
+ */
+function listenStatusChanges() {
+ if (listenerRegistered) return
+ listenerRegistered = true
+
+ window.electronAPI.workspace.onStatusChange((status) => {
+ isReady.value = status.ready
+ workspacePath.value = status.path
+ })
+}
+
+export function useWorkspace() {
+ // 注册状态变更监听
+ listenStatusChanges()
+ // 主动查询一次当前状态(防止 did-finish-load 事件在监听注册前触发)
+ checkStatus()
+
+ return {
+ isReady,
+ workspacePath,
+ loading,
+ checkStatus,
+ selectFolder,
+ initWorkspace
+ }
+}
diff --git a/src/renderer/src/stores/README.md b/src/renderer/src/stores/README.md
new file mode 100644
index 0000000..807a92e
--- /dev/null
+++ b/src/renderer/src/stores/README.md
@@ -0,0 +1,174 @@
+# Pinia Stores 使用指南
+
+本项目使用 Pinia 作为全局状态管理解决方案,替代了之前的 composables 模式。
+
+## 可用的 Stores
+
+### 1. useNotesStore - 笔记管理
+```javascript
+import { useNotesStore } from '@/stores/notes'
+
+const notesStore = useNotesStore()
+
+// 状态
+notesStore.notes // 所有笔记列表
+notesStore.currentNote // 当前选中的笔记
+notesStore.loading // 加载状态
+
+// Getters
+notesStore.activeNotes // 未删除的笔记
+notesStore.trashedNotes // 已删除的笔记
+notesStore.hasCurrentNote // 是否有选中的笔记
+
+// 方法
+await notesStore.loadNotes({ isTrashed: 0 }) // 加载笔记
+await notesStore.createNote({ title, content, notebookId }) // 创建笔记
+await notesStore.getNote(id) // 获取笔记详情
+await notesStore.updateNote(id, { title, content }) // 更新笔记
+await notesStore.deleteNote(id, permanent) // 删除笔记
+await notesStore.restoreNote(id) // 恢复已删除笔记
+await notesStore.searchNotes(query) // 搜索笔记
+notesStore.setCurrentNote(note) // 设置当前笔记
+notesStore.clearCurrentNote() // 清空当前笔记
+```
+
+### 2. useNotebooksStore - 笔记本管理
+```javascript
+import { useNotebooksStore } from '@/stores/notebooks'
+
+const notebooksStore = useNotebooksStore()
+
+// 状态
+notebooksStore.notebooks // 所有笔记本列表
+notebooksStore.loading // 加载状态
+
+// Getters
+notebooksStore.notebookCount // 笔记本数量
+notebooksStore.defaultNotebook // 默认笔记本
+
+// 方法
+await notebooksStore.loadNotebooks() // 加载笔记本
+await notebooksStore.createNotebook(name) // 创建笔记本
+await notebooksStore.renameNotebook(id, name) // 重命名笔记本
+await notebooksStore.deleteNotebook(id) // 删除笔记本
+notebooksStore.getNotebookById(id) // 根据 ID 获取笔记本
+```
+
+### 3. useTagsStore - 标签管理
+```javascript
+import { useTagsStore } from '@/stores/tags'
+
+const tagsStore = useTagsStore()
+
+// 状态
+tagsStore.tags // 所有标签列表
+tagsStore.loading // 加载状态
+
+// Getters
+tagsStore.tagCount // 标签数量
+tagsStore.tagNames // 所有标签名称数组
+
+// 方法
+await tagsStore.loadTags() // 加载标签
+await tagsStore.createTag(name) // 创建标签
+await tagsStore.deleteTag(id) // 删除标签
+await tagsStore.addTagToNote(noteId, tagName) // 为笔记添加标签
+await tagsStore.removeTagFromNote(noteId, tagName) // 从笔记移除标签
+tagsStore.getTagById(id) // 根据 ID 获取标签
+tagsStore.getTagByName(name) // 根据名称获取标签
+```
+
+### 4. useWorkspaceStore - 工作区管理
+```javascript
+import { useWorkspaceStore } from '@/stores/workspace'
+
+const workspaceStore = useWorkspaceStore()
+
+// 状态
+workspaceStore.isReady // 工作区是否就绪
+workspaceStore.workspacePath // 工作区路径
+workspaceStore.loading // 加载状态
+
+// Getters
+workspaceStore.hasWorkspace // 是否有工作区
+
+// 方法
+workspaceStore.initialize() // 初始化(自动调用)
+await workspaceStore.checkStatus() // 检查状态
+await workspaceStore.selectFolder() // 选择文件夹
+await workspaceStore.initWorkspace(path) // 初始化工作区
+```
+
+### 5. useThemeStore - 主题管理
+```javascript
+import { useThemeStore } from '@/stores/theme'
+
+const themeStore = useThemeStore()
+
+// 状态
+themeStore.theme // 当前主题 ('light' | 'dark' | 'system')
+
+// Getters
+themeStore.resolvedTheme // 解析后的主题 ('light' | 'dark')
+themeStore.isDark // 是否为暗色模式
+themeStore.themeLabel // 主题显示名称
+
+// 方法
+themeStore.initialize() // 初始化(需要在应用启动时调用)
+themeStore.toggleTheme() // 切换主题
+themeStore.setTheme('dark') // 设置指定主题
+themeStore.cleanup() // 清理监听器
+```
+
+## 在组件中使用
+
+### 选项式 API
+```javascript
+import { useNotesStore } from '@/stores/notes'
+import { mapState, mapActions } from 'pinia'
+
+export default {
+ computed: {
+ ...mapState(useNotesStore, ['notes', 'currentNote', 'loading'])
+ },
+ methods: {
+ ...mapActions(useNotesStore, ['loadNotes', 'createNote'])
+ }
+}
+```
+
+### 组合式 API (推荐)
+```javascript
+
+```
+
+## 最佳实践
+
+1. **单一数据源**: 每个 store 管理一个特定领域的状态
+2. **响应式**: 使用 `computed` 包装 store 的状态以保持响应性
+3. **异步操作**: 所有 API 调用都应在 actions 中进行
+4. **错误处理**: 在 actions 中处理错误并记录日志
+5. **初始化**: 某些 store 需要调用 `initialize()` 方法(如 themeStore、workspaceStore)
+
+## 从 Composables 迁移
+
+旧的 composables 仍然存在但已不推荐使用。迁移步骤:
+
+1. 将 `import { useXxx } from '@/composables/xxx'` 改为 `import { useXxxStore } from '@/stores/xxx'`
+2. 将 `const { data, method } = useXxx()` 改为 `const store = useXxxStore()`
+3. 使用 `computed(() => store.data)` 保持响应性
+4. 直接调用 `store.method()` 替代解构的方法
\ No newline at end of file
diff --git a/src/renderer/src/stores/index.js b/src/renderer/src/stores/index.js
new file mode 100644
index 0000000..7c3754c
--- /dev/null
+++ b/src/renderer/src/stores/index.js
@@ -0,0 +1,6 @@
+// Export all stores
+export { useNotesStore } from './notes'
+export { useNotebooksStore } from './notebooks'
+export { useTagsStore } from './tags'
+export { useWorkspaceStore } from './workspace'
+export { useThemeStore } from './theme'
\ No newline at end of file
diff --git a/src/renderer/src/stores/notebooks.js b/src/renderer/src/stores/notebooks.js
new file mode 100644
index 0000000..7ec9edc
--- /dev/null
+++ b/src/renderer/src/stores/notebooks.js
@@ -0,0 +1,87 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+export const useNotebooksStore = defineStore('notebooks', () => {
+ // State
+ const notebooks = ref([])
+ const loading = ref(false)
+
+ // Getters
+ const notebookCount = computed(() => notebooks.value.length)
+ const defaultNotebook = computed(() => notebooks.value.find(nb => nb.id === 'default'))
+
+ // Actions
+ async function loadNotebooks() {
+ loading.value = true
+ try {
+ notebooks.value = await window.electronAPI.notebooks.list()
+ } catch (err) {
+ console.error('Failed to load notebooks:', err)
+ notebooks.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function createNotebook(data) {
+ try {
+ const params = typeof data === 'string' ? { name: data } : data
+ const notebook = await window.electronAPI.notebooks.create(params)
+ if (notebook) {
+ notebooks.value.push(notebook)
+ }
+ return notebook
+ } catch (err) {
+ console.error('Failed to create notebook:', err)
+ return null
+ }
+ }
+
+ async function renameNotebook(id, name) {
+ try {
+ const notebook = await window.electronAPI.notebooks.rename(id, name)
+ if (notebook) {
+ const idx = notebooks.value.findIndex(nb => nb.id === id)
+ if (idx !== -1) {
+ notebooks.value[idx] = notebook
+ }
+ }
+ return notebook
+ } catch (err) {
+ console.error('Failed to rename notebook:', err)
+ return null
+ }
+ }
+
+ async function deleteNotebook(id) {
+ try {
+ await window.electronAPI.notebooks.delete(id)
+ notebooks.value = notebooks.value.filter(nb => nb.id !== id)
+ return true
+ } catch (err) {
+ console.error('Failed to delete notebook:', err)
+ return false
+ }
+ }
+
+ function getNotebookById(id) {
+ return notebooks.value.find(nb => nb.id === id)
+ }
+
+ return {
+ // State
+ notebooks,
+ loading,
+
+ // Getters
+ notebookCount,
+ defaultNotebook,
+
+ // Actions
+ loadNotebooks,
+ createNotebook,
+ renameNotebook,
+ deleteNotebook,
+ getNotebookById
+ }
+})
\ No newline at end of file
diff --git a/src/renderer/src/stores/notes.js b/src/renderer/src/stores/notes.js
new file mode 100644
index 0000000..554da94
--- /dev/null
+++ b/src/renderer/src/stores/notes.js
@@ -0,0 +1,173 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+export const useNotesStore = defineStore('notes', () => {
+ // State
+ const notes = ref([])
+ const currentNote = ref(null)
+ const loading = ref(false)
+
+ // Getters
+ const activeNotes = computed(() => notes.value.filter(n => !n.is_trashed))
+ const trashedNotes = computed(() => notes.value.filter(n => n.is_trashed))
+ const favoritedNotes = computed(() => notes.value.filter(n => n.is_favorited && !n.is_trashed))
+ const hasCurrentNote = computed(() => currentNote.value !== null)
+
+ // Actions
+ async function loadNotes(filter = {}) {
+ loading.value = true
+ try {
+ notes.value = await window.electronAPI.notes.list(filter)
+ } catch (err) {
+ console.error('Failed to load notes:', err)
+ notes.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function createNote(data = {}) {
+ try {
+ const note = await window.electronAPI.notes.create(data)
+ if (note) {
+ notes.value.unshift(note)
+ }
+ return note
+ } catch (err) {
+ console.error('Failed to create note:', err)
+ return null
+ }
+ }
+
+ async function getNote(id) {
+ try {
+ const note = await window.electronAPI.notes.get(id)
+ currentNote.value = note
+ return note
+ } catch (err) {
+ console.error('Failed to get note:', err)
+ return null
+ }
+ }
+
+ async function updateNote(id, data) {
+ try {
+ const note = await window.electronAPI.notes.update(id, data)
+ if (note) {
+ // Update in local list
+ const idx = notes.value.findIndex(n => n.id === id)
+ if (idx !== -1) {
+ notes.value[idx] = { ...notes.value[idx], ...note }
+ }
+ // Update current note if it's the one being edited
+ if (currentNote.value?.id === id) {
+ currentNote.value = note
+ }
+ }
+ return note
+ } catch (err) {
+ console.error('Failed to update note:', err)
+ return null
+ }
+ }
+
+ async function deleteNote(id, permanent = false) {
+ try {
+ await window.electronAPI.notes.delete(id, permanent)
+ if (permanent) {
+ notes.value = notes.value.filter(n => n.id !== id)
+ } else {
+ // Move to trash - update local state
+ const idx = notes.value.findIndex(n => n.id === id)
+ if (idx !== -1) {
+ notes.value[idx].is_trashed = 1
+ }
+ }
+ if (currentNote.value?.id === id) {
+ currentNote.value = null
+ }
+ return true
+ } catch (err) {
+ console.error('Failed to delete note:', err)
+ return false
+ }
+ }
+
+ async function restoreNote(id) {
+ try {
+ await window.electronAPI.notes.restore(id)
+ const idx = notes.value.findIndex(n => n.id === id)
+ if (idx !== -1) {
+ notes.value[idx].is_trashed = 0
+ }
+ return true
+ } catch (err) {
+ console.error('Failed to restore note:', err)
+ return false
+ }
+ }
+
+ async function searchNotes(query) {
+ try {
+ return await window.electronAPI.notes.search(query)
+ } catch (err) {
+ console.error('Failed to search notes:', err)
+ return []
+ }
+ }
+
+ async function toggleFavorite(id) {
+ try {
+ const note = notes.value.find(n => n.id === id)
+ if (!note) return null
+ const newValue = note.is_favorited ? 0 : 1
+ const updated = await window.electronAPI.notes.update(id, { isFavorited: newValue })
+ if (updated) {
+ const idx = notes.value.findIndex(n => n.id === id)
+ if (idx !== -1) {
+ notes.value[idx] = { ...notes.value[idx], ...updated }
+ }
+ if (currentNote.value?.id === id) {
+ currentNote.value = updated
+ }
+ }
+ return updated
+ } catch (err) {
+ console.error('Failed to toggle favorite:', err)
+ return null
+ }
+ }
+
+ function setCurrentNote(note) {
+ currentNote.value = note
+ }
+
+ function clearCurrentNote() {
+ currentNote.value = null
+ }
+
+ return {
+ // State
+ notes,
+ currentNote,
+ loading,
+
+ // Getters
+ activeNotes,
+ trashedNotes,
+ favoritedNotes,
+ hasCurrentNote,
+
+ // Actions
+ loadNotes,
+ createNote,
+ getNote,
+ updateNote,
+ deleteNote,
+ restoreNote,
+ searchNotes,
+ toggleFavorite,
+ setCurrentNote,
+ clearCurrentNote
+ }
+})
\ No newline at end of file
diff --git a/src/renderer/src/stores/tags.js b/src/renderer/src/stores/tags.js
new file mode 100644
index 0000000..e0ba966
--- /dev/null
+++ b/src/renderer/src/stores/tags.js
@@ -0,0 +1,94 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+export const useTagsStore = defineStore('tags', () => {
+ // State
+ const tags = ref([])
+ const loading = ref(false)
+
+ // Getters
+ const tagCount = computed(() => tags.value.length)
+ const tagNames = computed(() => tags.value.map(t => t.name))
+
+ // Actions
+ async function loadTags() {
+ loading.value = true
+ try {
+ tags.value = await window.electronAPI.tags.list()
+ } catch (err) {
+ console.error('Failed to load tags:', err)
+ tags.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function createTag(name) {
+ try {
+ const tag = await window.electronAPI.tags.create(name)
+ if (tag && !tags.value.find(t => t.id === tag.id)) {
+ tags.value.push(tag)
+ }
+ return tag
+ } catch (err) {
+ console.error('Failed to create tag:', err)
+ return null
+ }
+ }
+
+ async function deleteTag(id) {
+ try {
+ await window.electronAPI.tags.delete(id)
+ tags.value = tags.value.filter(t => t.id !== id)
+ return true
+ } catch (err) {
+ console.error('Failed to delete tag:', err)
+ return false
+ }
+ }
+
+ async function addTagToNote(noteId, tagName) {
+ try {
+ return await window.electronAPI.tags.addToNote(noteId, tagName)
+ } catch (err) {
+ console.error('Failed to add tag to note:', err)
+ return false
+ }
+ }
+
+ async function removeTagFromNote(noteId, tagName) {
+ try {
+ return await window.electronAPI.tags.removeFromNote(noteId, tagName)
+ } catch (err) {
+ console.error('Failed to remove tag from note:', err)
+ return false
+ }
+ }
+
+ function getTagById(id) {
+ return tags.value.find(t => t.id === id)
+ }
+
+ function getTagByName(name) {
+ return tags.value.find(t => t.name === name)
+ }
+
+ return {
+ // State
+ tags,
+ loading,
+
+ // Getters
+ tagCount,
+ tagNames,
+
+ // Actions
+ loadTags,
+ createTag,
+ deleteTag,
+ addTagToNote,
+ removeTagFromNote,
+ getTagById,
+ getTagByName
+ }
+})
\ No newline at end of file
diff --git a/src/renderer/src/stores/theme.js b/src/renderer/src/stores/theme.js
new file mode 100644
index 0000000..61e5257
--- /dev/null
+++ b/src/renderer/src/stores/theme.js
@@ -0,0 +1,108 @@
+import { defineStore } from 'pinia'
+import { ref, computed, watch } from 'vue'
+
+const STORAGE_KEY = 'tunji-theme'
+
+export const useThemeStore = defineStore('theme', () => {
+ // State
+ const theme = ref('system')
+ let mediaQuery = null
+ let mediaHandler = null
+
+ // Getters
+ const resolvedTheme = computed(() => {
+ if (theme.value === 'system') {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+ }
+ return theme.value
+ })
+
+ const isDark = computed(() => resolvedTheme.value === 'dark')
+
+ const themeLabel = computed(() => {
+ const labels = {
+ light: '浅色',
+ dark: '深色',
+ system: '跟随系统'
+ }
+ return labels[theme.value] || '跟随系统'
+ })
+
+ // Actions
+ function applyTheme(dark) {
+ const html = document.documentElement
+ if (dark) {
+ html.classList.add('dark')
+ } else {
+ html.classList.remove('dark')
+ }
+
+ // 通知 Electron 主进程更新标题栏颜色
+ if (window.electronAPI?.setTheme) {
+ window.electronAPI.setTheme(dark ? 'dark' : 'light')
+ }
+ }
+
+ function toggleTheme() {
+ const order = ['light', 'dark', 'system']
+ const idx = order.indexOf(theme.value)
+ theme.value = order[(idx + 1) % order.length]
+ }
+
+ function setTheme(t) {
+ theme.value = t
+ }
+
+ function initialize() {
+ // 从 localStorage 恢复主题
+ const saved = localStorage.getItem(STORAGE_KEY)
+ if (saved && ['light', 'dark', 'system'].includes(saved)) {
+ theme.value = saved
+ }
+
+ // 应用当前主题
+ applyTheme(isDark.value)
+
+ // 监听系统主题变化
+ mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ mediaHandler = (e) => {
+ if (theme.value === 'system') {
+ applyTheme(e.matches)
+ }
+ }
+ mediaQuery.addEventListener('change', mediaHandler)
+
+ // 监听 theme 变化并持久化 + 应用
+ watch(theme, (newTheme) => {
+ localStorage.setItem(STORAGE_KEY, newTheme)
+ applyTheme(isDark.value)
+ })
+
+ // 监听 resolvedTheme 变化(system 模式下系统偏好改变时触发)
+ watch(resolvedTheme, (dark) => {
+ applyTheme(dark === 'dark')
+ })
+ }
+
+ function cleanup() {
+ if (mediaQuery && mediaHandler) {
+ mediaQuery.removeEventListener('change', mediaHandler)
+ }
+ }
+
+ return {
+ // State
+ theme,
+
+ // Getters
+ resolvedTheme,
+ isDark,
+ themeLabel,
+
+ // Actions
+ toggleTheme,
+ setTheme,
+ initialize,
+ cleanup
+ }
+})
\ No newline at end of file
diff --git a/src/renderer/src/stores/workspace.js b/src/renderer/src/stores/workspace.js
new file mode 100644
index 0000000..f2751cb
--- /dev/null
+++ b/src/renderer/src/stores/workspace.js
@@ -0,0 +1,83 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+export const useWorkspaceStore = defineStore('workspace', () => {
+ // State
+ const isReady = ref(false)
+ const workspacePath = ref(null)
+ const loading = ref(false)
+ let listenerRegistered = false
+
+ // Getters
+ const hasWorkspace = computed(() => isReady.value && workspacePath.value !== null)
+
+ // Actions
+ async function checkStatus() {
+ try {
+ const status = await window.electronAPI.workspace.getStatus()
+ isReady.value = status.ready
+ workspacePath.value = status.path
+ } catch (err) {
+ console.error('Failed to check workspace status:', err)
+ }
+ }
+
+ async function selectFolder() {
+ try {
+ return await window.electronAPI.workspace.selectFolder()
+ } catch (err) {
+ console.error('Failed to select folder:', err)
+ return null
+ }
+ }
+
+ async function initWorkspace(path) {
+ loading.value = true
+ try {
+ const result = await window.electronAPI.workspace.setPath(path)
+ if (result && result.success) {
+ isReady.value = true
+ workspacePath.value = result.path
+ return true
+ }
+ console.error('Failed to init workspace:', result?.error || 'unknown error')
+ return false
+ } catch (err) {
+ console.error('Failed to init workspace:', err)
+ return false
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function listenStatusChanges() {
+ if (listenerRegistered) return
+ listenerRegistered = true
+
+ window.electronAPI.workspace.onStatusChange((status) => {
+ isReady.value = status.ready
+ workspacePath.value = status.path
+ })
+ }
+
+ function initialize() {
+ listenStatusChanges()
+ checkStatus()
+ }
+
+ return {
+ // State
+ isReady,
+ workspacePath,
+ loading,
+
+ // Getters
+ hasWorkspace,
+
+ // Actions
+ checkStatus,
+ selectFolder,
+ initWorkspace,
+ initialize
+ }
+})
\ No newline at end of file