feat: 功能迭代

This commit is contained in:
2026-05-31 21:06:22 +08:00
parent e49865600b
commit acd311a0d6
25 changed files with 3199 additions and 1 deletions

15
.editorconfig Normal file
View File

@@ -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

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist
out
node_modules
*.md

13
.prettierrc Normal file
View File

@@ -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
}

40
CLAUDE.md Normal file
View File

@@ -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.

363
README.md Normal file
View File

@@ -0,0 +1,363 @@
# Tunji - Markdown 编辑器
一个受 Typora 启发的 Markdown 编辑器桌面应用,使用 Electron + Vue 3 构建。
## 功能特性
### 核心功能
- **Markdown 编辑**:支持实时预览、分屏编辑、源码模式
- **笔记本管理**:支持多级笔记本结构
- **标签系统**:灵活的标签管理
- **主题切换**:浅色、深色、跟随系统
- **工作区管理**:支持自定义工作区路径
### 最新功能:笔记本树形结构
#### 功能概述
使用 PrimeVue Tree 组件实现笔记本的树形结构显示,支持:
1. **树形结构展示**:笔记本和文档以树形结构展示
2. **层级关系**:支持父子笔记本关系
3. **展开/折叠**:可以展开或折叠笔记本节点
4. **节点选择**:点击笔记本或文档节点可进行选择
5. **自定义图标**:笔记本使用书本图标,文档使用文件图标
#### 使用方式
##### 基本使用
```vue
<script setup>
import { useNotebookTree } from '@/composables/useNotebookTree'
const { notebookTree, expandedKeys, loadNotebooks, loadNotes } = useNotebookTree()
onMounted(async () => {
await loadNotebooks()
await loadNotes()
})
function onNodeSelect(event) {
console.log('Selected node:', event.node)
}
</script>
<template>
<Tree
:value="notebookTree"
:expandedKeys="expandedKeys"
selectionMode="single"
@node-select="onNodeSelect"
/>
</template>
```
##### 高级功能
```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]

17
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"prettier": "^3.8.3",
"vite": "^7.3.3" "vite": "^7.3.3"
} }
}, },
@@ -6816,6 +6817,22 @@
"node": "^12.20.0 || >=14" "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": { "node_modules/primeicons": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",

View File

@@ -7,7 +7,8 @@
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "electron-vite build", "build": "electron-vite build",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"package": "electron-builder" "package": "electron-builder",
"format": "prettier --write ."
}, },
"keywords": [ "keywords": [
"markdown", "markdown",
@@ -43,6 +44,7 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"prettier": "^3.8.3",
"vite": "^7.3.3" "vite": "^7.3.3"
} }
} }

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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$/, '')
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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:
*
* <script setup>
* import { useNotebookTreeExample } from '@/composables/useNotebookTreeExample'
*
* const {
* notebookTree,
* flatNotebooks,
* expandedKeys,
* initialize,
* findNotebook,
* getAllNotebooks
* } = useNotebookTreeExample()
*
* onMounted(() => {
* initialize()
* })
* </script>
*
* <template>
* <Tree
* :value="notebookTree"
* :expandedKeys="expandedKeys"
* selectionMode="single"
* @node-select="onNodeSelect"
* />
* </template>
*/

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
<script setup>
import { computed } from 'vue'
import { useNotesStore } from '@/stores/notes'
const notesStore = useNotesStore()
// 使用计算属性保持响应性
const notes = computed(() => notesStore.notes)
const loading = computed(() => notesStore.loading)
// 直接调用 actions
async function handleCreate() {
await notesStore.createNote({ title: '新笔记', content: '' })
}
</script>
```
## 最佳实践
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()` 替代解构的方法

View File

@@ -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'

View File

@@ -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
}
})

View File

@@ -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
}
})

View File

@@ -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
}
})

View File

@@ -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
}
})

View File

@@ -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
}
})