first commit
This commit is contained in:
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Electron
|
||||||
|
release/
|
||||||
|
*.dmg
|
||||||
|
*.AppImage
|
||||||
|
*.deb
|
||||||
|
*.rpm
|
||||||
|
*.snap
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude/
|
||||||
48
README.md
Normal file
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Tunji
|
||||||
|
|
||||||
|
类印象笔记 + Typora 的桌面笔记应用,基于 Electron + Vue 3 + Tailwind CSS + PrimeVue (Unstyled) 构建。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发环境
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **桌面框架**: Electron
|
||||||
|
- **前端框架**: Vue 3 (Composition API)
|
||||||
|
- **路由**: Vue Router 4
|
||||||
|
- **状态管理**: Pinia
|
||||||
|
- **样式**: Tailwind CSS 4
|
||||||
|
- **组件库**: PrimeVue (Unstyled Mode)
|
||||||
|
- **图标**: lucide-vue-next
|
||||||
|
- **构建工具**: Vite + vite-plugin-electron
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tunji/
|
||||||
|
├── electron/ # Electron 主进程
|
||||||
|
│ ├── main.js # 主进程入口
|
||||||
|
│ └── preload.js # IPC 桥接
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Vue 组件
|
||||||
|
│ │ ├── left-area/ # 左侧栏组件
|
||||||
|
│ │ └── common/ # 通用组件
|
||||||
|
│ ├── layouts/ # 布局组件
|
||||||
|
│ ├── stores/ # Pinia 状态管理
|
||||||
|
│ ├── views/ # 页面视图
|
||||||
|
│ ├── router/ # 路由配置
|
||||||
|
│ └── styles/ # 样式文件
|
||||||
|
├── index.html
|
||||||
|
├── package.json
|
||||||
|
└── vite.config.js
|
||||||
|
```
|
||||||
913
docs/DESIGN.md
Normal file
913
docs/DESIGN.md
Normal file
@@ -0,0 +1,913 @@
|
|||||||
|
# Tunji - 笔记应用设计文档
|
||||||
|
|
||||||
|
> 类印象笔记 + Typora 的桌面笔记应用,基于 Electron + Vue 3 + Tailwind CSS + PrimeVue (Unstyled) 构建
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 桌面框架 | Electron | 跨平台桌面应用 |
|
||||||
|
| 前端框架 | Vue 3 (Composition API) | 响应式 UI |
|
||||||
|
| 路由 | Vue Router 4 | SPA 路由管理 |
|
||||||
|
| 状态管理 | Pinia | 全局状态管理 |
|
||||||
|
| 样式 | Tailwind CSS 4 | 原子化 CSS |
|
||||||
|
| 组件库 | PrimeVue (Unstyled Mode) | 无样式组件,配合 Tailwind 自定义样式 |
|
||||||
|
| 图标 | lucide-vue-next | 轻量级图标库 |
|
||||||
|
| Markdown 处理 | unified (remark + rehype) | Markdown 解析 / 渲染 / 编辑 |
|
||||||
|
| 编辑器引擎 | ProseMirror / Tiptap | 富文本块编辑(Typora 风格) |
|
||||||
|
| 本地数据库 | better-sqlite3 (主进程) | 搜索索引、元数据缓存 |
|
||||||
|
| 文件监控 | chokidar | 工作目录文件变更监听 |
|
||||||
|
| 同步引擎 | 自研抽象层 | 支持 Git / WebDAV / S3 / 坚果云 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、主题色系统
|
||||||
|
|
||||||
|
参考 PrimeVue Lara 主题设计,采用蓝色为主色调,搭配中性灰色系。
|
||||||
|
|
||||||
|
### 2.1 主色 (Primary)
|
||||||
|
|
||||||
|
| 变量名 | 色值 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `--p-primary-50` | `#eff6ff` | 最浅背景 |
|
||||||
|
| `--p-primary-100` | `#dbeafe` | 浅色背景 / hover 背景 |
|
||||||
|
| `--p-primary-200` | `#bfdbfe` | 边框 / 分割线 |
|
||||||
|
| `--p-primary-300` | `#93c5fd` | 禁用状态 |
|
||||||
|
| `--p-primary-400` | `#60a5fa` | hover 边框 |
|
||||||
|
| `--p-primary-500` | `#3b82f6` | **默认按钮 / 链接** |
|
||||||
|
| `--p-primary-600` | `#2563eb` | **按钮 hover** |
|
||||||
|
| `--p-primary-700` | `#1d4ed8` | 按钮 active / pressed |
|
||||||
|
| `--p-primary-800` | `#1e40af` | 深色文字 |
|
||||||
|
| `--p-primary-900` | `#1e3a8a` | 最深色 |
|
||||||
|
|
||||||
|
### 2.2 表面色 (Surface)
|
||||||
|
|
||||||
|
| 变量名 | 色值 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `--p-surface-0` | `#ffffff` | 页面背景 / 卡片背景 |
|
||||||
|
| `--p-surface-50` | `#f8fafc` | 左侧栏背景 |
|
||||||
|
| `--p-surface-100` | `#f1f5f9` | 次级背景 / hover 背景 |
|
||||||
|
| `--p-surface-200` | `#e2e8f0` | 分割线 / 边框 |
|
||||||
|
| `--p-surface-300` | `#cbd5e1` | 禁用边框 |
|
||||||
|
| `--p-surface-400` | `#94a3b8` | 占位符文字 |
|
||||||
|
| `--p-surface-500` | `#64748b` | 次要文字 |
|
||||||
|
| `--p-surface-600` | `#475569` | 正文文字 |
|
||||||
|
| `--p-surface-700` | `#334155` | 标题文字 |
|
||||||
|
| `--p-surface-800` | `#1e293b` | 主标题文字 |
|
||||||
|
| `--p-surface-900` | `#0f172a` | 最深文字 |
|
||||||
|
|
||||||
|
### 2.3 语义色
|
||||||
|
|
||||||
|
| 语义 | 色值 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--p-success-500` | `#22c55e` | 成功 / 新建 |
|
||||||
|
| `--p-success-600` | `#16a34a` | 成功 hover |
|
||||||
|
| `--p-warn-500` | `#f59e0b` | 警告 |
|
||||||
|
| `--p-warn-600` | `#d97706` | 警告 hover |
|
||||||
|
| `--p-danger-500` | `#ef4444` | 危险 / 删除 |
|
||||||
|
| `--p-danger-600` | `#dc2626` | 危险 hover |
|
||||||
|
| `--p-info-500` | `#06b6d4` | 信息提示 |
|
||||||
|
|
||||||
|
### 2.4 暗色模式
|
||||||
|
|
||||||
|
暗色模式下 surface 反转,primary 色保持不变但降低饱和度:
|
||||||
|
|
||||||
|
| 变量名 | 色值 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `--p-surface-0` (dark) | `#0f172a` | 页面背景 |
|
||||||
|
| `--p-surface-50` (dark) | `#1e293b` | 左侧栏背景 |
|
||||||
|
| `--p-surface-100` (dark) | `#334155` | 卡片背景 |
|
||||||
|
| `--p-surface-200` (dark) | `#475569` | 边框 / 分割线 |
|
||||||
|
| `--p-surface-700` (dark) | `#e2e8f0` | 标题文字 |
|
||||||
|
| `--p-surface-800` (dark) | `#f1f5f9` | 主标题文字 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、工作目录设计
|
||||||
|
|
||||||
|
### 3.1 核心概念
|
||||||
|
|
||||||
|
参考 Obsidian 的 Vault 模型:**用户首次启动时必须选择一个工作目录**,所有数据(笔记、笔记本、标签、配置、插件)都在该目录下管理。
|
||||||
|
|
||||||
|
### 3.2 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
<用户选择的工作目录>/
|
||||||
|
├── .tunji/ # 应用元数据目录(隐藏)
|
||||||
|
│ ├── config.json # 应用配置(主题、编辑器设置等)
|
||||||
|
│ ├── tags.json # 标签定义(全局标签库)
|
||||||
|
│ ├── notebooks.json # 笔记本元数据(排序、颜色等)
|
||||||
|
│ ├── sync.json # 同步配置(WebDAV/S3/Git 凭据引用)
|
||||||
|
│ ├── plugins/ # 插件目录(未来扩展)
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── trash/ # 废纸篓(已删除笔记的临时存放)
|
||||||
|
│ │ ├── <note-id>.md
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── versions/ # 本地版本快照(Git-like 内容寻址)
|
||||||
|
│ │ ├── <hash1> # 文件内容的 SHA-256 哈希
|
||||||
|
│ │ ├── <hash2>
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── index.sqlite # 本地搜索索引(不同步!)
|
||||||
|
│
|
||||||
|
├── 笔记本A/ # 用户的笔记本(即文件夹)
|
||||||
|
│ ├── 笔记1.md # 笔记文件
|
||||||
|
│ ├── 笔记1.assets/ # 笔记1 的专属附件目录(同名配对)
|
||||||
|
│ │ ├── 架构图.png
|
||||||
|
│ │ └── 需求文档.pdf
|
||||||
|
│ ├── 笔记2.md
|
||||||
|
│ ├── 笔记2.assets/ # 笔记2 的专属附件目录
|
||||||
|
│ │ └── screenshot.jpg
|
||||||
|
│ ├── 子笔记本C/ # 笔记本支持嵌套
|
||||||
|
│ │ ├── 笔记3.md
|
||||||
|
│ │ ├── 笔记3.assets/
|
||||||
|
│ │ │ └── 流程图.png
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── 笔记本B/
|
||||||
|
│ ├── 笔记4.md
|
||||||
|
│ ├── 笔记4.assets/
|
||||||
|
│ │ └── 演示视频.mp4
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── 无归属笔记.md # 根目录下不属于任何笔记本的笔记
|
||||||
|
└── 无归属笔记.assets/
|
||||||
|
└── logo.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 笔记文件格式
|
||||||
|
|
||||||
|
每个 `.md` 文件使用 YAML Front Matter 存储元数据:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
title: "笔记标题"
|
||||||
|
created: "2026-06-01T10:00:00.000Z"
|
||||||
|
updated: "2026-06-01T12:30:00.000Z"
|
||||||
|
tags: ["工作", "会议", "项目A"]
|
||||||
|
notebook: "笔记本A"
|
||||||
|
pinned: false
|
||||||
|
favorited: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 笔记标题
|
||||||
|
|
||||||
|
这是笔记正文内容...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 附件存储设计
|
||||||
|
|
||||||
|
#### 设计原则
|
||||||
|
|
||||||
|
**每个笔记文件拥有一个同名的 `.assets/` 目录**,附件与笔记一一配对。这是 VS Code Markdown 预览和 Typora 验证过的成熟模式。
|
||||||
|
|
||||||
|
命名规则:`笔记名.md` ↔ `笔记名.assets/`
|
||||||
|
|
||||||
|
#### 存储方式
|
||||||
|
|
||||||
|
```
|
||||||
|
笔记本A/
|
||||||
|
├── 笔记1.md # 引用: 
|
||||||
|
├── 笔记1.assets/ # 笔记1 的专属附件
|
||||||
|
│ ├── 架构图.png # 保留用户原始文件名
|
||||||
|
│ ├── 架构图(1).png # 同名文件自动编号
|
||||||
|
│ └── 需求文档.pdf
|
||||||
|
├── 笔记2.md
|
||||||
|
├── 笔记2.assets/
|
||||||
|
│ └── screenshot.jpg
|
||||||
|
└── 子笔记本C/
|
||||||
|
├── 笔记3.md
|
||||||
|
├── 笔记3.assets/
|
||||||
|
│ └── 流程图.png
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 文件名策略(不用 UUID)
|
||||||
|
|
||||||
|
| 场景 | 处理方式 | 示例 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 首次保存 | 保留原始文件名 | `架构图.png` |
|
||||||
|
| 同名冲突 | 自动追加数字后缀 | `架构图(1).png`、`架构图(2).png` |
|
||||||
|
| 粘贴/截图 | 使用时间戳命名 | `image-20260601-143022.png` |
|
||||||
|
| 剪贴板图片 | 使用时间戳命名 | `clipboard-20260601-143022.png` |
|
||||||
|
|
||||||
|
#### 引用方式
|
||||||
|
|
||||||
|
笔记中引用附件使用**相对于自身**的路径:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
[需求文档](笔记1.assets/需求文档.pdf)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 移动笔记时的附件处理
|
||||||
|
|
||||||
|
这是本设计的核心优势——**移动笔记零成本**:
|
||||||
|
|
||||||
|
```
|
||||||
|
移动前:
|
||||||
|
笔记本A/
|
||||||
|
├── 会议纪要.md
|
||||||
|
└── 会议纪要.assets/
|
||||||
|
└── 白板照片.jpg
|
||||||
|
|
||||||
|
移动后(拖拽到笔记本B):
|
||||||
|
笔记本B/
|
||||||
|
├── 会议纪要.md # 引用路径不变:
|
||||||
|
└── 会议纪要.assets/
|
||||||
|
└── 白板照片.jpg
|
||||||
|
|
||||||
|
笔记本A/
|
||||||
|
└── (干净,无残留)
|
||||||
|
```
|
||||||
|
|
||||||
|
**只需要移动两个东西**:
|
||||||
|
1. `会议纪要.md`
|
||||||
|
2. `会议纪要.assets/` 目录
|
||||||
|
|
||||||
|
引用路径 `会议纪要.assets/白板照片.jpg` 是相对于 `.md` 文件的,所以**完全不需要修改笔记内容**。
|
||||||
|
|
||||||
|
#### 重命名笔记时的附件处理
|
||||||
|
|
||||||
|
重命名笔记时自动重命名对应的 `.assets/` 目录:
|
||||||
|
|
||||||
|
```
|
||||||
|
重命名前:笔记1.md + 笔记1.assets/
|
||||||
|
重命名后:项目方案.md + 项目方案.assets/
|
||||||
|
```
|
||||||
|
|
||||||
|
同时批量替换笔记内所有 `笔记1.assets/` → `项目方案.assets/` 的引用。
|
||||||
|
|
||||||
|
#### 删除笔记时的附件处理
|
||||||
|
|
||||||
|
- 删除笔记 → `.md` 文件 + 同名 `.assets/` 目录一起移入废纸篓
|
||||||
|
- 恢复笔记 → 一起恢复
|
||||||
|
- 彻底删除 → 一起彻底删除
|
||||||
|
- 不存在孤儿附件文件
|
||||||
|
|
||||||
|
#### 对比其他方案
|
||||||
|
|
||||||
|
| 方案 | 移动笔记 | 重命名笔记 | 删除笔记 | 用户可读性 | 同步冲突 |
|
||||||
|
|------|---------|-----------|---------|-----------|---------|
|
||||||
|
| 全局附件 + UUID | 需迁移附件+改引用 | 需改引用 | 需扫描孤儿文件 | 差(UUID) | 高(大目录) |
|
||||||
|
| 每笔记本 assets | 需迁移附件+改引用 | 需改引用 | 需扫描孤儿文件 | 好 | 中 |
|
||||||
|
| **每笔记 .assets(推荐)** | **移动两个文件/目录即可** | **改目录名+批量替换引用** | **自动跟随** | **好** | **低(分散)** |
|
||||||
|
|
||||||
|
### 3.5 笔记本 vs 文件夹
|
||||||
|
|
||||||
|
- **笔记本 = 文件夹**:直接映射,用户在文件管理器中看到的结构就是应用中的结构
|
||||||
|
- 支持无限层级嵌套
|
||||||
|
- `.tunji/` 目录下的 `notebooks.json` 存储笔记本的额外元数据(颜色、图标、排序)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、同步架构设计
|
||||||
|
|
||||||
|
### 4.1 设计原则
|
||||||
|
|
||||||
|
1. **数据库不同步**:`index.sqlite` 纯本地,同步后在本地重建
|
||||||
|
2. **版本快照不同步**:`.tunji/versions/` 纯本地
|
||||||
|
3. **配置可选同步**:`.tunji/config.json` 可选是否同步
|
||||||
|
4. **只同步用户内容**:`.md` 文件 + 同名 `.assets/` 目录 + `.tunji/tags.json` + `.tunji/notebooks.json`
|
||||||
|
|
||||||
|
### 4.2 同步抽象层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ SyncManager │
|
||||||
|
│ (事件驱动,统一接口) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ - sync() 触发全量同步 │
|
||||||
|
│ - pull() 拉取远程变更 │
|
||||||
|
│ - push() 推送本地变更 │
|
||||||
|
│ - getConflicts() 获取冲突列表 │
|
||||||
|
│ - resolve() 解决冲突 │
|
||||||
|
│ - getStatus() 同步状态 │
|
||||||
|
├──────────┬──────────┬──────────┬─────────┤
|
||||||
|
│ GitSync │WebDavSync│ S3Sync │坚果云Sync│
|
||||||
|
│ (libgit2)│(webdav) │(aws-sdk) │(webdav) │
|
||||||
|
└──────────┴──────────┴──────────┴─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 同步策略
|
||||||
|
|
||||||
|
#### Git 同步
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 策略:自动 commit + push,冲突时提示用户
|
||||||
|
// config 示例:
|
||||||
|
{
|
||||||
|
type: 'git',
|
||||||
|
repoUrl: 'https://github.com/user/notes.git',
|
||||||
|
branch: 'main', // 默认 main
|
||||||
|
autoCommit: true, // 每次变更自动 commit
|
||||||
|
autoPush: true, // 每次 commit 后自动 push
|
||||||
|
commitMessage: '{{date}} {{action}}', // 支持模板
|
||||||
|
sshKeyPath: '', // SSH 私钥路径(可选)
|
||||||
|
token: '' // HTTPS token(可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### WebDAV 同步(坚果云等)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// config 示例:
|
||||||
|
{
|
||||||
|
type: 'webdav',
|
||||||
|
serverUrl: 'https://dav.jianguoyun.com/dav/',
|
||||||
|
username: 'user@example.com',
|
||||||
|
password: 'app-specific-password', // 应用专用密码
|
||||||
|
remotePath: '/tunji-notes/', // 远程目录路径
|
||||||
|
syncInterval: 300 // 自动同步间隔(秒)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### S3 同步
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// config 示例:
|
||||||
|
{
|
||||||
|
type: 's3',
|
||||||
|
endpoint: 'https://s3.amazonaws.com', // S3 兼容端点
|
||||||
|
bucket: 'my-notes',
|
||||||
|
accessKey: 'AKIAIOSFODNN7EXAMPLE',
|
||||||
|
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||||
|
region: 'us-east-1',
|
||||||
|
prefix: 'tunji/' // 对象前缀
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 冲突解决策略
|
||||||
|
|
||||||
|
| 场景 | 策略 |
|
||||||
|
|------|------|
|
||||||
|
| 仅本地修改 | 直接推送 |
|
||||||
|
| 仅远程修改 | 直接拉取 |
|
||||||
|
| 双方都修改 | **冲突**:生成冲突副本 `笔记名(冲突-本地).md` + `笔记名(冲突-远程).md`,提示用户手动合并 |
|
||||||
|
| 本地删除 vs 远程修改 | 提示用户确认:恢复 or 确认删除 |
|
||||||
|
| 远程删除 vs 本地修改 | 提示用户确认:重新推送 or 确认删除 |
|
||||||
|
|
||||||
|
### 4.5 同步状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
IDLE → SYNCING → SUCCESS
|
||||||
|
→ CONFLICT (需用户介入)
|
||||||
|
→ ERROR (网络错误等,自动重试)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、本地数据库设计
|
||||||
|
|
||||||
|
### 5.1 为什么需要本地数据库
|
||||||
|
|
||||||
|
1. **全文搜索**:纯文件遍历搜索太慢,需要倒排索引
|
||||||
|
2. **元数据查询**:按标签、创建时间、更新时间等维度快速筛选
|
||||||
|
3. **排序和分页**:大量笔记的列表展示需要高效查询
|
||||||
|
4. **跨笔记本搜索**:模糊搜索需要全局索引
|
||||||
|
|
||||||
|
### 5.2 数据库选型
|
||||||
|
|
||||||
|
**better-sqlite3**(Electron 主进程)
|
||||||
|
|
||||||
|
- 纯本地,无需网络
|
||||||
|
- 同步到工作目录 `.tunji/index.sqlite`
|
||||||
|
- **不同步到远程**
|
||||||
|
|
||||||
|
### 5.3 数据库 Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 笔记索引表(从 .md 文件解析的元数据)
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL UNIQUE, -- 相对于工作目录的路径
|
||||||
|
notebook_path TEXT, -- 所属笔记本路径
|
||||||
|
created_at TEXT, -- ISO 8601
|
||||||
|
updated_at TEXT,
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
|
favorited INTEGER DEFAULT 0,
|
||||||
|
word_count INTEGER DEFAULT 0,
|
||||||
|
is_deleted INTEGER DEFAULT 0 -- 软删除(废纸篓)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 标签表
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
color TEXT -- 标签颜色
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 笔记-标签关联表
|
||||||
|
CREATE TABLE 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),
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 全文搜索虚拟表(FTS5)
|
||||||
|
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||||
|
title,
|
||||||
|
content, -- 笔记正文(不含 front matter)
|
||||||
|
note_id UNINDEXED,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 笔记本元数据表
|
||||||
|
CREATE TABLE notebooks (
|
||||||
|
path TEXT PRIMARY KEY, -- 相对于工作目录的路径
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent_path TEXT,
|
||||||
|
color TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 同步状态表
|
||||||
|
CREATE TABLE sync_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 索引重建流程
|
||||||
|
|
||||||
|
当用户在其他设备同步后打开应用,或删除了 `index.sqlite`:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 扫描工作目录下所有 .md 文件
|
||||||
|
2. 解析 front matter 元数据
|
||||||
|
3. 提取正文内容
|
||||||
|
4. 批量写入 notes 表
|
||||||
|
5. 批量写入 notes_fts 全文索引
|
||||||
|
6. 解析 tags.json,写入 tags 表
|
||||||
|
7. 建立 note_tags 关联
|
||||||
|
8. 完成,进入正常模式
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、版本控制与文件防丢失
|
||||||
|
|
||||||
|
### 6.1 本地版本快照(不依赖 Git)
|
||||||
|
|
||||||
|
**原理**:每次保存笔记时,将旧内容的 SHA-256 哈希作为文件名,存入 `.tunji/versions/` 目录。
|
||||||
|
|
||||||
|
```
|
||||||
|
.tunji/versions/
|
||||||
|
├── a1b2c3d4... # 笔记某次修改的内容快照
|
||||||
|
├── e5f6g7h8...
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
.tunji/versions-manifest.json # 版本清单
|
||||||
|
{
|
||||||
|
"note-id-1": [
|
||||||
|
{ "hash": "a1b2c3d4...", "timestamp": "2026-06-01T10:00:00Z", "message": "自动保存" },
|
||||||
|
{ "hash": "e5f6g7h8...", "timestamp": "2026-06-01T09:30:00Z", "message": "自动保存" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 版本回退机制
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击"版本历史"
|
||||||
|
→ 读取 versions-manifest.json 获取该笔记的所有版本
|
||||||
|
→ 按时间倒序展示版本列表
|
||||||
|
→ 用户选择某个版本
|
||||||
|
→ 从 versions/<hash> 读取内容
|
||||||
|
→ 预览差异(diff view)
|
||||||
|
→ 确认回退
|
||||||
|
→ 将当前版本存为新快照(可撤销)
|
||||||
|
→ 将选中版本内容写回 .md 文件
|
||||||
|
→ 更新数据库索引
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 防丢失策略
|
||||||
|
|
||||||
|
| 策略 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **自动保存** | 编辑器内容每 5 秒自动保存到 .md 文件 |
|
||||||
|
| **版本快照** | 每次保存前,旧版本自动存入 versions/ |
|
||||||
|
| **废纸篓** | 删除笔记不直接删除,移入 .tunji/trash/,保留 30 天 |
|
||||||
|
| **崩溃恢复** | Electron 主进程监听 crash 事件,编辑器状态暂存 localStorage |
|
||||||
|
| **同步前备份** | 同步拉取远程变更前,先备份本地当前版本 |
|
||||||
|
|
||||||
|
### 6.4 版本快照清理策略
|
||||||
|
|
||||||
|
- 保留最近 50 个版本
|
||||||
|
- 超过 30 天的版本,仅保留每天第一个
|
||||||
|
- 超过 90 天的版本,仅保留每周第一个
|
||||||
|
- 用户可手动清理所有历史版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、页面布局设计
|
||||||
|
|
||||||
|
### 7.1 整体布局
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Electron Title Bar │
|
||||||
|
├──────────┬───────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ LEFT │ RIGHT AREA │
|
||||||
|
│ AREA │ (Router View) │
|
||||||
|
│ │ │
|
||||||
|
│ 240- │ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ 480px │ │ Notes View / Tags View / Note Detail │ │
|
||||||
|
│ 可拖拽 │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
└──────────┴───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 左侧栏 (LeftArea) 详细设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ 👤 用户名称 [◀] │ ← 第一行:头像 + 名称 + 折叠按钮
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ 🔍 搜索笔记... [+] │ ← 第二行:搜索框 + 新建按钮
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ 📝 笔记 │ ← 第三行:Menu Item - 跳转笔记列表
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ 🏷️ 标签 │ ← 第四行:Menu Item - 跳转标签页
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ 📚 笔记本 │ ← 第五行:Menu Label
|
||||||
|
│ ├─ 📂 笔记本A │ ← PanelMenu:可展开/折叠
|
||||||
|
│ │ ├─ 📄 笔记1 │
|
||||||
|
│ │ ├─ 📄 笔记2 │
|
||||||
|
│ │ └─ 📂 子笔记本C │
|
||||||
|
│ │ └─ 📄 笔记3 │
|
||||||
|
│ └─ 📂 笔记本B │
|
||||||
|
│ └─ 📄 笔记4 │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ ⚙️ 设置 🗑️ 废纸篓│ ← 底部固定:设置 + 废纸篓
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 交互细节
|
||||||
|
|
||||||
|
- **折叠按钮**:点击后左侧栏收起为 0px 或 48px(仅显示图标),右侧区域全屏
|
||||||
|
- **搜索框**:实时搜索(debounce 300ms),结果在右侧区域展示
|
||||||
|
- **新建按钮**:点击后在当前选中的笔记本下创建新笔记(若未选中笔记本则创建在根目录)
|
||||||
|
- **笔记本 PanelMenu**:点击笔记本切换展开/折叠;点击笔记在右侧打开详情
|
||||||
|
- **拖拽调整宽度**:左侧栏右边框可拖拽,范围 240px ~ 480px
|
||||||
|
|
||||||
|
### 7.3 右侧区域 (RightArea) 路由设计
|
||||||
|
|
||||||
|
| 路由 | 视图 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | 首页 / 欢迎页 | 首次打开或未选中笔记时展示 |
|
||||||
|
| `/notes` | 笔记列表 | 工作目录下所有笔记,支持列表/卡片视图切换 |
|
||||||
|
| `/notes?notebook=<path>` | 笔记列表(过滤) | 指定笔记本下的笔记 |
|
||||||
|
| `/tags` | 标签列表 | 展示所有标签,点击标签查看关联笔记 |
|
||||||
|
| `/tags/:tagId` | 标签详情 | 某个标签下的所有笔记,支持列表/卡片视图 |
|
||||||
|
| `/note/:id` | 笔记详情 | 笔记编辑/预览/双栏视图 |
|
||||||
|
| `/search?q=xxx` | 搜索结果 | 全局搜索结果 |
|
||||||
|
| `/settings` | 设置页面 | 应用设置 |
|
||||||
|
| `/trash` | 废纸篓 | 已删除笔记列表 |
|
||||||
|
|
||||||
|
### 7.4 笔记详情页 (NoteDetail) 详细设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 笔记标题(可编辑) │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ [编辑] [预览] [双栏] │ 🏷️ 标签 │ ⋯ 更多操作 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 笔记内容区域 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 支持: │ │
|
||||||
|
│ │ - Markdown 实时渲染 │ │
|
||||||
|
│ │ - 块级编辑(Typora 风格) │ │
|
||||||
|
│ │ - 拖拽移动块 │ │
|
||||||
|
│ │ - 图片粘贴 / 拖入 │ │
|
||||||
|
│ │ - 代码块高亮 │ │
|
||||||
|
│ │ - 表格编辑 │ │
|
||||||
|
│ │ - 数学公式 (KaTeX) │ │
|
||||||
|
│ │ - Mermaid 图表 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 三种视图模式
|
||||||
|
|
||||||
|
| 模式 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **编辑模式** | 纯 Markdown 编辑(类似 VS Code) |
|
||||||
|
| **预览模式** | 纯渲染预览(只读) |
|
||||||
|
| **双栏模式** | 左侧 Markdown 源码,右侧实时预览(类似 Typora 的源码模式) |
|
||||||
|
|
||||||
|
> 注:后期实现块编辑后,默认进入"所见即所得"模式,编辑/预览/双栏作为切换选项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、组件架构
|
||||||
|
|
||||||
|
### 8.1 核心组件树
|
||||||
|
|
||||||
|
```
|
||||||
|
App.vue
|
||||||
|
├── MainLayout
|
||||||
|
│ ├── LeftArea
|
||||||
|
│ │ ├── UserProfile # 头像 + 用户名
|
||||||
|
│ │ ├── CollapseButton # 折叠按钮
|
||||||
|
│ │ ├── SearchInput # 搜索框
|
||||||
|
│ │ ├── AddNoteButton # 新建笔记按钮
|
||||||
|
│ │ ├── MenuItem (笔记) # 路由跳转
|
||||||
|
│ │ ├── MenuItem (标签) # 路由跳转
|
||||||
|
│ │ ├── NotebookTree # 笔记本树(PanelMenu)
|
||||||
|
│ │ │ ├── NotebookNode # 笔记本节点(可展开)
|
||||||
|
│ │ │ │ ├── NoteItem # 笔记条目
|
||||||
|
│ │ │ │ └── NotebookNode # 递归嵌套
|
||||||
|
│ │ ├── SettingsLink # 设置入口
|
||||||
|
│ │ └── TrashLink # 废纸篓入口
|
||||||
|
│ │
|
||||||
|
│ ├── ResizeHandle # 拖拽调整宽度
|
||||||
|
│ │
|
||||||
|
│ └── RightArea (RouterView)
|
||||||
|
│ ├── WelcomeView # 欢迎页
|
||||||
|
│ ├── NotesListView # 笔记列表
|
||||||
|
│ │ ├── NoteCard # 卡片模式
|
||||||
|
│ │ └── NoteListItem # 列表模式
|
||||||
|
│ ├── TagsView # 标签列表
|
||||||
|
│ ├── TagDetailView # 标签详情
|
||||||
|
│ ├── NoteDetailView # 笔记详情
|
||||||
|
│ │ ├── NoteTitle # 标题编辑
|
||||||
|
│ │ ├── NoteToolbar # 操作栏
|
||||||
|
│ │ ├── NoteEditor # 编辑器
|
||||||
|
│ │ │ ├── TiptapEditor # Tiptap 编辑器实例
|
||||||
|
│ │ │ └── BlockMenu # 块操作菜单
|
||||||
|
│ │ └── NotePreview # 渲染预览
|
||||||
|
│ ├── SearchView # 搜索结果
|
||||||
|
│ ├── SettingsView # 设置页面
|
||||||
|
│ └── TrashView # 废纸篓
|
||||||
|
│
|
||||||
|
└── StatusBar # 底部状态栏(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Electron 进程架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Main Process │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ FileManager│ │ Database │ │ SyncEngine│ │
|
||||||
|
│ │ (chokidar)│ │(sqlite3) │ │ (各后端) │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────┼──────────────┘ │
|
||||||
|
│ │ IPC Bridge │
|
||||||
|
├───────────────────────┼─────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ Renderer Process (Vue 3) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ fileStore │ │ noteStore │ │ syncStore │ │
|
||||||
|
│ │ (Pinia) │ │ (Pinia) │ │ (Pinia) │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 IPC 通信接口
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// preload.js 暴露到渲染进程的 API
|
||||||
|
// 通过 contextBridge 暴露,渲染进程通过 window.tunjiAPI 调用
|
||||||
|
|
||||||
|
// 文件操作
|
||||||
|
window.tunjiAPI.file.read(filePath) // → string
|
||||||
|
window.tunjiAPI.file.write(filePath, content) // → void
|
||||||
|
window.tunjiAPI.file.delete(filePath) // → void
|
||||||
|
window.tunjiAPI.file.rename(oldPath, newPath) // → void
|
||||||
|
window.tunjiAPI.file.list(dirPath) // → FileEntry[]
|
||||||
|
window.tunjiAPI.file.watch(callback) // 监听文件变更事件
|
||||||
|
|
||||||
|
// 数据库操作
|
||||||
|
window.tunjiAPI.db.searchNotes(query) // → NoteSearchResult[]
|
||||||
|
window.tunjiAPI.db.getNotes(filter) // → NoteMeta[]
|
||||||
|
window.tunjiAPI.db.getNote(id) // → NoteMeta
|
||||||
|
window.tunjiAPI.db.updateNoteMeta(id, meta) // → void
|
||||||
|
window.tunjiAPI.db.getTags() // → Tag[]
|
||||||
|
window.tunjiAPI.db.getNotebooks() // → Notebook[]
|
||||||
|
window.tunjiAPI.db.rebuildIndex() // → void
|
||||||
|
|
||||||
|
// 同步操作
|
||||||
|
window.tunjiAPI.sync.getStatus() // → SyncStatus
|
||||||
|
window.tunjiAPI.sync.start() // → void
|
||||||
|
window.tunjiAPI.sync.configure(config) // → void
|
||||||
|
window.tunjiAPI.sync.resolveConflict(noteId, resolution) // resolution: 'local'|'remote'|'merge'
|
||||||
|
|
||||||
|
// 工作目录
|
||||||
|
window.tunjiAPI.workspace.select() // → string|null 打开目录选择对话框
|
||||||
|
window.tunjiAPI.workspace.get() // → string|null 获取当前工作目录
|
||||||
|
window.tunjiAPI.workspace.set(path) // → void 设置工作目录
|
||||||
|
|
||||||
|
// 系统
|
||||||
|
window.tunjiAPI.system.getAppVersion() // → string
|
||||||
|
window.tunjiAPI.system.getPlatform() // → string
|
||||||
|
window.tunjiAPI.system.openExternal(url) // → void
|
||||||
|
window.tunjiAPI.system.showItemInFolder(path) // → void
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、Pinia Store 设计
|
||||||
|
|
||||||
|
所有 Store 使用 Vue 3 Composition API 风格(`setup` 函数),纯 JavaScript。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// stores/workspace.js - 工作目录状态
|
||||||
|
// state: { path, isReady }
|
||||||
|
// actions: selectWorkspace(), initWorkspace(path)
|
||||||
|
|
||||||
|
// stores/notes.js - 笔记状态
|
||||||
|
// state: { notes, currentNote, viewMode, filter }
|
||||||
|
// actions: fetchNotes(filter), openNote(id), createNote(notebookPath),
|
||||||
|
// deleteNote(id), saveNote(id, content)
|
||||||
|
|
||||||
|
// stores/notebooks.js - 笔记本状态
|
||||||
|
// state: { notebooks, expandedPaths }
|
||||||
|
// actions: fetchNotebooks(), toggleExpand(path), createNotebook(name, parentPath),
|
||||||
|
// renameNotebook(path, newName), deleteNotebook(path)
|
||||||
|
|
||||||
|
// stores/tags.js - 标签状态
|
||||||
|
// state: { tags }
|
||||||
|
// actions: fetchTags(), createTag(name, color), deleteTag(id),
|
||||||
|
// addTagToNote(noteId, tagId), removeTagFromNote(noteId, tagId)
|
||||||
|
|
||||||
|
// stores/sync.js - 同步状态
|
||||||
|
// state: { status, config, conflicts }
|
||||||
|
// actions: startSync(), configureSync(config), resolveConflict(noteId, resolution)
|
||||||
|
|
||||||
|
// stores/ui.js - UI 状态
|
||||||
|
// state: { leftCollapsed, leftWidth, theme }
|
||||||
|
// actions: toggleLeft(), setLeftWidth(width), setTheme(theme)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tunji/
|
||||||
|
├── docs/
|
||||||
|
│ └── DESIGN.md # 本文档
|
||||||
|
│
|
||||||
|
├── electron/
|
||||||
|
│ ├── main.js # Electron 主进程入口
|
||||||
|
│ ├── preload.js # 预加载脚本(IPC 桥接)
|
||||||
|
│ ├── ipc/
|
||||||
|
│ │ ├── file.ipc.js # 文件操作 IPC 处理
|
||||||
|
│ │ ├── db.ipc.js # 数据库 IPC 处理
|
||||||
|
│ │ ├── sync.ipc.js # 同步 IPC 处理
|
||||||
|
│ │ └── workspace.ipc.js # 工作目录 IPC 处理
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── file-manager.js # 文件管理服务
|
||||||
|
│ │ ├── database.js # SQLite 数据库服务
|
||||||
|
│ │ ├── index-builder.js # 索引构建服务
|
||||||
|
│ │ ├── version-manager.js # 版本管理服务
|
||||||
|
│ │ └── sync/
|
||||||
|
│ │ ├── sync-manager.js # 同步管理器
|
||||||
|
│ │ ├── git-sync.js # Git 同步实现
|
||||||
|
│ │ ├── webdav-sync.js # WebDAV 同步实现
|
||||||
|
│ │ └── s3-sync.js # S3 同步实现
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── markdown-parser.js # Front matter 解析
|
||||||
|
│ └── file-hash.js # 文件哈希计算
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ ├── App.vue # 根组件
|
||||||
|
│ ├── main.js # Vue 入口
|
||||||
|
│ ├── router/
|
||||||
|
│ │ └── index.js # 路由配置
|
||||||
|
│ ├── stores/ # Pinia 状态管理
|
||||||
|
│ │ ├── workspace.js
|
||||||
|
│ │ ├── notes.js
|
||||||
|
│ │ ├── notebooks.js
|
||||||
|
│ │ ├── tags.js
|
||||||
|
│ │ ├── sync.js
|
||||||
|
│ │ └── ui.js
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── MainLayout.vue # 主布局
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── left-area/
|
||||||
|
│ │ │ ├── LeftArea.vue
|
||||||
|
│ │ │ ├── UserProfile.vue
|
||||||
|
│ │ │ ├── SearchInput.vue
|
||||||
|
│ │ │ ├── AddNoteButton.vue
|
||||||
|
│ │ │ ├── MenuItem.vue
|
||||||
|
│ │ │ ├── NotebookTree.vue
|
||||||
|
│ │ │ ├── NotebookNode.vue
|
||||||
|
│ │ │ └── NoteItem.vue
|
||||||
|
│ │ ├── right-area/
|
||||||
|
│ │ │ ├── NotesListView.vue
|
||||||
|
│ │ │ ├── NoteCard.vue
|
||||||
|
│ │ │ ├── NoteListItem.vue
|
||||||
|
│ │ │ ├── TagsView.vue
|
||||||
|
│ │ │ ├── TagDetailView.vue
|
||||||
|
│ │ │ ├── NoteDetailView.vue
|
||||||
|
│ │ │ ├── SearchView.vue
|
||||||
|
│ │ │ ├── SettingsView.vue
|
||||||
|
│ │ │ └── TrashView.vue
|
||||||
|
│ │ ├── editor/
|
||||||
|
│ │ │ ├── NoteEditor.vue
|
||||||
|
│ │ │ ├── NotePreview.vue
|
||||||
|
│ │ │ ├── TiptapEditor.vue
|
||||||
|
│ │ │ └── BlockMenu.vue
|
||||||
|
│ │ └── common/
|
||||||
|
│ │ ├── ResizeHandle.vue
|
||||||
|
│ │ ├── ViewToggle.vue # 列表/卡片视图切换
|
||||||
|
│ │ └── EmptyState.vue
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── useDragResize.js # 拖拽调整宽度
|
||||||
|
│ │ ├── useSearch.js # 搜索逻辑
|
||||||
|
│ │ ├── useAutoSave.js # 自动保存
|
||||||
|
│ │ └── useVersionHistory.js # 版本历史
|
||||||
|
│ └── styles/
|
||||||
|
│ ├── theme.css # 主题变量定义
|
||||||
|
│ └── tailwind.css # Tailwind 入口
|
||||||
|
│
|
||||||
|
├── index.html # HTML 入口
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── electron-builder.json # Electron 打包配置
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、开发路线图
|
||||||
|
|
||||||
|
### Phase 1:基础骨架(MVP)
|
||||||
|
|
||||||
|
- [ ] Electron + Vue 3 + Vite 项目初始化
|
||||||
|
- [ ] Tailwind CSS + PrimeVue (Unstyled) 集成
|
||||||
|
- [ ] 主题色系统实现
|
||||||
|
- [ ] 工作目录选择功能
|
||||||
|
- [ ] 两栏布局 + 拖拽调整宽度
|
||||||
|
- [ ] 左侧栏基础组件
|
||||||
|
- [ ] 路由基础配置
|
||||||
|
|
||||||
|
### Phase 2:核心笔记功能
|
||||||
|
|
||||||
|
- [ ] Markdown 文件读写
|
||||||
|
- [ ] Front matter 解析
|
||||||
|
- [ ] 笔记创建 / 编辑 / 删除
|
||||||
|
- [ ] 笔记本(文件夹)管理
|
||||||
|
- [ ] Tiptap 编辑器集成
|
||||||
|
- [ ] Markdown 实时预览
|
||||||
|
|
||||||
|
### Phase 3:索引与搜索
|
||||||
|
|
||||||
|
- [ ] SQLite 数据库集成
|
||||||
|
- [ ] 笔记索引构建
|
||||||
|
- [ ] 全文搜索
|
||||||
|
- [ ] 标签系统
|
||||||
|
|
||||||
|
### Phase 4:同步功能
|
||||||
|
|
||||||
|
- [ ] 同步抽象层
|
||||||
|
- [ ] Git 同步
|
||||||
|
- [ ] WebDAV 同步
|
||||||
|
- [ ] S3 同步
|
||||||
|
- [ ] 冲突解决
|
||||||
|
|
||||||
|
### Phase 5:高级编辑
|
||||||
|
|
||||||
|
- [ ] Typora 风格块编辑
|
||||||
|
- [ ] 拖拽排序
|
||||||
|
- [ ] 图片管理
|
||||||
|
- [ ] 代码高亮
|
||||||
|
- [ ] 数学公式
|
||||||
|
- [ ] Mermaid 图表
|
||||||
|
|
||||||
|
### Phase 6:完善与优化
|
||||||
|
|
||||||
|
- [ ] 版本历史 / 回退
|
||||||
|
- [ ] 废纸篓
|
||||||
|
- [ ] 暗色模式
|
||||||
|
- [ ] 插件系统
|
||||||
|
- [ ] 性能优化
|
||||||
|
- [ ] 打包发布
|
||||||
141
electron/main.js
Normal file
141
electron/main.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { app, BrowserWindow, dialog, ipcMain } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
|
||||||
|
|
||||||
|
let mainWindow = null
|
||||||
|
|
||||||
|
const CONFIG_DIR = join(app.getPath('userData'), '.tunji')
|
||||||
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
||||||
|
|
||||||
|
function ensureConfigDir() {
|
||||||
|
if (!existsSync(CONFIG_DIR)) {
|
||||||
|
mkdirSync(CONFIG_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
ensureConfigDir()
|
||||||
|
if (existsSync(CONFIG_FILE)) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(config) {
|
||||||
|
ensureConfigDir()
|
||||||
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
|
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
|
||||||
|
mainWindow.webContents.openDevTools()
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../dist/index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC Handlers
|
||||||
|
ipcMain.handle('workspace:select', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory'],
|
||||||
|
title: '选择工作目录',
|
||||||
|
})
|
||||||
|
if (result.canceled) return null
|
||||||
|
const workspacePath = result.filePaths[0]
|
||||||
|
const config = loadConfig()
|
||||||
|
config.workspace = workspacePath
|
||||||
|
saveConfig(config)
|
||||||
|
return workspacePath
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:get', () => {
|
||||||
|
const config = loadConfig()
|
||||||
|
return config.workspace || null
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:set', (_event, path) => {
|
||||||
|
const config = loadConfig()
|
||||||
|
config.workspace = path
|
||||||
|
saveConfig(config)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:ensure-structure', (_event, workspacePath) => {
|
||||||
|
const tunjiDir = join(workspacePath, '.tunji')
|
||||||
|
if (!existsSync(tunjiDir)) {
|
||||||
|
mkdirSync(tunjiDir, { recursive: true })
|
||||||
|
}
|
||||||
|
const configPath = join(tunjiDir, 'config.json')
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
writeFileSync(configPath, JSON.stringify({ theme: 'light' }, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:read', (_event, filePath) => {
|
||||||
|
try {
|
||||||
|
return readFileSync(filePath, 'utf-8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:write', (_event, filePath, content) => {
|
||||||
|
writeFileSync(filePath, content, 'utf-8')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:mkdir', (_event, dirPath) => {
|
||||||
|
try {
|
||||||
|
if (!existsSync(dirPath)) {
|
||||||
|
mkdirSync(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:list', (_event, dirPath) => {
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dirPath, { withFileTypes: true })
|
||||||
|
return entries
|
||||||
|
.filter(e => !e.name.startsWith('.'))
|
||||||
|
.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
path: join(dirPath, e.name),
|
||||||
|
isDirectory: e.isDirectory(),
|
||||||
|
isFile: e.isFile(),
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(createWindow)
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
16
electron/preload.js
Normal file
16
electron/preload.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('tunjiAPI', {
|
||||||
|
workspace: {
|
||||||
|
select: () => ipcRenderer.invoke('workspace:select'),
|
||||||
|
get: () => ipcRenderer.invoke('workspace:get'),
|
||||||
|
set: (path) => ipcRenderer.invoke('workspace:set', path),
|
||||||
|
ensureStructure: (path) => ipcRenderer.invoke('workspace:ensure-structure', path),
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
read: (filePath) => ipcRenderer.invoke('file:read', filePath),
|
||||||
|
write: (filePath, content) => ipcRenderer.invoke('file:write', filePath, content),
|
||||||
|
list: (dirPath) => ipcRenderer.invoke('file:list', dirPath),
|
||||||
|
mkdir: (dirPath) => ipcRenderer.invoke('file:mkdir', dirPath),
|
||||||
|
},
|
||||||
|
})
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tunji</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3197
package-lock.json
generated
Normal file
3197
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "tunji",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "类印象笔记 + Typora 的桌面笔记应用",
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"pinia": "^3.0.2",
|
||||||
|
"primevue": "^4.3.3",
|
||||||
|
"lucide-vue-next": "^0.474.0",
|
||||||
|
"gray-matter": "^4.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"vite": "^6.2.4",
|
||||||
|
"electron": "^35.1.2",
|
||||||
|
"vite-plugin-electron": "^0.28.8",
|
||||||
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
"tailwindcss": "^4.1.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/App.vue
Normal file
19
src/App.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useWorkspaceStore } from './stores/workspace'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await workspaceStore.initWorkspace()
|
||||||
|
if (!workspaceStore.path) {
|
||||||
|
// No workspace selected, stay on welcome page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
44
src/components/common/ResizeHandle.vue
Normal file
44
src/components/common/ResizeHandle.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
function onMouseDown(e) {
|
||||||
|
isDragging.value = true
|
||||||
|
const startX = e.clientX
|
||||||
|
const startWidth = uiStore.leftWidth
|
||||||
|
|
||||||
|
function onMouseMove(e) {
|
||||||
|
const delta = e.clientX - startX
|
||||||
|
uiStore.setLeftWidth(startWidth + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isDragging.value = false
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="w-1 h-full flex-shrink-0 cursor-col-resize group relative"
|
||||||
|
:style="{ backgroundColor: 'var(--p-surface-200)' }"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 -left-1 -right-1 group-hover:bg-blue-400/20 transition-colors"
|
||||||
|
:class="{ 'bg-blue-400/20': isDragging }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
19
src/components/left-area/AddNoteButton.vue
Normal file
19
src/components/left-area/AddNoteButton.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: 'var(--p-primary-500)',
|
||||||
|
color: '#ffffff',
|
||||||
|
}"
|
||||||
|
@click="emit('click')"
|
||||||
|
title="新建笔记"
|
||||||
|
>
|
||||||
|
<Plus :size="16" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
73
src/components/left-area/LeftArea.vue
Normal file
73
src/components/left-area/LeftArea.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import { useNotesStore } from '@/stores/notes'
|
||||||
|
import { useNotebooksStore } from '@/stores/notebooks'
|
||||||
|
import UserProfile from './UserProfile.vue'
|
||||||
|
import SearchInput from './SearchInput.vue'
|
||||||
|
import AddNoteButton from './AddNoteButton.vue'
|
||||||
|
import MenuItem from './MenuItem.vue'
|
||||||
|
import NotebookTree from './NotebookTree.vue'
|
||||||
|
import { FileText, Tag, Settings, Trash2, PanelLeftClose } from 'lucide-vue-next'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
const notesStore = useNotesStore()
|
||||||
|
const notebooksStore = useNotebooksStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
async function handleSearch(query) {
|
||||||
|
notesStore.setFilter('search', query)
|
||||||
|
router.push({ name: 'search', query: { q: query } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddNote() {
|
||||||
|
const note = await notesStore.createNote()
|
||||||
|
if (note) {
|
||||||
|
router.push({ name: 'note-detail', params: { id: note.id } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- 用户信息 + 折叠按钮 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b" :style="{ borderColor: 'var(--p-surface-200)' }">
|
||||||
|
<UserProfile />
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md hover:bg-[var(--p-surface-100)] transition-colors"
|
||||||
|
@click="uiStore.toggleLeft()"
|
||||||
|
title="收起侧边栏"
|
||||||
|
>
|
||||||
|
<PanelLeftClose :size="18" :style="{ color: 'var(--p-surface-500)' }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索框 + 新建按钮 -->
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2">
|
||||||
|
<SearchInput class="flex-1" @search="handleSearch" />
|
||||||
|
<AddNoteButton @click="handleAddNote" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 菜单项 -->
|
||||||
|
<nav class="flex-1 overflow-y-auto px-2">
|
||||||
|
<MenuItem label="笔记" :icon="FileText" @click="router.push({ name: 'notes' })" />
|
||||||
|
<MenuItem label="标签" :icon="Tag" @click="router.push({ name: 'tags' })" />
|
||||||
|
|
||||||
|
<!-- 笔记本区域 -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider" :style="{ color: 'var(--p-surface-400)' }">
|
||||||
|
笔记本
|
||||||
|
</div>
|
||||||
|
<NotebookTree />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 底部固定 -->
|
||||||
|
<div class="border-t px-2 py-2" :style="{ borderColor: 'var(--p-surface-200)' }">
|
||||||
|
<MenuItem label="设置" :icon="Settings" @click="router.push({ name: 'settings' })" />
|
||||||
|
<MenuItem label="废纸篓" :icon="Trash2" @click="router.push({ name: 'trash' })" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
src/components/left-area/MenuItem.vue
Normal file
26
src/components/left-area/MenuItem.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: { type: String, required: true },
|
||||||
|
icon: { type: Object, default: null },
|
||||||
|
active: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors"
|
||||||
|
:class="active ? 'font-medium' : ''"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: active ? 'var(--p-primary-50)' : 'transparent',
|
||||||
|
color: active ? 'var(--p-primary-700)' : 'var(--p-surface-600)',
|
||||||
|
}"
|
||||||
|
@click="emit('click')"
|
||||||
|
@mouseenter="$event.target.style.backgroundColor = active ? 'var(--p-primary-50)' : 'var(--p-surface-100)'"
|
||||||
|
@mouseleave="$event.target.style.backgroundColor = active ? 'var(--p-primary-50)' : 'transparent'"
|
||||||
|
>
|
||||||
|
<component v-if="icon" :is="icon" :size="18" />
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
23
src/components/left-area/NoteItem.vue
Normal file
23
src/components/left-area/NoteItem.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
import { FileText } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
note: { type: Object, required: true },
|
||||||
|
level: { type: Number, default: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors"
|
||||||
|
:style="{ paddingLeft: (level * 12 + 8) + 'px', color: 'var(--p-surface-600)' }"
|
||||||
|
@click="emit('click')"
|
||||||
|
@mouseenter="$event.target.style.backgroundColor = 'var(--p-surface-100)'"
|
||||||
|
@mouseleave="$event.target.style.backgroundColor = 'transparent'"
|
||||||
|
>
|
||||||
|
<FileText :size="14" :style="{ color: 'var(--p-surface-400)' }" />
|
||||||
|
<span class="truncate">{{ note.title }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
86
src/components/left-area/NotebookNode.vue
Normal file
86
src/components/left-area/NotebookNode.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useNotebooksStore } from '@/stores/notebooks'
|
||||||
|
import { useNotesStore } from '@/stores/notes'
|
||||||
|
import { ChevronRight, Folder, FolderOpen } from 'lucide-vue-next'
|
||||||
|
import NoteItem from './NoteItem.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notebook: { type: Object, required: true },
|
||||||
|
level: { type: Number, default: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const notebooksStore = useNotebooksStore()
|
||||||
|
const notesStore = useNotesStore()
|
||||||
|
const notes = ref([])
|
||||||
|
|
||||||
|
const isExpanded = () => notebooksStore.isExpanded(props.notebook.path)
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
notebooksStore.toggleExpand(props.notebook.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotes() {
|
||||||
|
const entries = await window.tunjiAPI.file.list(props.notebook.path)
|
||||||
|
notes.value = entries
|
||||||
|
.filter(e => e.isFile && e.name.endsWith('.md'))
|
||||||
|
.map(e => ({
|
||||||
|
id: e.name.replace('.md', ''),
|
||||||
|
title: e.name.replace('.md', ''),
|
||||||
|
filePath: e.path,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNote(note) {
|
||||||
|
router.push({ name: 'note-detail', params: { id: note.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadNotes)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 笔记本节点 -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-sm transition-colors"
|
||||||
|
:style="{ paddingLeft: (level * 12 + 8) + 'px', color: 'var(--p-surface-600)' }"
|
||||||
|
@click="toggle"
|
||||||
|
@mouseenter="$event.target.style.backgroundColor = 'var(--p-surface-100)'"
|
||||||
|
@mouseleave="$event.target.style.backgroundColor = 'transparent'"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
:size="14"
|
||||||
|
class="transition-transform flex-shrink-0"
|
||||||
|
:class="{ 'rotate-90': isExpanded() }"
|
||||||
|
:style="{ color: 'var(--p-surface-400)' }"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
:is="isExpanded() ? FolderOpen : Folder"
|
||||||
|
:size="16"
|
||||||
|
:style="{ color: 'var(--p-primary-500)' }"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ notebook.name }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 子内容 -->
|
||||||
|
<div v-if="isExpanded()">
|
||||||
|
<!-- 子笔记本 -->
|
||||||
|
<NotebookNode
|
||||||
|
v-for="child in notebook.children"
|
||||||
|
:key="child.path"
|
||||||
|
:notebook="child"
|
||||||
|
:level="level + 1"
|
||||||
|
/>
|
||||||
|
<!-- 笔记列表 -->
|
||||||
|
<NoteItem
|
||||||
|
v-for="note in notes"
|
||||||
|
:key="note.id"
|
||||||
|
:note="note"
|
||||||
|
:level="level + 1"
|
||||||
|
@click="openNote(note)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
33
src/components/left-area/NotebookTree.vue
Normal file
33
src/components/left-area/NotebookTree.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useNotebooksStore } from '@/stores/notebooks'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import NotebookNode from './NotebookNode.vue'
|
||||||
|
|
||||||
|
const notebooksStore = useNotebooksStore()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (workspaceStore.path) {
|
||||||
|
await notebooksStore.fetchNotebooks()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<NotebookNode
|
||||||
|
v-for="notebook in notebooksStore.notebooks"
|
||||||
|
:key="notebook.path"
|
||||||
|
:notebook="notebook"
|
||||||
|
:level="0"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="notebooksStore.notebooks.length === 0"
|
||||||
|
class="px-3 py-4 text-xs text-center"
|
||||||
|
:style="{ color: 'var(--p-surface-400)' }"
|
||||||
|
>
|
||||||
|
暂无笔记本
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
src/components/left-area/SearchInput.vue
Normal file
38
src/components/left-area/SearchInput.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const emit = defineEmits(['search'])
|
||||||
|
const query = ref('')
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
emit('search', query.value)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
:size="14"
|
||||||
|
class="absolute left-2.5 top-1/2 -translate-y-1/2"
|
||||||
|
:style="{ color: 'var(--p-surface-400)' }"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索笔记..."
|
||||||
|
class="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border outline-none transition-colors"
|
||||||
|
:style="{
|
||||||
|
borderColor: 'var(--p-surface-200)',
|
||||||
|
backgroundColor: 'var(--p-surface-0)',
|
||||||
|
color: 'var(--p-surface-700)',
|
||||||
|
}"
|
||||||
|
@input="onInput"
|
||||||
|
@keydown.enter="emit('search', query)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
src/components/left-area/UserProfile.vue
Normal file
15
src/components/left-area/UserProfile.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup>
|
||||||
|
import { User } from 'lucide-vue-next'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-full flex items-center justify-center"
|
||||||
|
:style="{ backgroundColor: 'var(--p-primary-100)', color: 'var(--p-primary-600)' }"
|
||||||
|
>
|
||||||
|
<User :size="16" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium" :style="{ color: 'var(--p-surface-700)' }">Tunji</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
src/layouts/MainLayout.vue
Normal file
28
src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import LeftArea from '@/components/left-area/LeftArea.vue'
|
||||||
|
import ResizeHandle from '@/components/common/ResizeHandle.vue'
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full w-full overflow-hidden">
|
||||||
|
<!-- 左侧栏 -->
|
||||||
|
<aside
|
||||||
|
v-show="!uiStore.leftCollapsed"
|
||||||
|
class="flex-shrink-0 h-full overflow-hidden border-r"
|
||||||
|
:style="{ width: uiStore.leftWidth + 'px', borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
|
||||||
|
>
|
||||||
|
<LeftArea />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 拖拽手柄 -->
|
||||||
|
<ResizeHandle v-show="!uiStore.leftCollapsed" />
|
||||||
|
|
||||||
|
<!-- 右侧区域 -->
|
||||||
|
<main class="flex-1 h-full overflow-hidden" :style="{ backgroundColor: 'var(--p-surface-0)' }">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
src/main.js
Normal file
16
src/main.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
unstyled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
57
src/router/index.js
Normal file
57
src/router/index.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'welcome',
|
||||||
|
component: () => import('@/views/WelcomeView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'notes',
|
||||||
|
name: 'notes',
|
||||||
|
component: () => import('@/views/NotesListView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tags',
|
||||||
|
name: 'tags',
|
||||||
|
component: () => import('@/views/TagsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tags/:tagId',
|
||||||
|
name: 'tag-detail',
|
||||||
|
component: () => import('@/views/TagsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'note/:id',
|
||||||
|
name: 'note-detail',
|
||||||
|
component: () => import('@/views/NoteDetailView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
name: 'search',
|
||||||
|
component: () => import('@/views/SearchView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'trash',
|
||||||
|
name: 'trash',
|
||||||
|
component: () => import('@/views/TrashView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
69
src/stores/notebooks.js
Normal file
69
src/stores/notebooks.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useWorkspaceStore } from './workspace'
|
||||||
|
|
||||||
|
export const useNotebooksStore = defineStore('notebooks', () => {
|
||||||
|
const notebooks = ref([])
|
||||||
|
const expandedPaths = ref(new Set())
|
||||||
|
|
||||||
|
async function fetchNotebooks() {
|
||||||
|
const workspace = useWorkspaceStore()
|
||||||
|
if (!workspace.path) return
|
||||||
|
const entries = await window.tunjiAPI.file.list(workspace.path)
|
||||||
|
const dirs = entries.filter(e => e.isDirectory && !e.name.startsWith('.'))
|
||||||
|
notebooks.value = dirs.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
path: e.path,
|
||||||
|
parentPath: workspace.path,
|
||||||
|
children: [],
|
||||||
|
}))
|
||||||
|
// Recursively load children
|
||||||
|
for (const nb of notebooks.value) {
|
||||||
|
await loadChildren(nb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChildren(notebook) {
|
||||||
|
const entries = await window.tunjiAPI.file.list(notebook.path)
|
||||||
|
const dirs = entries.filter(e => e.isDirectory && !e.name.startsWith('.'))
|
||||||
|
notebook.children = dirs.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
path: e.path,
|
||||||
|
parentPath: notebook.path,
|
||||||
|
children: [],
|
||||||
|
}))
|
||||||
|
for (const child of notebook.children) {
|
||||||
|
await loadChildren(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(path) {
|
||||||
|
if (expandedPaths.value.has(path)) {
|
||||||
|
expandedPaths.value.delete(path)
|
||||||
|
} else {
|
||||||
|
expandedPaths.value.add(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(path) {
|
||||||
|
return expandedPaths.value.has(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNotebook(name, parentPath) {
|
||||||
|
const workspace = useWorkspaceStore()
|
||||||
|
if (!workspace.path) return
|
||||||
|
const dir = parentPath || workspace.path
|
||||||
|
const newPath = `${dir}/${name}`
|
||||||
|
await window.tunjiAPI.file.mkdir(newPath)
|
||||||
|
await fetchNotebooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notebooks,
|
||||||
|
expandedPaths,
|
||||||
|
fetchNotebooks,
|
||||||
|
toggleExpand,
|
||||||
|
isExpanded,
|
||||||
|
createNotebook,
|
||||||
|
}
|
||||||
|
})
|
||||||
96
src/stores/notes.js
Normal file
96
src/stores/notes.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useWorkspaceStore } from './workspace'
|
||||||
|
|
||||||
|
export const useNotesStore = defineStore('notes', () => {
|
||||||
|
const notes = ref([])
|
||||||
|
const currentNote = ref(null)
|
||||||
|
const viewMode = ref('list') // 'list' | 'grid'
|
||||||
|
const filter = ref({
|
||||||
|
notebook: null,
|
||||||
|
tag: null,
|
||||||
|
search: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredNotes = computed(() => {
|
||||||
|
let result = notes.value
|
||||||
|
if (filter.value.notebook) {
|
||||||
|
result = result.filter(n => n.notebookPath === filter.value.notebook)
|
||||||
|
}
|
||||||
|
if (filter.value.tag) {
|
||||||
|
result = result.filter(n => n.tags?.includes(filter.value.tag))
|
||||||
|
}
|
||||||
|
if (filter.value.search) {
|
||||||
|
const q = filter.value.search.toLowerCase()
|
||||||
|
result = result.filter(n =>
|
||||||
|
n.title.toLowerCase().includes(q) || n.content?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchNotes() {
|
||||||
|
const workspace = useWorkspaceStore()
|
||||||
|
if (!workspace.path) return
|
||||||
|
const entries = await window.tunjiAPI.file.list(workspace.path)
|
||||||
|
const mdFiles = entries.filter(e => e.isFile && e.name.endsWith('.md'))
|
||||||
|
notes.value = mdFiles.map(e => ({
|
||||||
|
id: e.name.replace('.md', ''),
|
||||||
|
title: e.name.replace('.md', ''),
|
||||||
|
filePath: e.path,
|
||||||
|
notebookPath: null,
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNote(id) {
|
||||||
|
const note = notes.value.find(n => n.id === id)
|
||||||
|
if (note) {
|
||||||
|
const content = await window.tunjiAPI.file.read(note.filePath)
|
||||||
|
currentNote.value = { ...note, content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNote(notebookPath) {
|
||||||
|
const workspace = useWorkspaceStore()
|
||||||
|
if (!workspace.path) return null
|
||||||
|
const dir = notebookPath || workspace.path
|
||||||
|
const title = `新笔记-${Date.now()}`
|
||||||
|
const filePath = `${dir}/${title}.md`
|
||||||
|
const content = `---\ntitle: "${title}"\ncreated: "${new Date().toISOString()}"\nupdated: "${new Date().toISOString()}"\ntags: []\n---\n\n# ${title}\n\n`
|
||||||
|
await window.tunjiAPI.file.write(filePath, content)
|
||||||
|
await fetchNotes()
|
||||||
|
return notes.value.find(n => n.filePath === filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNote(id, content) {
|
||||||
|
const note = notes.value.find(n => n.id === id)
|
||||||
|
if (note) {
|
||||||
|
await window.tunjiAPI.file.write(note.filePath, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote(id) {
|
||||||
|
// MVP: placeholder
|
||||||
|
notes.value = notes.value.filter(n => n.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(key, value) {
|
||||||
|
filter.value[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notes,
|
||||||
|
currentNote,
|
||||||
|
viewMode,
|
||||||
|
filter,
|
||||||
|
filteredNotes,
|
||||||
|
fetchNotes,
|
||||||
|
openNote,
|
||||||
|
createNote,
|
||||||
|
saveNote,
|
||||||
|
deleteNote,
|
||||||
|
setFilter,
|
||||||
|
}
|
||||||
|
})
|
||||||
23
src/stores/ui.js
Normal file
23
src/stores/ui.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useUiStore = defineStore('ui', () => {
|
||||||
|
const leftCollapsed = ref(false)
|
||||||
|
const leftWidth = ref(280)
|
||||||
|
const theme = ref('light')
|
||||||
|
|
||||||
|
function toggleLeft() {
|
||||||
|
leftCollapsed.value = !leftCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLeftWidth(width) {
|
||||||
|
leftWidth.value = Math.max(240, Math.min(480, width))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(newTheme) {
|
||||||
|
theme.value = newTheme
|
||||||
|
document.documentElement.classList.toggle('dark', newTheme === 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { leftCollapsed, leftWidth, theme, toggleLeft, setLeftWidth, setTheme }
|
||||||
|
})
|
||||||
32
src/stores/workspace.js
Normal file
32
src/stores/workspace.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||||
|
const path = ref(null)
|
||||||
|
const isReady = ref(false)
|
||||||
|
|
||||||
|
async function initWorkspace() {
|
||||||
|
const savedPath = await window.tunjiAPI.workspace.get()
|
||||||
|
if (savedPath) {
|
||||||
|
path.value = savedPath
|
||||||
|
isReady.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectWorkspace() {
|
||||||
|
const selected = await window.tunjiAPI.workspace.select()
|
||||||
|
if (selected) {
|
||||||
|
path.value = selected
|
||||||
|
await window.tunjiAPI.workspace.ensureStructure(selected)
|
||||||
|
isReady.value = true
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
function $reset() {
|
||||||
|
path.value = null
|
||||||
|
isReady.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path, isReady, initWorkspace, selectWorkspace, $reset }
|
||||||
|
})
|
||||||
52
src/styles/main.css
Normal file
52
src/styles/main.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./theme.css";
|
||||||
|
|
||||||
|
/* 全局基础样式 */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
|
background-color: var(--p-surface-0);
|
||||||
|
color: var(--p-surface-700);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--p-surface-300);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--p-surface-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中文本颜色 */
|
||||||
|
::selection {
|
||||||
|
background-color: var(--p-primary-200);
|
||||||
|
color: var(--p-primary-900);
|
||||||
|
}
|
||||||
52
src/styles/theme.css
Normal file
52
src/styles/theme.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/* Tunji 主题色系统 - 参考 PrimeVue Lara 主题 */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Primary 蓝色 */
|
||||||
|
--p-primary-50: #eff6ff;
|
||||||
|
--p-primary-100: #dbeafe;
|
||||||
|
--p-primary-200: #bfdbfe;
|
||||||
|
--p-primary-300: #93c5fd;
|
||||||
|
--p-primary-400: #60a5fa;
|
||||||
|
--p-primary-500: #3b82f6;
|
||||||
|
--p-primary-600: #2563eb;
|
||||||
|
--p-primary-700: #1d4ed8;
|
||||||
|
--p-primary-800: #1e40af;
|
||||||
|
--p-primary-900: #1e3a8a;
|
||||||
|
|
||||||
|
/* Surface 中性灰 */
|
||||||
|
--p-surface-0: #ffffff;
|
||||||
|
--p-surface-50: #f8fafc;
|
||||||
|
--p-surface-100: #f1f5f9;
|
||||||
|
--p-surface-200: #e2e8f0;
|
||||||
|
--p-surface-300: #cbd5e1;
|
||||||
|
--p-surface-400: #94a3b8;
|
||||||
|
--p-surface-500: #64748b;
|
||||||
|
--p-surface-600: #475569;
|
||||||
|
--p-surface-700: #334155;
|
||||||
|
--p-surface-800: #1e293b;
|
||||||
|
--p-surface-900: #0f172a;
|
||||||
|
|
||||||
|
/* 语义色 */
|
||||||
|
--p-success-500: #22c55e;
|
||||||
|
--p-success-600: #16a34a;
|
||||||
|
--p-warn-500: #f59e0b;
|
||||||
|
--p-warn-600: #d97706;
|
||||||
|
--p-danger-500: #ef4444;
|
||||||
|
--p-danger-600: #dc2626;
|
||||||
|
--p-info-500: #06b6d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式 */
|
||||||
|
.dark {
|
||||||
|
--p-surface-0: #0f172a;
|
||||||
|
--p-surface-50: #1e293b;
|
||||||
|
--p-surface-100: #334155;
|
||||||
|
--p-surface-200: #475569;
|
||||||
|
--p-surface-300: #64748b;
|
||||||
|
--p-surface-400: #94a3b8;
|
||||||
|
--p-surface-500: #cbd5e1;
|
||||||
|
--p-surface-600: #e2e8f0;
|
||||||
|
--p-surface-700: #f1f5f9;
|
||||||
|
--p-surface-800: #f8fafc;
|
||||||
|
--p-surface-900: #ffffff;
|
||||||
|
}
|
||||||
156
src/views/NoteDetailView.vue
Normal file
156
src/views/NoteDetailView.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useNotesStore } from '@/stores/notes'
|
||||||
|
import { ArrowLeft, Edit3, Eye, Columns } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const notesStore = useNotesStore()
|
||||||
|
|
||||||
|
const note = ref(null)
|
||||||
|
const content = ref('')
|
||||||
|
const editMode = ref('edit') // 'edit' | 'preview' | 'split'
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadNote()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.params.id, loadNote)
|
||||||
|
|
||||||
|
async function loadNote() {
|
||||||
|
const id = route.params.id
|
||||||
|
if (!id) return
|
||||||
|
await notesStore.openNote(id)
|
||||||
|
if (notesStore.currentNote) {
|
||||||
|
note.value = notesStore.currentNote
|
||||||
|
content.value = notesStore.currentNote.content || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!note.value) return
|
||||||
|
isSaving.value = true
|
||||||
|
await notesStore.saveNote(note.value.id, content.value)
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save on content change
|
||||||
|
let saveTimer = null
|
||||||
|
watch(content, () => {
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(handleSave, 2000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- 头部工具栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-6 py-3 border-b"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)' }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:style="{ color: 'var(--p-surface-500)' }"
|
||||||
|
@click="router.back()"
|
||||||
|
>
|
||||||
|
<ArrowLeft :size="18" />
|
||||||
|
</button>
|
||||||
|
<h1
|
||||||
|
v-if="note"
|
||||||
|
class="text-lg font-semibold truncate"
|
||||||
|
:style="{ color: 'var(--p-surface-800)' }"
|
||||||
|
>
|
||||||
|
{{ note.title }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视图模式切换 -->
|
||||||
|
<div class="flex items-center gap-1 rounded-lg p-0.5" :style="{ backgroundColor: 'var(--p-surface-100)' }">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: editMode === 'edit' ? 'var(--p-surface-0)' : 'transparent',
|
||||||
|
color: editMode === 'edit' ? 'var(--p-primary-600)' : 'var(--p-surface-500)',
|
||||||
|
}"
|
||||||
|
@click="editMode = 'edit'"
|
||||||
|
title="编辑模式"
|
||||||
|
>
|
||||||
|
<Edit3 :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: editMode === 'preview' ? 'var(--p-surface-0)' : 'transparent',
|
||||||
|
color: editMode === 'preview' ? 'var(--p-primary-600)' : 'var(--p-surface-500)',
|
||||||
|
}"
|
||||||
|
@click="editMode = 'preview'"
|
||||||
|
title="预览模式"
|
||||||
|
>
|
||||||
|
<Eye :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: editMode === 'split' ? 'var(--p-surface-0)' : 'transparent',
|
||||||
|
color: editMode === 'split' ? 'var(--p-primary-600)' : 'var(--p-surface-500)',
|
||||||
|
}"
|
||||||
|
@click="editMode = 'split'"
|
||||||
|
title="双栏模式"
|
||||||
|
>
|
||||||
|
<Columns :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<div v-if="!note" class="flex items-center justify-center h-full">
|
||||||
|
<p :style="{ color: 'var(--p-surface-400)' }">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="h-full flex">
|
||||||
|
<!-- 编辑区 -->
|
||||||
|
<div
|
||||||
|
v-if="editMode === 'edit' || editMode === 'split'"
|
||||||
|
class="h-full overflow-y-auto"
|
||||||
|
:class="editMode === 'split' ? 'w-1/2 border-r' : 'w-full'"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)' }"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="content"
|
||||||
|
class="w-full h-full p-6 text-sm font-mono resize-none outline-none"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: 'var(--p-surface-0)',
|
||||||
|
color: 'var(--p-surface-700)',
|
||||||
|
}"
|
||||||
|
placeholder="开始编写 Markdown..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div
|
||||||
|
v-if="editMode === 'preview' || editMode === 'split'"
|
||||||
|
class="h-full overflow-y-auto p-6"
|
||||||
|
:class="editMode === 'split' ? 'w-1/2' : 'w-full'"
|
||||||
|
>
|
||||||
|
<div class="prose prose-sm max-w-none">
|
||||||
|
<pre class="whitespace-pre-wrap text-sm" :style="{ color: 'var(--p-surface-700)' }">{{ content }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-6 py-1.5 border-t text-xs"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', color: 'var(--p-surface-400)' }"
|
||||||
|
>
|
||||||
|
<span>{{ isSaving ? '保存中...' : '已保存' }}</span>
|
||||||
|
<span>{{ content.length }} 字符</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
108
src/views/NotesListView.vue
Normal file
108
src/views/NotesListView.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useNotesStore } from '@/stores/notes'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import { FileText, LayoutGrid, List } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const notesStore = useNotesStore()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (workspaceStore.path) {
|
||||||
|
await notesStore.fetchNotes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openNote(note) {
|
||||||
|
router.push({ name: 'note-detail', params: { id: note.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleView() {
|
||||||
|
notesStore.viewMode = notesStore.viewMode === 'list' ? 'grid' : 'list'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-6 py-4 border-b"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)' }"
|
||||||
|
>
|
||||||
|
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">全部笔记</h1>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:style="{ color: 'var(--p-surface-500)' }"
|
||||||
|
@click="toggleView"
|
||||||
|
:title="notesStore.viewMode === 'list' ? '卡片视图' : '列表视图'"
|
||||||
|
>
|
||||||
|
<component :is="notesStore.viewMode === 'list' ? LayoutGrid : List" :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<!-- 无笔记提示 -->
|
||||||
|
<div
|
||||||
|
v-if="notesStore.filteredNotes.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 rounded-xl flex items-center justify-center mb-4"
|
||||||
|
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
|
||||||
|
>
|
||||||
|
<FileText :size="32" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">
|
||||||
|
{{ workspaceStore.path ? '暂无笔记,点击左侧 + 创建' : '请先选择工作目录' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表视图 -->
|
||||||
|
<div v-else-if="notesStore.viewMode === 'list'" class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="note in notesStore.filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg cursor-pointer transition-colors"
|
||||||
|
:style="{ color: 'var(--p-surface-700)' }"
|
||||||
|
@click="openNote(note)"
|
||||||
|
@mouseenter="$event.currentTarget.style.backgroundColor = 'var(--p-surface-50)'"
|
||||||
|
@mouseleave="$event.currentTarget.style.backgroundColor = 'transparent'"
|
||||||
|
>
|
||||||
|
<FileText :size="18" :style="{ color: 'var(--p-surface-400)' }" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate">{{ note.title }}</div>
|
||||||
|
<div class="text-xs mt-0.5" :style="{ color: 'var(--p-surface-400)' }">
|
||||||
|
{{ note.filePath }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 卡片视图 -->
|
||||||
|
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="note in notesStore.filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="p-4 rounded-xl border cursor-pointer transition-colors"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
|
||||||
|
@click="openNote(note)"
|
||||||
|
@mouseenter="$event.currentTarget.style.borderColor = 'var(--p-primary-300)'"
|
||||||
|
@mouseleave="$event.currentTarget.style.borderColor = 'var(--p-surface-200)'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<FileText :size="16" :style="{ color: 'var(--p-primary-500)' }" />
|
||||||
|
<span class="text-sm font-medium truncate" :style="{ color: 'var(--p-surface-700)' }">
|
||||||
|
{{ note.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs truncate" :style="{ color: 'var(--p-surface-400)' }">
|
||||||
|
{{ note.filePath }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
77
src/views/SearchView.vue
Normal file
77
src/views/SearchView.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useNotesStore } from '@/stores/notes'
|
||||||
|
import { Search, FileText } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const notesStore = useNotesStore()
|
||||||
|
|
||||||
|
const query = ref(route.query.q || '')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (query.value) {
|
||||||
|
notesStore.setFilter('search', query.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.q, (newQ) => {
|
||||||
|
query.value = newQ || ''
|
||||||
|
notesStore.setFilter('search', query.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function openNote(note) {
|
||||||
|
router.push({ name: 'note-detail', params: { id: note.id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-6 py-4 border-b"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)' }"
|
||||||
|
>
|
||||||
|
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">
|
||||||
|
搜索{{ query ? `: ${query}` : '' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div v-if="!query" class="flex flex-col items-center justify-center h-full">
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 rounded-xl flex items-center justify-center mb-4"
|
||||||
|
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
|
||||||
|
>
|
||||||
|
<Search :size="32" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">输入关键词搜索笔记</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="notesStore.filteredNotes.length === 0" class="flex flex-col items-center justify-center h-full">
|
||||||
|
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">未找到匹配的笔记</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="note in notesStore.filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg cursor-pointer transition-colors"
|
||||||
|
@click="openNote(note)"
|
||||||
|
@mouseenter="$event.currentTarget.style.backgroundColor = 'var(--p-surface-50)'"
|
||||||
|
@mouseleave="$event.currentTarget.style.backgroundColor = 'transparent'"
|
||||||
|
>
|
||||||
|
<FileText :size="18" :style="{ color: 'var(--p-surface-400)' }" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate" :style="{ color: 'var(--p-surface-700)' }">
|
||||||
|
{{ note.title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-0.5" :style="{ color: 'var(--p-surface-400)' }">
|
||||||
|
{{ note.filePath }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
98
src/views/SettingsView.vue
Normal file
98
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import { Settings, Sun, Moon, FolderOpen } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
uiStore.setTheme(uiStore.theme === 'light' ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeWorkspace() {
|
||||||
|
await workspaceStore.selectWorkspace()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-6 py-4 border-b"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)' }"
|
||||||
|
>
|
||||||
|
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">设置</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
<!-- 主题设置 -->
|
||||||
|
<div
|
||||||
|
class="rounded-xl p-5 border"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<component
|
||||||
|
:is="uiStore.theme === 'light' ? Sun : Moon"
|
||||||
|
:size="18"
|
||||||
|
:style="{ color: 'var(--p-primary-500)' }"
|
||||||
|
/>
|
||||||
|
<h2 class="text-sm font-semibold" :style="{ color: 'var(--p-surface-700)' }">外观</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm" :style="{ color: 'var(--p-surface-600)' }">深色模式</span>
|
||||||
|
<button
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:style="{ backgroundColor: uiStore.theme === 'dark' ? 'var(--p-primary-500)' : 'var(--p-surface-300)' }"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
|
||||||
|
:class="{ 'translate-x-5': uiStore.theme === 'dark' }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工作目录设置 -->
|
||||||
|
<div
|
||||||
|
class="rounded-xl p-5 border"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<FolderOpen :size="18" :style="{ color: 'var(--p-primary-500)' }" />
|
||||||
|
<h2 class="text-sm font-semibold" :style="{ color: 'var(--p-surface-700)' }">工作目录</h2>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs mb-1" :style="{ color: 'var(--p-surface-400)' }">当前目录</p>
|
||||||
|
<p class="text-sm font-mono" :style="{ color: 'var(--p-surface-600)' }">
|
||||||
|
{{ workspaceStore.path || '未选择' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', color: 'var(--p-surface-600)' }"
|
||||||
|
@click="changeWorkspace"
|
||||||
|
>
|
||||||
|
更换目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关于 -->
|
||||||
|
<div
|
||||||
|
class="rounded-xl p-5 border"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Settings :size="18" :style="{ color: 'var(--p-primary-500)' }" />
|
||||||
|
<h2 class="text-sm font-semibold" :style="{ color: 'var(--p-surface-700)' }">关于</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" :style="{ color: 'var(--p-surface-600)' }">Tunji v0.1.0</p>
|
||||||
|
<p class="text-xs mt-1" :style="{ color: 'var(--p-surface-400)' }">
|
||||||
|
类印象笔记 + Typora 的桌面笔记应用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
25
src/views/TagsView.vue
Normal file
25
src/views/TagsView.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Tag } from 'lucide-vue-next'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-6 py-4 border-b"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)' }"
|
||||||
|
>
|
||||||
|
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">标签</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 rounded-xl flex items-center justify-center mx-auto mb-4"
|
||||||
|
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
|
||||||
|
>
|
||||||
|
<Tag :size="32" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">标签功能开发中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
src/views/TrashView.vue
Normal file
28
src/views/TrashView.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Trash2 } from 'lucide-vue-next'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-6 py-4 border-b"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)' }"
|
||||||
|
>
|
||||||
|
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">废纸篓</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 rounded-xl flex items-center justify-center mx-auto mb-4"
|
||||||
|
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
|
||||||
|
>
|
||||||
|
<Trash2 :size="32" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">废纸篓为空</p>
|
||||||
|
<p class="text-xs mt-1" :style="{ color: 'var(--p-surface-400)' }">
|
||||||
|
删除的笔记将在这里保留 30 天
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
87
src/views/WelcomeView.vue
Normal file
87
src/views/WelcomeView.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import { FolderOpen, FileText } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
|
async function selectWorkspace() {
|
||||||
|
const path = await workspaceStore.selectWorkspace()
|
||||||
|
if (path) {
|
||||||
|
router.push({ name: 'notes' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center h-full" :style="{ backgroundColor: 'var(--p-surface-0)' }">
|
||||||
|
<div class="text-center max-w-md px-8">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div
|
||||||
|
class="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center mb-4"
|
||||||
|
:style="{ backgroundColor: 'var(--p-primary-50)', color: 'var(--p-primary-500)' }"
|
||||||
|
>
|
||||||
|
<FileText :size="40" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold" :style="{ color: 'var(--p-surface-800)' }">Tunji</h1>
|
||||||
|
<p class="mt-2 text-sm" :style="{ color: 'var(--p-surface-500)' }">
|
||||||
|
类印象笔记 + Typora 的桌面笔记应用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工作目录选择 -->
|
||||||
|
<div
|
||||||
|
v-if="!workspaceStore.path"
|
||||||
|
class="rounded-xl p-6 border"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
|
||||||
|
>
|
||||||
|
<p class="mb-4 text-sm" :style="{ color: 'var(--p-surface-600)' }">
|
||||||
|
请选择一个目录作为你的工作空间,所有笔记将保存在该目录下。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium text-white transition-colors"
|
||||||
|
:style="{ backgroundColor: 'var(--p-primary-500)' }"
|
||||||
|
@mouseenter="$event.target.style.backgroundColor = 'var(--p-primary-600)'"
|
||||||
|
@mouseleave="$event.target.style.backgroundColor = 'var(--p-primary-500)'"
|
||||||
|
@click="selectWorkspace"
|
||||||
|
>
|
||||||
|
<FolderOpen :size="18" />
|
||||||
|
选择工作目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已有工作目录 -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="rounded-xl p-4 border"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
|
||||||
|
>
|
||||||
|
<p class="text-xs mb-1" :style="{ color: 'var(--p-surface-400)' }">当前工作目录</p>
|
||||||
|
<p class="text-sm font-mono truncate" :style="{ color: 'var(--p-surface-700)' }">
|
||||||
|
{{ workspaceStore.path }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 justify-center">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium text-white transition-colors"
|
||||||
|
:style="{ backgroundColor: 'var(--p-primary-500)' }"
|
||||||
|
@click="router.push({ name: 'notes' })"
|
||||||
|
>
|
||||||
|
<FileText :size="16" />
|
||||||
|
查看笔记
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium border transition-colors"
|
||||||
|
:style="{ borderColor: 'var(--p-surface-200)', color: 'var(--p-surface-600)' }"
|
||||||
|
@click="selectWorkspace"
|
||||||
|
>
|
||||||
|
<FolderOpen :size="16" />
|
||||||
|
切换目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
30
vite.config.js
Normal file
30
vite.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import electron from 'vite-plugin-electron'
|
||||||
|
import renderer from 'vite-plugin-electron-renderer'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
electron([
|
||||||
|
{
|
||||||
|
entry: 'electron/main.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/preload.js',
|
||||||
|
onstart(args) {
|
||||||
|
args.reload()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
renderer(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user