feat(writeflow): 完成MVP第一阶段迭代,实现基础编辑器与文件管理

新增:
- 初始化Tauri+Vue3项目骨架,配置Vite别名与TypeScript
- 集成Milkdown Markdown编辑器,支持CommonMark标准渲染
- 实现自定义标题栏,包含主题切换与窗口控制功能
- 支持亮色/暗色双主题,通过CSS变量统一管理样式
- 新增工作区管理、文件树侧边栏,支持本地MD文件浏览
- 实现文件新建/打开/保存/另存为功能,支持快捷键操作
- 添加最近文件列表,使用localStorage持久化存储
- 新增Rust后端文件系统API,支持目录读取与文件读写
- 配置Tauri插件与权限,适配桌面文件操作需求

优化:
- 替换Ant Design为Naive UI组件库,提升UI风格一致性
- 重构App.vue整合所有核心功能,拆分Editor/TitleBar/Sidebar组件
- 配置vite路径别名与tsconfig路径映射
- 完善类型定义与错误处理逻辑
This commit is contained in:
Yuhang Wu 2026-07-15 13:45:25 +08:00
parent 7efa839f7a
commit c20fee000e
30 changed files with 6127 additions and 365 deletions

285
Vue3插件库精选.md Normal file
View File

@ -0,0 +1,285 @@
# Vue3 开发效率翻倍2026 年必备 20+ 实用 npm 插件库精选
Original 尔嵘 尔嵘
---
在 Vue3 + Vite 生态下,优质的 npm 插件能大幅简化开发流程、解决常见痛点、提升项目性能与交互体验。本文整理了前端开发、Vue3 专属、工具类、UI 增强、网络请求、表单/表格、可视化、工程化八大场景的实用插件,全部支持 Vue3 + TypeScript开箱即用附核心用途 + 快速上手代码,新手也能直接用。
---
## 一、Vue3 核心生态插件(必装)
### 1. Pinia - Vue3 官方状态管理库
替代 VuexVue3 官方推荐的状态管理,语法简洁、支持 TS、无需 mutations轻量化易上手。
```
npm install pinia
```
核心价值:全局状态共享,告别繁琐的 Vuex 语法,支持模块化、热更新。
### 2. Vue Router 4 - Vue3 官方路由
Vue3 专属路由管理器,支持动态路由、路由守卫、懒加载。
```
npm install vue-router@4
```
核心价值:单页应用路由跳转,项目必备基础插件。
### 3. @vueuse/core - Vue3 组合式工具集
Vue3 最强工具库,封装了上百个实用的 Composition API 工具函数,无需重复造轮子。
```
npm install @vueuse/core
```
常用功能:监听窗口大小、本地存储、防抖节流、鼠标跟踪、暗黑模式切换。
```html
<script setup>
import { useMouse, useLocalStorage, useDark, useToggle } from '@vueuse/core'
// 鼠标位置
const { x, y } = useMouse()
// 本地存储持久化
const theme = useLocalStorage('theme', 'light')
// 暗黑模式
const isDark = useDark()
const toggleDark = useToggle(isDark)
</script>
```
---
## 二、UI 组件库(高效搭建页面)
### 4. Element Plus - Vue3 主流 UI 库
最成熟的 Vue3 中后台 UI 组件库,组件齐全、文档完善、支持国际化。
```
npm install element-plus
```
适用场景:管理系统、企业级后台、常规业务页面。
### 5. Ant Design Vue 3.x - 阿里出品 UI 库
阿里开源的 Vue3 版本,设计规范,适合高端企业级项目。
```
npm install ant-design-vue
```
### 6. Naive UI - Vue3 高颜值 UI 库
TS 原生支持,主题定制极强,组件动画流畅,适合追求美观的项目。
```
npm install naive-ui
```
---
## 三、网络请求 & 数据处理
### 7. Axios - 前端请求神器
Vue 生态最常用的 HTTP 客户端,支持拦截器、取消请求、请求封装。
```
npm install axios
```
核心价值:统一管理接口请求、封装请求/响应拦截器token 校验、错误处理)。
### 8. qs - 参数序列化工具
配合 Axios 使用解决表单提交、GET 请求参数格式化问题。
```
npm install qs
```
```js
import qs from 'qs'
// qs.stringify / qs.parse
```
### 9. dayjs - 轻量级时间处理库
替代 Moment.js体积更小API 一致),格式化时间、计算时间差。
```
npm install dayjs
```
```js
import dayjs from 'dayjs'
dayjs().format('YYYY-MM-DD HH:mm:ss')
```
---
## 四、表单 & 表格(业务开发神器)
### 10. Vxe Table - Vue3 高性能表格
支持虚拟滚动、合并单元格、导出 Excel、树形表格大数据表格必备。
```
npm install vxe-table
```
### 11. Vue Use Form - 轻量级表单钩子
轻量化表单验证、状态管理,无 UI 依赖,搭配任何 UI 库都能用。
```
npm install @vueuse/form
```
### 12. vee-validate - Vue3 表单验证库
强大的表单验证工具,支持自定义规则、错误提示,适合复杂表单场景。
```
npm install vee-validate yup
```
---
## 五、交互 & 动画增强
### 13. animate.css - 开箱即用动画库
一行代码实现页面入场、交互动画,无需手写 CSS 动画。
```
npm install animate.css
```
### 14. @vueuse/motion - Vue3 动效工具
基于 Composition API 的流畅动画,支持滚动动画、过渡效果。
```
npm install @vueuse/motion
```
### 15. better-scroll - 移动端滚动优化
解决移动端滚动卡顿、下拉刷新、上拉加载问题,适配 Vue3。
```
npm install better-scroll
```
---
## 六、可视化 & 图表
### 16. ECharts - 百度开源图表库
支持折线图、柱状图、地图、仪表盘等全场景图表Vue3 完美适配。
```
npm install echarts
```
### 17. vue-echarts - ECharts Vue3 封装
简化 ECharts 在 Vue3 中的使用,无需手动操作 DOM。
```
npm install vue-echarts
```
---
## 七、工具类 & 实用函数
### 18. lodash-es - 模块化工具函数库
提供防抖、深拷贝、数组/对象处理等百种实用函数ES 模块版体积更小。
```
npm install lodash-es
```
```js
import { debounce, cloneDeep } from 'lodash-es'
```
### 19. js-cookie - Cookie 操作工具
简化浏览器 Cookie 的增删改查,适配登录态存储。
```
npm install js-cookie
```
### 20. nprogress - 页面加载进度条
路由切换、请求加载时显示顶部进度条,提升用户体验。
```
npm install nprogress
```
---
## 八、工程化 & 开发效率
### 21. unplugin-auto-import - 自动导入 API
Vue3、Pinia、VueUse 等 API 无需手动 import自动导入告别重复代码。
```
npm install unplugin-auto-import -D
```
### 22. unplugin-vue-components - 组件自动导入
UI 库组件、自定义组件无需手动引入,直接使用。
```
npm install unplugin-vue-components -D
```
### 23. vite-plugin-compression - 打包压缩
Vite 项目打包时自动生成 gzip/brotli 压缩包,提升页面加载速度。
```
npm install vite-plugin-compression -D
```
---
## 九、插件使用建议
1. **基础必备**Pinia + Vue Router 4 + Axios + @vueuse/core + dayjs
2. **UI 选型**:中后台选 Element Plus移动端选 Vant 4高颜值选 Naive UI
3. **性能优化**vite-plugin-compression + 虚拟滚动表格Vxe Table
4. **开发提效**自动导入插件unplugin 系列)+ lodash-es
---
## 总结
这篇插件库覆盖了 Vue3 开发从基础搭建到业务开发、性能优化的全流程,所有插件均稳定支持 Vue3 + TypeScript无兼容坑点。
- 新手优先安装核心生态 + 工具类插件,快速上手开发;
- 中大型项目可补充表单表格、可视化、工程化插件,提升团队效率;
- 所有插件均提供官方安装命令,直接复制即可使用。

7
part6/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "part6"
version = "0.1.0"

6
part6/Cargo.toml Normal file
View File

@ -0,0 +1,6 @@
[package]
name = "part6"
version = "0.1.0"
edition = "2024"
[dependencies]

3
part6/src/main.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}

File diff suppressed because it is too large Load Diff

57
tauri-app/CHANGELOG.md Normal file
View File

@ -0,0 +1,57 @@
# WriteFlow Changelog
## Phase 1 MVP 基础编辑器
### 迭代 1.1 项目骨架与编辑器内核 (2026-07-15)
#### 新增
- 配置 `vite.config.ts``tsconfig.json``@` 别名,指向 `src` 目录
- 集成 Milkdown 编辑器:`@milkdown/kit` + `@milkdown/vue`,配置 CommonMark 预设
- 创建 `Editor.vue` 编辑器组件,支持 v-model 双向绑定、历史记录 (history)、剪贴板 (clipboard)
- 实现纯键盘驱动所见即所得编辑(无工具栏)
- 自定义标题栏:含 WriteFlow Logo、主题切换按钮、窗口控制最小化/最大化/关闭)
- 使用 `@vicons/ionicons5` 图标库,通过 Naive UI `n-icon` 组件渲染(`CreateOutline`、`SunnyOutline`、`MoonOutline`、`RemoveOutline`、`ExpandOutline`、`ContractOutline`、`CloseOutline`
- 亮色 / 暗色主题切换CSS 变量驱动,标题栏、编辑器、滚动条同步变化
- 集成 Naive UI 组件库,使用 `n-config-provider` 管理全局主题
- 编辑器样式覆盖:标题、列表、引用、代码块、表格、链接等 Markdown 元素样式完整
- Markdown 内容变化通过 `listener` 插件实时同步到 v-model
#### 技术要点
- Milkdown v7 编辑器通过 `useEditor` composable 创建实例
- 自定义标题栏使用 Tauri `data-tauri-drag-region` 实现窗口拖拽
- 主题通过 `html.dark` CSS 类切换Naive UI 同步切换 `darkTheme`
- 编辑器 `ProseMirror` 输出 markdown 通过 `listenerCtx.markdownUpdated` 实时获取
#### 修复
- 修复页面空白:`MilkdownProvider` 需作为 `useEditor` 的祖先组件提供上下文,将其从 Editor.vue 提升至 App.vue
- 修复窗口拖拽权限:补充 `core:window:allow-is-maximized`、`allow-maximize`、`allow-unmaximize`
- 修复编辑器无法滚动:`#app` 改用 `padding-top: 40px` 替代 `.app-body``margin-top: 40px`,避免 `overflow: hidden` 裁掉底部内容导致高度计算错误
- 移除 `milkdown.css``min-height: 100%` 避免干扰 flex 布局高度约束
- 导入 ProseMirror 和 Tables 基础 CSS`@milkdown/kit/prose/view/style/prosemirror.css`、`tables.css`
### 迭代 1.2 文件系统集成 (2026-07-15)
#### 新增
- **Rust 后端**:添加 `tauri-plugin-fs` + `tauri-plugin-dialog` 插件,实现文件系统 Tauri commands
- `read_directory` — 递归读取目录,返回文件树结构(仅 `.md` 文件和非空目录)
- `read_file_content` / `write_file_content` — 读写文件内容
- `create_file` / `rename_file` / `delete_file` / `file_exists` — 文件增删改查
- **前端插件**:安装 `@tauri-apps/plugin-fs` + `@tauri-apps/plugin-dialog`
- **文件树侧边栏** (`Sidebar.vue` + `FileTree.vue`):使用 Naive UI `n-tree` 渲染目录结构
- 支持展开/折叠文件夹,点击 `.md` 文件在编辑器中打开
- 顶部工具栏:打开工作区、刷新、最近文件切换
- **新建/打开/保存文件**
- `Ctrl+N` 新建空白文件,`Ctrl+O` 打开文件对话框
- `Ctrl+S` 保存当前文件,`Ctrl+Shift+S` 另存为
- 首次启动自动弹出工作区目录选择
- **工作区管理**:工作区路径持久化到 `localStorage`,后续启动自动加载
- **最近文件列表**:记录最近 10 个打开的文件,存储在 `localStorage`,重启保留
- **标题栏增强**:显示当前文件名 + 未保存标记(●)
- **Editor 组件增强**:支持外部内容替换(`replaceAll`),用于打开文件时更新编辑器
#### 技术要点
- 文件操作通过 Rust `std::fs` 实现,前端通过 `invoke` 调用 Tauri commands
- 目录遍历过滤隐藏文件和 `node_modules`/`target` 目录,仅展示 `.md` 文件
- 编辑器使用 `@milkdown/kit/utils``replaceAll` 实现外部内容替换
- 使用 `@tauri-apps/plugin-dialog``open`/`save` 实现原生文件对话框
- 快捷键通过全局 `keydown` 监听,区分 `Ctrl`/`Cmd` + 组合键

View File

@ -1,7 +1,143 @@
# Tauri + Vue + TypeScript
# WriteFlow - 所见即所得的 Markdown 编辑器
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
> 一款基于 Tauri + Vue 3 开发的本地 Markdown 编辑器,复刻 Typora 的所见即所得体验,并加入多设备远程同步能力。
## Recommended IDE Setup
## 核心特性
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
- **所见即所得编辑** — 实时渲染 Markdown打字即预览告别双栏模式
- **本地优先** — 所有笔记以 `.md` 文件存储于本地文件系统,不依赖云服务即可完整使用
- **多设备同步** — 通过 S3 / WebDAV 等协议将笔记和图片自动同步到云端,多台设备无缝切换
- **跨平台** — 基于 Tauri v2原生支持 Windows、macOS、Linux
- **轻量高速** — Rust 后端 + Vue 3 前端,启动快、内存占用低
- **扩展丰富** — 支持主题切换、代码高亮、数学公式、流程图、目录大纲、导出 PDF/HTML 等
## 技术栈
| 层 | 技术 | 说明 |
|----|------|------|
| 桌面框架 | Tauri v2 | Rust 驱动,比 Electron 更轻量 |
| 前端 | Vue 3 + TypeScript | Composition API + `<script setup>` |
| UI 组件 | Naive UI | Vue3 高颜值 UI 库TypeScript 原生支持 |
| Markdown 渲染 | markdown-it / Milkdown | 所见即所得编辑与渲染 |
| 后端 | Rust (Tauri) | 文件管理、同步引擎、编辑器核心 |
| 同步协议 | S3 / WebDAV / Git | 多协议远程同步 |
## 快速开始
### 前置要求
- [Node.js](https://nodejs.org/) >= 18
- [pnpm](https://pnpm.io/) >= 8
- [Rust](https://www.rust-lang.org/) >= 1.70
- [Tauri CLI](https://tauri.app/) >= 2.0
### 开发运行
```bash
# 安装前端依赖
pnpm install
# 启动开发模式(前端 + Tauri 窗口)
pnpm tauri dev
# 仅启动前端(浏览器调试)
pnpm dev
```
### 构建打包
```bash
pnpm tauri build
```
构建产物位于 `src-tauri/target/release/bundle/`
## 项目结构
```
tauri-app/
├── index.html # 入口 HTML
├── package.json # 前端依赖与脚本
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
├── src/ # Vue 3 前端源码
│ ├── main.ts # Vue 应用入口
│ ├── App.vue # 根组件
│ ├── assets/ # 静态资源
│ └── components/ # 通用组件
├── src-tauri/ # Tauri (Rust) 后端源码
│ ├── Cargo.toml # Rust 依赖
│ ├── tauri.conf.json # Tauri 配置
│ ├── build.rs # 构建脚本
│ ├── icons/ # 应用图标
│ └── src/
│ ├── main.rs # Rust 入口
│ └── lib.rs # 核心逻辑
└── public/ # 公共静态资源
```
## 开发路线图
### Phase 1 — MVP基础编辑器
- [x] 项目初始化Tauri + Vue 3 + TypeScript
- [ ] 基础 Markdown 编辑器(实时预览)
- [ ] 文件树浏览与文件管理
- [ ] 本地文件读写(新建、打开、保存)
- [ ] 基础主题切换(亮色/暗色)
### Phase 2 — 同步引擎
- [ ] S3 协议同步AWS S3 / MinIO / Cloudflare R2
- [ ] 同步状态管理(冲突检测、版本对比)
- [ ] 图片资源的自动上传与链接替换
- [ ] 离线编辑 + 联网自动同步
### Phase 3 — 高级编辑功能
- [ ] 代码高亮(语法着色)
- [ ] 数学公式KaTeX / MathJax
- [ ] 表格编辑器
- [ ] Mermaid 流程图
- [ ] 目录大纲 / 文档导航
- [ ] 图片拖拽粘贴上传
- [ ] 导出 PDF / HTML / Word
### Phase 4 — 多协议同步
- [ ] WebDAV 协议支持
- [ ] Git 仓库同步
- [ ] WebRTC 局域网直连同步
- [ ] SMB / NFS 局域网共享
### Phase 5 — 体验打磨
- [ ] 多标签页管理
- [ ] 全文搜索
- [ ] 快捷键体系
- [ ] 自定义主题
- [ ] 插件系统
## 同步方案设计
### S3 协议Phase 2 首发)
```
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 设备 A │ │ S3 存储 │ │ 设备 B │
│ (本地笔记) │◄──►│ (云端同步) │◄──►│ (本地笔记) │
└──────────┘ └──────────┘ └──────────┘
```
- 支持标准 S3 协议AWS S3、阿里云 OSS、腾讯云 COS、MinIO、Cloudflare R2
- 增量同步,仅上传变更文件
- 冲突策略:保留冲突副本,用户手动合并
### 未来扩展
| 协议 | 适用场景 | 优先级 |
|------|---------|--------|
| S3 | 云存储(公有云 / 私有化 MinIO | Phase 2 |
| WebDAV | 自建 NAS、云盘坚果云、NextCloud | Phase 4 |
| Git | 程序员笔记、版本追溯 | Phase 4 |
| WebRTC | 局域网极速同步,无需中转 | Phase 4 |
| SMB/NFS | 纯局域网共享,无需云端 | Phase 4 |
## License
MIT

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
<title>WriteFlow</title>
</head>
<body>

View File

@ -10,10 +10,14 @@
"tauri": "tauri"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@milkdown/kit": "^7.21.3",
"@milkdown/vue": "^7.21.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2",
"ant-design-vue": "~4.2.6",
"@vicons/ionicons5": "^0.13.0",
"naive-ui": "^2.41.0",
"vue": "^3.5.13"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@ -2187,6 +2187,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@ -2703,6 +2704,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@ -3316,6 +3341,8 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener",
]
@ -3397,6 +3424,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@ -4445,6 +4514,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -4478,13 +4556,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@ -4515,6 +4610,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@ -4527,6 +4628,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@ -4539,12 +4646,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@ -4557,6 +4676,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@ -4569,6 +4694,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@ -4581,6 +4712,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@ -4593,6 +4730,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"

View File

@ -20,6 +20,8 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@ -9,6 +9,19 @@
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging"
"core:window:allow-start-dragging",
"core:window:allow-is-maximized",
"core:window:allow-unmaximize",
"core:window:allow-maximize",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename"
]
}
}

View File

@ -1,14 +1,158 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use serde::Serialize;
use std::fs;
use std::path::Path;
#[derive(Serialize, Clone)]
struct FileEntry {
name: String,
path: String,
is_dir: bool,
#[serde(skip_serializing_if = "Option::is_none")]
children: Option<Vec<FileEntry>>,
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[tauri::command]
fn read_directory(dir_path: String) -> Result<Vec<FileEntry>, String> {
let path = Path::new(&dir_path);
if !path.is_dir() {
return Err(format!("路径不是目录: {}", dir_path));
}
read_dir_recursive(path).map_err(|e| e.to_string())
}
fn read_dir_recursive(dir: &Path) -> Result<Vec<FileEntry>, std::io::Error> {
let mut entries = Vec::new();
let read_dir = match fs::read_dir(dir) {
Ok(rd) => rd,
Err(_) => return Ok(entries),
};
for entry in read_dir.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || name == "node_modules" || name == "target" {
continue;
}
let is_dir = path.is_dir();
let children = if is_dir {
Some(read_dir_recursive(&path)?)
} else {
None
};
if is_dir {
let child_count = children.as_ref().map_or(0, |c| c.len());
if child_count == 0 && !has_md_files(&path)? {
continue;
}
} else if !name.ends_with(".md") {
continue;
}
entries.push(FileEntry {
name,
path: path.to_string_lossy().to_string(),
is_dir,
children,
});
}
entries.sort_by(|a, b| {
if a.is_dir != b.is_dir {
b.is_dir.cmp(&a.is_dir)
} else {
a.name.to_lowercase().cmp(&b.name.to_lowercase())
}
});
Ok(entries)
}
fn has_md_files(dir: &Path) -> Result<bool, std::io::Error> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if entry.path().is_dir() {
if has_md_files(&entry.path())? {
return Ok(true);
}
} else if name.ends_with(".md") {
return Ok(true);
}
}
Ok(false)
}
#[tauri::command]
fn read_file_content(file_path: String) -> Result<String, String> {
fs::read_to_string(&file_path).map_err(|e| format!("读取文件失败: {}", e))
}
#[tauri::command]
fn write_file_content(file_path: String, content: String) -> Result<(), String> {
fs::write(&file_path, &content).map_err(|e| format!("保存文件失败: {}", e))
}
#[tauri::command]
fn create_file(file_path: String) -> Result<(), String> {
if Path::new(&file_path).exists() {
return Err("文件已存在".to_string());
}
if let Some(parent) = Path::new(&file_path).parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
fs::write(&file_path, "").map_err(|e| format!("创建文件失败: {}", e))
}
#[tauri::command]
fn rename_file(old_path: String, new_path: String) -> Result<(), String> {
if Path::new(&new_path).exists() {
return Err("目标路径已存在".to_string());
}
fs::rename(&old_path, &new_path).map_err(|e| format!("重命名失败: {}", e))
}
#[tauri::command]
fn delete_file(file_path: String) -> Result<(), String> {
let path = Path::new(&file_path);
if path.is_dir() {
fs::remove_dir_all(&file_path).map_err(|e| format!("删除目录失败: {}", e))
} else {
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))
}
}
#[tauri::command]
fn file_exists(file_path: String) -> bool {
Path::new(&file_path).exists()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
greet,
read_directory,
read_file_content,
write_file_content,
create_file,
rename_file,
delete_file,
file_exists
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "Tauri App",
"title": "WriteFlow",
"width": 800,
"height": 600,
"decorations": false

View File

@ -1,131 +1,227 @@
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import TitleBar from "./components/TitleBar.vue";
import { ref, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open, save, message } from '@tauri-apps/plugin-dialog'
import { darkTheme } from 'naive-ui'
import { MilkdownProvider } from '@milkdown/vue'
import TitleBar from './components/TitleBar.vue'
import Sidebar from './components/Sidebar.vue'
import Editor from './components/Editor.vue'
import { getWorkspacePath, setWorkspacePath } from './stores/workspace'
import { addRecentFile } from './stores/recentFiles'
const greetMsg = ref("");
const name = ref("");
const editorContent = ref('')
const currentFilePath = ref<string | null>(null)
const currentFileName = ref('未命名')
const isDirty = ref(false)
const isDark = ref(false)
const sidebarVisible = ref(true)
async function greet() {
greetMsg.value = await invoke("greet", { name: name.value });
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
}
//
onMounted(async () => {
let ws = getWorkspacePath()
if (!ws) {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择笔记工作区目录 — WriteFlow',
})
if (selected && typeof selected === 'string') {
ws = selected
setWorkspacePath(ws)
}
} catch {
//
}
}
})
//
async function openFile(filePath: string) {
if (!filePath) {
await newFile()
return
}
try {
const exists: boolean = await invoke('file_exists', { filePath })
if (!exists) {
await message(`文件不存在: ${filePath}`, { kind: 'error' })
return
}
if (!filePath.endsWith('.md')) {
await message('仅支持打开 .md 文件', { kind: 'warning' })
return
}
const content: string = await invoke('read_file_content', { filePath })
editorContent.value = content
currentFilePath.value = filePath
currentFileName.value = filePath.replace(/\\/g, '/').split('/').pop() || '未命名'
isDirty.value = false
addRecentFile(filePath)
} catch (e) {
await message(`打开文件失败: ${e}`, { kind: 'error' })
}
}
//
async function saveFile() {
if (!currentFilePath.value) {
await saveFileAs()
return
}
try {
await invoke('write_file_content', {
filePath: currentFilePath.value,
content: editorContent.value,
})
isDirty.value = false
} catch (e) {
await message(`保存失败: ${e}`, { kind: 'error' })
}
}
//
async function saveFileAs() {
try {
const ws = getWorkspacePath()
const defaultPath = ws ? `${ws}\\untitled.md` : undefined
const selected = await save({
defaultPath,
filters: [{ name: 'Markdown', extensions: ['md'] }],
title: '另存为',
})
if (selected) {
currentFilePath.value = selected
currentFileName.value = selected.replace(/\\/g, '/').split('/').pop() || '未命名'
await invoke('write_file_content', {
filePath: selected,
content: editorContent.value,
})
isDirty.value = false
addRecentFile(selected)
}
} catch (e) {
await message(`保存失败: ${e}`, { kind: 'error' })
}
}
//
async function newFile() {
if (isDirty.value) {
//
}
editorContent.value = ''
currentFilePath.value = null
currentFileName.value = '未命名'
isDirty.value = false
}
//
function onContentUpdate() {
if (currentFilePath.value) {
isDirty.value = true
}
}
//
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey
if (mod && e.key === 's') {
e.preventDefault()
if (e.shiftKey) {
saveFileAs()
} else {
saveFile()
}
} else if (mod && e.key === 'n') {
e.preventDefault()
newFile()
} else if (mod && e.key === 'o') {
e.preventDefault()
openFileDialog()
}
}
async function openFileDialog() {
try {
const selected = await open({
multiple: false,
filters: [{ name: 'Markdown', extensions: ['md'] }],
title: '打开 Markdown 文件',
})
if (selected && typeof selected === 'string') {
await openFile(selected)
}
} catch {
//
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<TitleBar />
<main class="container">
<h1>Welcome to Tauri + Vue</h1>
<div class="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" class="logo tauri" alt="Tauri logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<n-config-provider :theme="isDark ? darkTheme : null">
<TitleBar
:dark="isDark"
:file-name="currentFileName"
:is-dirty="isDirty"
@toggle-theme="toggleTheme"
/>
<div class="app-body">
<MilkdownProvider>
<div class="app-layout">
<Sidebar
v-if="sidebarVisible"
@open-file="openFile"
/>
<div class="editor-area">
<Editor
v-model="editorContent"
@update:model-value="onContentUpdate"
/>
</div>
</div>
</MilkdownProvider>
</div>
<p>Click on the Tauri, Vite, and Vue logos to learn more.</p>
<form class="row" @submit.prevent="greet">
<a-input id="greet-input" v-model:value="name" placeholder="Enter a name..." />
<a-button type="primary" html-type="submit">Greet</a-button>
</form>
<p>{{ greetMsg }}</p>
</main>
</n-config-provider>
</template>
<style scoped>
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
<style>
@import './styles/theme.css';
@import './styles/global.css';
@import './styles/milkdown.css';
.logo.vue:hover {
filter: drop-shadow(0 0 2em #249b73);
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.container {
margin: 0;
padding-top: calc(40px + 8vh);
.app-body {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.row {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
#greet-input {
max-width: 200px;
}
h1 {
text-align: center;
}
</style>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
flex: 1;
overflow: hidden;
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: var(--ant-color-text, #0f0f0f);
background-color: var(--ant-color-bg-container, #f6f6f6);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
.app-layout {
display: flex;
flex: 1;
overflow: hidden;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
@media (prefers-color-scheme: dark) {
:root {
color: var(--ant-color-text, #f6f6f6);
background-color: var(--ant-color-bg-container, #2f2f2f);
}
a:hover {
color: #24c8db;
}
.editor-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
</style>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Milkdown, useEditor } from '@milkdown/vue'
import { defaultValueCtx, Editor, rootCtx } from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { history } from '@milkdown/kit/plugin/history'
import { clipboard } from '@milkdown/kit/plugin/clipboard'
import { trailing } from '@milkdown/kit/plugin/trailing'
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
import { replaceAll } from '@milkdown/kit/utils'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const content = ref(props.modelValue)
let isExternalUpdate = false
watch(() => props.modelValue, (val) => {
if (val !== content.value) {
content.value = val
isExternalUpdate = true
const editor = get()
if (editor) {
editor.action(replaceAll(val))
}
}
})
const { loading, get } = useEditor((root) => {
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, content.value)
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
if (!isExternalUpdate) {
content.value = markdown
emit('update:modelValue', markdown)
}
isExternalUpdate = false
})
})
.use(commonmark)
.use(history)
.use(clipboard)
.use(trailing)
.use(listener)
return editor
})
defineExpose({
getEditor: () => get(),
setContent: (value: string) => {
const editor = get()
if (editor) {
editor.action(replaceAll(value))
}
},
})
</script>
<template>
<div class="editor-wrapper">
<div v-if="loading" class="editor-loading">
<span>编辑器加载中...</span>
</div>
<Milkdown class="milkdown-editor" />
</div>
</template>
<style scoped>
.editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-size: 14px;
}
.milkdown-editor {
flex: 1;
overflow-y: auto;
}
</style>

View File

@ -0,0 +1,140 @@
<script setup lang="ts">
import { ref, watch, h } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { NIcon, NTree, type TreeOption } from 'naive-ui'
import {
FolderOutline,
FolderOpenOutline,
DocumentTextOutline,
} from '@vicons/ionicons5'
interface FileEntry {
name: string
path: string
is_dir: boolean
children?: FileEntry[]
}
interface FileTreeNode {
label: string
key: string
isLeaf?: boolean
prefix?: () => ReturnType<typeof h>
children?: FileTreeNode[]
}
const props = defineProps<{
workspace: string | null
}>()
const emit = defineEmits<{
(e: 'selectFile', path: string): void
}>()
const treeData = ref<FileTreeNode[]>([])
const expandedKeys = ref<string[]>([])
const selectedKey = ref<string | null>(null)
const loading = ref(false)
function fileIcon(isDir: boolean, expanded: boolean) {
return h(NIcon, { size: 18 }, () =>
isDir
? expanded
? h(FolderOpenOutline)
: h(FolderOutline)
: h(DocumentTextOutline)
)
}
function convertTree(entries: FileEntry[]): FileTreeNode[] {
return entries.map((entry) => ({
label: entry.name,
key: entry.path,
isLeaf: !entry.is_dir,
prefix: () => fileIcon(entry.is_dir, false),
children: entry.children ? convertTree(entry.children) : undefined,
}))
}
async function loadDirectory() {
if (!props.workspace) {
treeData.value = []
return
}
loading.value = true
try {
const entries: FileEntry[] = await invoke('read_directory', {
dirPath: props.workspace,
})
treeData.value = convertTree(entries)
} catch (e) {
console.error('读取目录失败:', e)
treeData.value = []
} finally {
loading.value = false
}
}
watch(() => props.workspace, loadDirectory, { immediate: true })
function handleSelect(keys: string[]) {
if (keys.length === 0) return
const key = keys[0]
selectedKey.value = key
// only emit for .md files
if (key.endsWith('.md')) {
emit('selectFile', key)
}
}
function handleUpdateExpandedKeys(keys: string[]) {
expandedKeys.value = keys
}
function nodeProps(info: { option: TreeOption }) {
return {
onClick: () => handleSelect([info.option.key as string]),
}
}
defineExpose({
refresh: loadDirectory,
})
</script>
<template>
<div class="file-tree">
<div v-if="!workspace" class="file-tree-empty">
<span>请先选择工作区目录</span>
</div>
<div v-else-if="loading" class="file-tree-loading">
<span>加载中...</span>
</div>
<n-tree
v-else
:data="treeData"
:expanded-keys="expandedKeys"
:selected-keys="selectedKey ? [selectedKey] : []"
:node-props="nodeProps"
@update:expanded-keys="handleUpdateExpandedKeys"
block-line
:show-irrelevant-nodes="false"
/>
</div>
</template>
<style scoped>
.file-tree {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.file-tree-empty,
.file-tree-loading {
padding: 24px 16px;
text-align: center;
color: var(--color-text-secondary);
font-size: 13px;
}
</style>

View File

@ -0,0 +1,228 @@
<script setup lang="ts">
import { ref } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { NIcon, NButton, NDivider, NTooltip } from 'naive-ui'
import {
FolderOpenOutline,
AddOutline,
RefreshOutline,
TimeOutline,
} from '@vicons/ionicons5'
import { getWorkspacePath, setWorkspacePath } from '../stores/workspace'
import { getRecentFiles } from '../stores/recentFiles'
import FileTree from './FileTree.vue'
const emit = defineEmits<{
(e: 'openFile', path: string): void
}>()
const workspace = ref<string | null>(getWorkspacePath())
const fileTreeRef = ref<InstanceType<typeof FileTree> | null>(null)
const recentFiles = ref<string[]>(getRecentFiles())
const showRecent = ref(false)
async function selectWorkspace() {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择笔记工作区目录',
})
if (selected && typeof selected === 'string') {
workspace.value = selected
setWorkspacePath(selected)
}
} catch (e) {
console.error('选择目录失败:', e)
}
}
function refreshTree() {
recentFiles.value = getRecentFiles()
fileTreeRef.value?.refresh()
}
function fileName(path: string): string {
const parts = path.replace(/\\/g, '/').split('/')
return parts[parts.length - 1] || path
}
function onSelectFile(path: string) {
emit('openFile', path)
}
function onOpenRecent(path: string) {
emit('openFile', path)
}
</script>
<template>
<aside class="sidebar">
<!-- 工作区标题 -->
<div class="sidebar-header">
<span class="sidebar-title">文件</span>
<div class="sidebar-actions">
<NTooltip trigger="hover">
<template #trigger>
<NButton
size="tiny"
quaternary
circle
@click="selectWorkspace"
>
<NIcon :size="16" :component="FolderOpenOutline" />
</NButton>
</template>
打开工作区
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="tiny" quaternary circle @click="refreshTree">
<NIcon :size="16" :component="RefreshOutline" />
</NButton>
</template>
刷新
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton
size="tiny"
quaternary
circle
@click="showRecent = !showRecent"
>
<NIcon :size="16" :component="TimeOutline" />
</NButton>
</template>
最近文件
</NTooltip>
</div>
</div>
<NDivider style="margin: 0" />
<!-- 工作区路径 -->
<div v-if="workspace" class="sidebar-workspace-path">
{{ workspace }}
</div>
<!-- 最近文件面板 -->
<div v-if="showRecent && recentFiles.length > 0" class="sidebar-recent">
<div class="recent-header">最近打开</div>
<div
v-for="path in recentFiles"
:key="path"
class="recent-item"
@click="onOpenRecent(path)"
>
<NIcon :size="14" :component="TimeOutline" />
<span class="recent-name">{{ fileName(path) }}</span>
</div>
<NDivider style="margin: 0" />
</div>
<!-- 文件树 -->
<FileTree
ref="fileTreeRef"
:workspace="workspace"
@select-file="onSelectFile"
/>
<!-- 底部新建按钮 -->
<div v-if="workspace" class="sidebar-footer">
<NTooltip trigger="hover">
<template #trigger>
<NButton
size="tiny"
quaternary
@click="$emit('openFile', '')"
>
<NIcon :size="16" :component="AddOutline" />
新建笔记
</NButton>
</template>
在工作区创建新 Markdown 文件
</NTooltip>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 240px;
min-width: 180px;
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
height: 36px;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.sidebar-actions {
display: flex;
gap: 2px;
}
.sidebar-workspace-path {
padding: 4px 12px;
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-recent {
padding: 4px 0;
}
.recent-header {
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.recent-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 13px;
cursor: pointer;
color: var(--color-text);
}
.recent-item:hover {
background: var(--color-bg-tertiary);
}
.recent-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-footer {
padding: 8px 12px;
border-top: 1px solid var(--color-border);
}
</style>

View File

@ -1,62 +1,78 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { ref, onMounted, onUnmounted } from 'vue'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { NIcon } from 'naive-ui'
import {
MinusOutlined,
BorderOutlined,
CloseOutlined,
SwitcherOutlined,
} from "@ant-design/icons-vue";
CreateOutline,
SunnyOutline,
MoonOutline,
RemoveOutline,
ExpandOutline,
ContractOutline,
CloseOutline,
} from '@vicons/ionicons5'
const appWindow = getCurrentWindow();
const isMaximized = ref(false);
defineProps<{
dark: boolean
fileName?: string
isDirty?: boolean
}>()
//
let unlisten: (() => void) | null = null;
const emit = defineEmits<{
(e: 'toggleTheme'): void
}>()
const appWindow = getCurrentWindow()
const isMaximized = ref(false)
let unlisten: (() => void) | null = null
onMounted(async () => {
try {
isMaximized.value = await appWindow.isMaximized();
isMaximized.value = await appWindow.isMaximized()
unlisten = await appWindow.onResized(() => {
appWindow.isMaximized().then((v) => (isMaximized.value = v));
});
appWindow.isMaximized().then((v) => (isMaximized.value = v))
})
} catch {
// Tauri
// Tauri
}
});
})
onUnmounted(() => {
if (unlisten) unlisten();
});
if (unlisten) unlisten()
})
//
function onMinimize() {
appWindow.minimize().catch(() => {});
appWindow.minimize().catch(() => {})
}
function onToggleMaximize() {
appWindow.toggleMaximize().catch(() => {});
appWindow.toggleMaximize().catch(() => {})
}
function onClose() {
appWindow.close().catch(() => {});
appWindow.close().catch(() => {})
}
</script>
<template>
<header class="titlebar" data-tauri-drag-region>
<!-- 左侧应用标题 -->
<div class="titlebar-left">
<span class="titlebar-app-name">Tauri App</span>
<NIcon :size="18" :component="CreateOutline" class="titlebar-logo" />
<span class="titlebar-app-name">WriteFlow</span>
<span v-if="fileName" class="titlebar-file-name">
{{ fileName }}{{ isDirty ? ' \u25CF' : '' }}
</span>
</div>
<!-- 右侧窗口控制按钮 -->
<div class="titlebar-controls">
<button
class="win-btn"
@click="onMinimize"
aria-label="最小化"
title="最小化"
>
<MinusOutlined />
<div class="titlebar-center" />
<div class="titlebar-right">
<button class="win-btn" @click="emit('toggleTheme')" aria-label="切换主题" title="切换主题">
<NIcon v-if="!dark" :size="18" :component="SunnyOutline" />
<NIcon v-else :size="18" :component="MoonOutline" />
</button>
<button class="win-btn" @click="onMinimize" aria-label="最小化" title="最小化">
<NIcon :size="16" :component="RemoveOutline" />
</button>
<button
@ -65,26 +81,18 @@ function onClose() {
:aria-label="isMaximized ? '还原' : '最大化'"
:title="isMaximized ? '还原' : '最大化'"
>
<BorderOutlined v-if="!isMaximized" />
<SwitcherOutlined v-else />
<NIcon v-if="!isMaximized" :size="16" :component="ExpandOutline" />
<NIcon v-else :size="16" :component="ContractOutline" />
</button>
<button
class="win-btn win-btn--close"
@click="onClose"
aria-label="关闭"
title="关闭"
>
<CloseOutlined />
<button class="win-btn win-btn--close" @click="onClose" aria-label="关闭" title="关闭">
<NIcon :size="18" :component="CloseOutline" />
</button>
</div>
</header>
</template>
<style scoped>
/* ============================================================
标题栏容器
============================================================ */
.titlebar {
position: fixed;
top: 0;
@ -94,46 +102,56 @@ function onClose() {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--titlebar-bg, #141414);
color: var(--titlebar-fg, rgba(255, 255, 255, 0.85));
background: var(--titlebar-bg);
color: var(--titlebar-fg);
user-select: none;
z-index: 9999;
}
/* 整个标题栏可拖拽Tauri 无边框窗口) */
[data-tauri-drag-region] {
-webkit-app-region: drag;
}
/* ============================================================
左侧区域
============================================================ */
.titlebar-left {
padding-left: 16px;
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
}
.titlebar-logo {
opacity: 0.7;
}
.titlebar-app-name {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.02em;
white-space: nowrap;
pointer-events: none;
}
/* ============================================================
窗口控制按钮
============================================================ */
.titlebar-controls {
.titlebar-file-name {
font-size: 12px;
color: var(--titlebar-fg);
opacity: 0.6;
white-space: nowrap;
pointer-events: none;
}
.titlebar-center {
flex: 1;
}
.titlebar-right {
display: flex;
align-items: center;
height: 100%;
-webkit-app-region: no-drag;
gap: 2px;
}
.win-btn {
width: 46px;
width: 40px;
height: 100%;
display: inline-flex;
align-items: center;
@ -151,36 +169,19 @@ function onClose() {
}
.win-btn:hover {
background-color: var(--titlebar-btn-hover, rgba(255, 255, 255, 0.08));
background-color: var(--titlebar-btn-hover);
}
.win-btn:active {
background-color: var(--titlebar-btn-active, rgba(255, 255, 255, 0.14));
background-color: var(--titlebar-btn-active);
}
/* 关闭按钮 hover 变红Windows 风格) */
.win-btn--close:hover {
background-color: #e81123;
color: #fff;
}
.win-btn--close:active {
background-color: #f1707a;
}
/* ============================================================
亮色主题
============================================================ */
@media (prefers-color-scheme: light) {
.titlebar {
--titlebar-bg: #fafafa;
--titlebar-fg: rgba(0, 0, 0, 0.88);
--titlebar-btn-hover: rgba(0, 0, 0, 0.06);
--titlebar-btn-active: rgba(0, 0, 0, 0.12);
}
.win-btn--close:hover {
background-color: #e81123;
color: #fff;
}
}
</style>

View File

@ -1,8 +1,12 @@
import { createApp } from "vue";
import Antd from "ant-design-vue";
import App from "./App.vue";
import "ant-design-vue/dist/reset.css";
import { createApp } from 'vue'
import { create } from 'naive-ui'
import App from './App.vue'
const app = createApp(App);
app.use(Antd);
app.mount("#app");
import '@milkdown/kit/prose/view/style/prosemirror.css'
import '@milkdown/kit/prose/tables/style/tables.css'
const naive = create()
const app = createApp(App)
app.use(naive)
app.mount('#app')

View File

@ -0,0 +1,26 @@
const STORAGE_KEY = 'writeflow-recent-files'
const MAX_RECENT = 10
export function getRecentFiles(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as string[]
} catch {
return []
}
}
export function addRecentFile(path: string): void {
const files = getRecentFiles().filter((f) => f !== path)
files.unshift(path)
if (files.length > MAX_RECENT) {
files.length = MAX_RECENT
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(files))
}
export function removeRecentFile(path: string): void {
const files = getRecentFiles().filter((f) => f !== path)
localStorage.setItem(STORAGE_KEY, JSON.stringify(files))
}

View File

@ -0,0 +1,13 @@
const STORAGE_KEY = 'writeflow-workspace'
export function getWorkspacePath(): string | null {
return localStorage.getItem(STORAGE_KEY)
}
export function setWorkspacePath(path: string): void {
localStorage.setItem(STORAGE_KEY, path)
}
export function clearWorkspacePath(): void {
localStorage.removeItem(STORAGE_KEY)
}

View File

@ -0,0 +1,58 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background: var(--color-bg);
color: var(--color-text);
}
body {
font-family: 'Inter', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
#app {
display: flex;
flex-direction: column;
padding-top: 40px;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
color: var(--color-accent-hover);
}

View File

@ -0,0 +1,129 @@
/* ========== Milkdown 编辑器全局样式覆盖 ========== */
.editor-wrapper {
background: var(--color-bg);
color: var(--color-text);
font-family: 'Inter', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.75;
}
.editor-wrapper .milkdown-editor {
max-width: 820px;
margin: 0 auto;
padding: 40px 60px 200px;
outline: none;
}
.editor-wrapper .milkdown-editor .ProseMirror {
outline: none;
}
.editor-wrapper .milkdown-editor .ProseMirror > * + * {
margin-top: 0.75em;
}
/* 标题 */
.editor-wrapper h1 { font-size: 2em; font-weight: 700; margin: 1em 0 0.5em; line-height: 1.2; }
.editor-wrapper h2 { font-size: 1.5em; font-weight: 600; margin: 1em 0 0.5em; line-height: 1.3; }
.editor-wrapper h3 { font-size: 1.25em; font-weight: 600; margin: 0.8em 0 0.4em; }
.editor-wrapper h4 { font-size: 1.1em; font-weight: 600; }
.editor-wrapper h5 { font-size: 1em; font-weight: 600; }
.editor-wrapper h6 { font-size: 0.9em; font-weight: 600; color: var(--color-text-secondary); }
/* 段落 */
.editor-wrapper p { margin: 0.5em 0; }
/* 加粗斜体 */
.editor-wrapper strong { font-weight: 700; }
.editor-wrapper em { font-style: italic; }
/* 列表 */
.editor-wrapper ul, .editor-wrapper ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.editor-wrapper li { margin: 0.25em 0; }
/* 引用 */
.editor-wrapper blockquote {
margin: 0.75em 0;
padding: 0.5em 1em;
border-left: 4px solid var(--color-blockquote-border);
background: var(--color-blockquote-bg);
border-radius: 0 4px 4px 0;
}
/* 代码 */
.editor-wrapper code {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.9em;
background: var(--color-code-bg);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.editor-wrapper pre {
background: var(--color-code-bg);
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 0.75em 0;
}
.editor-wrapper pre code {
background: none;
padding: 0;
}
/* 分割线 */
.editor-wrapper hr {
border: none;
border-top: 2px solid var(--color-border);
margin: 1.5em 0;
}
/* 链接 */
.editor-wrapper a {
color: var(--color-accent);
}
.editor-wrapper a:hover {
color: var(--color-accent-hover);
}
/* 表格 */
.editor-wrapper table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
}
.editor-wrapper th, .editor-wrapper td {
border: 1px solid var(--color-table-border);
padding: 8px 12px;
text-align: left;
}
.editor-wrapper th {
background: var(--color-table-stripe);
font-weight: 600;
}
.editor-wrapper tr:nth-child(even) td {
background: var(--color-table-stripe);
}
/* 图片 */
.editor-wrapper img {
max-width: 100%;
border-radius: 6px;
}
/* 选中文本高亮 */
.editor-wrapper ::selection {
background-color: rgba(79, 140, 255, 0.3);
}
html.dark .editor-wrapper ::selection {
background-color: rgba(137, 180, 250, 0.3);
}

View File

@ -0,0 +1,50 @@
:root {
/* 编辑器基础色 */
--color-bg: #ffffff;
--color-bg-secondary: #f8f9fa;
--color-bg-tertiary: #f0f0f0;
--color-text: #1a1a2e;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
--color-accent: #4f8cff;
--color-accent-hover: #3a75e8;
--color-blockquote-bg: #f3f4f6;
--color-blockquote-border: #d1d5db;
--color-code-bg: #f3f4f6;
--color-table-border: #d1d5db;
--color-table-stripe: #f9fafb;
/* 滚动条 */
--color-scrollbar-thumb: #c1c1c1;
--color-scrollbar-track: transparent;
/* 标题栏 */
--titlebar-bg: #fafafa;
--titlebar-fg: rgba(0, 0, 0, 0.88);
--titlebar-btn-hover: rgba(0, 0, 0, 0.06);
--titlebar-btn-active: rgba(0, 0, 0, 0.12);
}
html.dark {
--color-bg: #1e1e2e;
--color-bg-secondary: #252536;
--color-bg-tertiary: #2d2d44;
--color-text: #cdd6f4;
--color-text-secondary: #a6adc8;
--color-border: #3d3d5c;
--color-accent: #89b4fa;
--color-accent-hover: #74a8f2;
--color-blockquote-bg: #252536;
--color-blockquote-border: #45475a;
--color-code-bg: #181825;
--color-table-border: #45475a;
--color-table-stripe: #252536;
--color-scrollbar-thumb: #585b70;
--color-scrollbar-track: transparent;
--titlebar-bg: #141414;
--titlebar-fg: rgba(255, 255, 255, 0.85);
--titlebar-btn-hover: rgba(255, 255, 255, 0.08);
--titlebar-btn-active: rgba(255, 255, 255, 0.14);
}

View File

@ -14,6 +14,11 @@
"noEmit": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,

View File

@ -1,5 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
@ -8,6 +9,12 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [vue()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors

View File

@ -0,0 +1,671 @@
# WriteFlow 产品策划开发手册
> 版本v1.0 | 作者:产品团队 | 最后更新2026-07
---
## 目录
1. [产品概述](#1-产品概述)
2. [市场分析与竞品调研](#2-市场分析与竞品调研)
3. [目标用户与用户故事](#3-目标用户与用户故事)
4. [功能规划](#4-功能规划)
5. [技术架构](#5-技术架构)
6. [UI/UX 设计规范](#6-uiux-设计规范)
7. [数据结构设计](#7-数据结构设计)
8. [同步引擎设计](#8-同步引擎设计)
9. [开发排期与里程碑](#9-开发排期与里程碑)
10. [质量保障与测试策略](#10-质量保障与测试策略)
11. [发布与运营策略](#11-发布与运营策略)
12. [风险评估与应对](#12-风险评估与应对)
---
## 1. 产品概述
### 1.1 产品定位
WriteFlow 是一款 **本地优先、所见即所得、支持多设备云同步** 的 Markdown 笔记编辑器。定位为 Typora 的现代化替代品,补齐 Typora 缺乏云同步能力、不再积极更新的短板。
### 1.2 产品愿景
> 让每一个人在任何设备上都能获得沉浸、流畅的 Markdown 写作体验,所有内容安全可控、随处可及。
### 1.3 核心价值主张
| 维度 | 价值 |
|------|------|
| 编辑体验 | 所见即所得,打字即渲染,无干扰沉浸 |
| 数据主权 | 本地 .md 文件存储,不锁定数据格式 |
| 多设备 | S3/WebDAV 协议打通,你自己的云存储 |
| 性能 | Tauri 桌面端原生性能,秒启动、低资源 |
| 可扩展 | 插件系统、多导出格式、自定义主题 |
### 1.4 产品边界
- **做什么**Markdown 编辑、本地文件管理、多设备云同步
- **不做什么**:协作编辑(那是 Google Docs 的事)、在线 Web 版(专注桌面端)、知识图谱(那是 Obsidian 的事)
---
## 2. 市场分析与竞品调研
### 2.1 竞品矩阵
| 产品 | 编辑模式 | 同步 | 价格 | 优势 | 劣势 |
|------|---------|------|------|------|------|
| **Typora** | WYSIWYG | 无内置 | $14.99 买断 | 编辑体验最好 | 停更、无同步、无插件 |
| **Obsidian** | 源码+预览 | 付费 Obsidian Sync $5/月 | 免费 | 插件生态强、双链 | 学习曲线陡、非 WYSIWYG、同步收费 |
| **Notion** | Block 编辑器 | 内置 | 免费+付费 | 全能协作 | 非 Markdown 原生态、云端依赖、慢 |
| **VS Code** | 源码+预览 | Git | 免费 | 全能编辑器 | Markdown 非核心、体验不够专注 |
| **Bear** | WYSIWYG | iCloud | 免费+订阅 | 体验精美 | 仅 Apple 生态 |
| **Joplin** | 双栏 | S3/WebDAV/NextCloud 等 | 免费开源 | 多协议同步 | 非 WYSIWYG、界面偏工程化 |
| **思源笔记** | WYSIWYG | 付费 S3/WebDAV | 免费+订阅 | 国产、功能全 | 数据格式私有 |
### 2.2 WriteFlow 的差异化机会
1. **Typora 级别的编辑体验 + 内置同步** — 目前市场上没有产品同时做到这两点
2. **本地文件格式(.md—** 不锁定用户数据,可用任意编辑器打开
3. **自带同步引擎 —** 用你自己的 S3/WebDAV无需额外付费
4. **轻量高性能 —** Tauri 比 Electron 内存占用少 60%+
5. **跨平台 —** Windows / macOS / Linux 全支持
---
## 3. 目标用户与用户故事
### 3.1 核心用户画像
| 画像 | 描述 | 核心需求 |
|------|------|---------|
| **技术写作者** | 写技术博客、文档的开发者 | 代码高亮、数学公式、导出、Git 同步 |
| **知识管理者** | 做笔记、写日记的学生/研究者 | 多设备同步、全文搜索、文件管理 |
| **效率追求者** | 用 Markdown 做一切记录的极客 | 快捷键、最小干扰、自定义主题 |
| **隐私敏感者** | 不希望数据在第三方服务器 | 自建 S3/MinIO 同步、纯本地可用 |
### 3.2 用户故事(关键场景)
```
作为一名技术博客作者,
我想要在笔记本上写完文章后,自动同步到台式机继续编辑,
以便无缝切换设备而不需要手动拷贝文件。
```
```
作为一个研究生,
我希望能将上课拍的板书照片拖入笔记中,
图片能自动上传到我的 S3 图床并在笔记里用相对路径引用,
以便在任何设备上都能看到完整笔记。
```
```
作为一个隐私敏感用户,
我希望能使用自己搭建的 MinIO 服务器作为同步后端,
笔记和图片都不会经过任何第三方服务器。
```
```
作为一名程序员,
我希望笔记仓库能通过 Git 协议同步,
以便利用 Git 的版本历史随时回溯笔记变更。
```
---
## 4. 功能规划
### 4.1 MVPPhase 1— 基础编辑器
**目标:可用的本地 Markdown 编辑器,核心编辑体验对齐 Typora。**
| 模块 | 功能点 | 优先级 |
|------|--------|--------|
| 编辑器 | 所见即所得渲染 | P0 |
| 编辑器 | 标题H1-H6 | P0 |
| 编辑器 | 加粗、斜体、删除线、行内代码 | P0 |
| 编辑器 | 有序/无序列表、嵌套列表 | P0 |
| 编辑器 | 引用块 | P0 |
| 编辑器 | 分隔线 | P0 |
| 编辑器 | 链接与图片 | P0 |
| 编辑器 | 代码块(有/无语言标注) | P0 |
| 编辑器 | 表格(增删行列、对齐) | P1 |
| 文件管理 | 新建/打开/保存 .md 文件 | P0 |
| 文件管理 | 文件树侧边栏(浏览目录) | P0 |
| 文件管理 | 最近打开的文件列表 | P1 |
| 窗口 | 自定义标题栏(无系统边框) | P0 |
| 主题 | 亮色/暗色主题切换 | P0 |
| 导出 | 导出为 HTML | P1 |
### 4.2 Phase 2 — 同步引擎 v1
**目标S3 协议同步上线,实现多设备笔记互通。**
| 模块 | 功能点 | 优先级 |
|------|--------|--------|
| 同步核心 | S3 协议连接配置Endpoint / Bucket / AK / SK | P0 |
| 同步核心 | 增量同步(仅上传/下载变更文件) | P0 |
| 同步核心 | 冲突检测(同名文件被两台设备同时修改) | P0 |
| 同步核心 | 手动触发同步 + 定时自动同步 | P0 |
| 同步核心 | 同步状态指示(同步中/已同步/冲突/错误) | P0 |
| 图片同步 | 图片上传到 S3自动替换为 S3 URL | P1 |
| 图片同步 | 图片相对路径引用方案 | P1 |
| 图片同步 | 粘贴图片自动上传 + 生成引用 | P1 |
| 体验 | 首次配置向导 | P1 |
### 4.3 Phase 3 — 高级编辑
**目标:编辑能力对齐 Typora满足专业写作需求。**
| 模块 | 功能点 | 优先级 |
|------|--------|--------|
| 代码 | 语法高亮100+ 语言) | P0 |
| 数学 | KaTeX/LaTeX 公式渲染 | P0 |
| 图表 | Mermaid 流程图、甘特图、序列图 | P1 |
| 图表 | PlantUML 可选支持 | P2 |
| 导航 | 文档大纲(根据标题自动生成) | P0 |
| 导航 | 大纲点击跳转到对应段落 | P0 |
| 表格 | 可视化表格编辑器(拖拽调整列宽) | P1 |
| 表格 | 表格内公式计算 | P2 |
| 导出 | 导出 PDF带样式 | P0 |
| 导出 | 导出 Word (.docx) | P1 |
| 导出 | 导出图片 (.png) | P2 |
| 脚注 | 脚注/尾注 | P2 |
| 任务 | 任务列表checkboxes | P1 |
| 目录 | TOC 自动生成 | P1 |
### 4.4 Phase 4 — 多协议同步
**目标:扩展同步协议,覆盖更多使用场景。**
| 协议 | 功能说明 | 优先级 |
|------|---------|--------|
| WebDAV | 支持坚果云、NextCloud、自建 WebDAV 服务器 | P0 |
| Git | 同步到 GitHub/GitLab/Gitee 仓库,支持版本历史浏览 | P1 |
| WebRTC | 局域网点对点直连同步(无需中央服务器,极低延迟) | P1 |
| SMB/NFS | 局域网共享文件夹模式,检测文件变更自动刷新 | P2 |
| OneDrive | 利用系统 OneDrive 目录的自动同步能力 | P2 |
| iCloud | macOS 上利用 iCloud Drive 目录 | P2 |
### 4.5 Phase 5 — 体验打磨与生态
**目标:完整的桌面应用体验,建立插件生态。**
| 模块 | 功能点 | 优先级 |
|------|--------|--------|
| 标签页 | 多标签页管理文件 | P0 |
| 标签页 | 标签页拖拽排序、分离窗口 | P1 |
| 搜索 | 全文搜索(文件名+文件内容) | P0 |
| 搜索 | 正则搜索 | P1 |
| 搜索 | 全局搜索与替换 | P1 |
| 快捷键 | 完整快捷键体系(可自定义) | P0 |
| 插件 | 插件系统JS/TS 编写) | P1 |
| 插件 | 插件市场 | P2 |
| 主题 | 自定义 CSS 主题 | P0 |
| 主题 | 主题市场 / 社区分享 | P2 |
| 国际化 | 中英文界面 | P0 |
| 国际化 | 社区贡献更多语言 | P2 |
| 无障碍 | 屏幕阅读器支持、键盘导航 | P2 |
---
## 5. 技术架构
### 5.1 整体架构图
```
┌──────────────────────────────────────────────────┐
│ 前端 (Vue 3) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 编辑器组件 │ │ 文件树 │ │ 设置/同步管理 │ │
│ │ ProseMirror│ │ 组件 │ │ 组件 │ │
│ └─────┬─────┘ └─────┬─────┘ └────────┬─────────┘ │
│ │ │ │ │
├────────┼─────────────┼───────────────┼─────────────┤
│ │ Tauri IPC Bridge (invoke) │ │
├────────┼─────────────┼───────────────┼─────────────┤
│ 后端 (Rust) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Command Layer │ │
│ │ read_file / write_file / list_dir / ... │ │
│ ├──────────────┬───────────────┬───────────────┤ │
│ │ 文件管理模块 │ 同步引擎 │ 导出模块 │ │
│ │ - 文件IO │ - S3 Client │ - PDF │ │
│ │ - 目录监听 │ - Diff 引擎 │ - HTML │ │
│ │ - 图片处理 │ - 冲突解决 │ - DOCX │ │
│ ├──────────────┴───────────────┴───────────────┤ │
│ │ Platform Abstraction │ │
│ │ (文件系统 / 网络 / 系统托盘 / 快捷键) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
### 5.2 前端技术选型
| 组件 | 技术选型 | 选型理由 |
|------|---------|---------|
| 框架 | Vue 3 + Composition API | 与 Tauri 生态结合好,轻量灵活 |
| 语言 | TypeScript | 类型安全,大型项目必备 |
| UI 库 | Naive UI | Vue3 高颜值 UI 库TypeScript 原生支持 |
| Markdown 编辑器 | ProseMirror / Milkdown | 插件化架构WYSIWYG 能力强 |
| 代码高亮 | Shiki / Prism.js | 服务端级语法高亮 |
| 数学渲染 | KaTeX | 比 MathJax 更快 |
| 图表 | Mermaid.js | 标准 Markdown 图表方案 |
| 打包 | Vite 6 | 极速 HMR生态完善 |
### 5.3 Rust 后端技术选型
| 模块 | 选型 | 说明 |
|------|------|------|
| 框架 | Tauri 2 | 桌面框架Rust 后端 |
| S3 客户端 | rust-s3 / aws-sdk-s3 | S3 协议实现 |
| WebDAV 客户端 | reqwest + 自实现 | HTTP 协议扩展 |
| Git | git2-rs | libgit2 绑定 |
| 文件监听 | notify | 跨平台文件变更监听 |
| Markdown 解析 | pulldown-cmark | 高性能 Markdown 解析器 |
| PDF 生成 | printpdf / genpdf | Rust 原生 PDF 生成 |
| 序列化 | serde + serde_json | 配置与 IPC 通信 |
| 加密 | ring / aes-gcm | 远程凭据加密存储 |
| 压缩 | flate2 | gzip 压缩WebDAV |
### 5.4 前端与后端通信设计
```rust
// Rust 侧 - 定义 Tauri Command
#[tauri::command]
async fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
async fn sync_to_s3(
config: S3Config,
files: Vec<SyncFile>,
) -> Result<SyncResult, String> {
// 同步逻辑
}
```
```typescript
// 前端侧 - 调用 Tauri Command
import { invoke } from '@tauri-apps/api/core';
const content = await invoke<string>('read_file', { path: '/path/to/file.md' });
const result = await invoke<SyncResult>('sync_to_s3', { config, files });
```
### 5.5 Tauri Capabilities 权限设计
```json
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-exists",
"dialog:allow-open",
"dialog:allow-save",
"http:default"
]
}
```
---
## 6. UI/UX 设计规范
### 6.1 设计原则
1. **沉浸优先** — 编辑器占据视觉重心Chrome 尽可能少
2. **所见即所得** — 光标所在行的 Markdown 语法标记即时隐藏,只展示渲染结果
3. **无干扰** — 默认隐藏工具栏,聚焦模式更进一步隐藏侧边栏
4. **反馈及时** — 同步状态、保存状态始终可见,不超过 200ms 延迟给出反馈
5. **渐进披露** — 高级功能隐藏在二级菜单,不干扰日常使用
### 6.2 布局方案
```
┌─────────────────────────────────────────┐
│ 自定义标题栏 (拖拽区 + 窗口控制 + Logo) │
├────────┬────────────────────────────────┤
│ 侧边栏 │ │
│ │ │
│ 文件树 │ 编辑器主区域 │
│ │ (所见即所得 Markdown) │
│ 大纲 │ │
│ │ │
│ 同步状态│ │
│ │ │
├────────┴────────────────────────────────┤
│ 状态栏 (文件路径 | 字数 | 同步状态 | 光标位置) │
└─────────────────────────────────────────┘
```
### 6.3 三种视图模式
| 模式 | 描述 | 使用场景 |
|------|------|---------|
| **编辑模式(默认)** | 所见即所得,打字时隐藏 Markdown 标记 | 日常写作 |
| **源码模式** | 显示原始 Markdown 文本 | 精确控制格式、调试 |
| **阅读模式** | 纯渲染,不可编辑 | 阅读长文、演示 |
### 6.4 色彩方案
- **亮色主题**:背景 #FFFFFF,文字 #333333,强调色 #2080F0
- **暗色主题**:背景 #1E1E1E,文字 #D4D4D4,强调色 #4FC1FF
- **Sepia 主题**:背景 #FBF0D9,文字 #5F4B32(类 Kindle
---
## 7. 数据结构设计
### 7.1 笔记本Workspace配置
```json
{
"workspaces": [
{
"id": "ws-001",
"name": "我的笔记",
"local_path": "~/Documents/WriteFlow/notes",
"sync": {
"enabled": true,
"protocol": "s3",
"config": {
"endpoint": "https://s3.amazonaws.com",
"bucket": "my-notes-bucket",
"region": "us-east-1",
"prefix": "writeflow/"
},
"auto_sync_interval_secs": 300,
"conflict_strategy": "keep_both"
}
}
]
}
```
### 7.2 同步元数据
```json
{
"file_sync_meta": {
"notes/getting-started.md": {
"last_local_modified": "2026-07-15T10:30:00Z",
"local_hash": "sha256:abc123...",
"last_remote_modified": "2026-07-15T09:00:00Z",
"remote_hash": "sha256:abc123...",
"sync_status": "synced"
},
"notes/draft.md": {
"last_local_modified": "2026-07-15T10:35:00Z",
"local_hash": "sha256:def456...",
"last_remote_modified": "2026-07-15T09:00:00Z",
"remote_hash": "sha256:old789...",
"sync_status": "local_newer"
}
}
}
```
### 7.3 图片存储方案
```
笔记目录/
├── notes/
│ ├── article.md
│ └── images/
│ └── article/
│ ├── screenshot-01.png
│ └── diagram-02.png
```
Markdown 中引用方式:
```markdown
![截图](images/article/screenshot-01.png)
```
同步时策略:
- **S3 模式**:图片上传到 `s3://bucket/prefix/notes/images/article/screenshot-01.png`,本地保留原始文件
- **图片引用不做 URL 替换**,保持相对路径,确保本地和远程均可渲染
---
## 8. 同步引擎设计
### 8.1 同步流程
```
用户操作 / 定时触发
┌─ 计算本地文件 Hash ─┐
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ 本地变更 │ │ 获取远程文件列表 │
│ 文件列表 │ │ (S3 ListObjects)│
└────┬─────┘ └──────┬───────┘
│ │
└───────┬───────────┘
┌──────────────┐
│ Diff 对比 │
│ 三元对比: │
│ 本地 / 远程 │
│ / 上次同步基线│
└──────┬───────┘
┌───────┼───────┐
▼ ▼ ▼
仅本地上传 仅远程下载 冲突
(Upload) (Download) (Conflict)
│ │ │
└───────┼───────┘
┌──────────────┐
│ 更新同步基线 │
│ (保存到本地) │
└──────────────┘
```
### 8.2 冲突处理策略
| 策略 | 描述 | 适用场景 |
|------|------|---------|
| `keep_local` | 以本地版本为准,覆盖远程 | 确定本地是最新 |
| `keep_remote` | 以远程版本为准,覆盖本地 | 刚换了新设备 |
| `keep_both` | 保留冲突副本 `filename_conflict_20260715.md` | 不确定优先级的默认策略 |
| `manual_merge` | 弹出对比界面,用户手动合并 | 重要文件 |
### 8.3 安全性
- **凭据加密**S3 的 AccessKey/SecretKey 使用 AES-256-GCM 加密存储在本机 KeychainmacOS Keychain / Windows Credential Manager / Linux Secret Service
- **传输加密**HTTPS/TLS 加密传输S3 SDK 默认开启)
- **本地文件不动**:同步失败不会删除或修改本地文件
### 8.4 同步协议抽象设计
```rust
// 统一的同步接口
#[async_trait]
pub trait SyncProtocol {
async fn connect(&self, config: SyncConfig) -> Result<(), SyncError>;
async fn list_files(&self, prefix: &str) -> Result<Vec<RemoteFile>, SyncError>;
async fn upload(&self, local_path: &Path, remote_key: &str) -> Result<(), SyncError>;
async fn download(&self, remote_key: &str, local_path: &Path) -> Result<(), SyncError>;
async fn delete(&self, remote_key: &str) -> Result<(), SyncError>;
async fn get_metadata(&self, remote_key: &str) -> Result<FileMetadata, SyncError>;
}
// 具体实现
pub struct S3SyncEngine { /* ... */ }
pub struct WebDAVSyncEngine { /* ... */ }
pub struct GitSyncEngine { /* ... */ }
pub struct WebRTCSyncEngine { /* ... */ }
```
---
## 9. 开发排期与里程碑
### 9.1 整体时间线
```
2026 Q3 2026 Q4 2027 Q1 2027 Q2
├──────┼──────────┼───────┼────────┼───────┼────────┼──────
│ │ │ │ │ │ │
Phase 1 Phase 2 Phase 3 Phase 4
(MVP) (S3同步) (高级编辑) (多协议同步)
8 周 6 周 8 周 6 周
```
### 9.2 Phase 1 MVP 详细排期8 周)
| 周次 | 里程碑 | 交付物 |
|------|--------|--------|
| W1 | 编辑器基础 | Milkdown 集成,基础 Markdown 实时渲染 |
| W2 | 编辑器进阶 | 代码块、表格、链接、图片、任务列表 |
| W3 | 文件系统 | 文件树侧边栏、新建/打开/保存文件 |
| W4 | 文件管理 | 最近文件、自动保存、文件监听刷新 |
| W5 | 主题系统 | 亮色/暗色主题、CSS 变量体系 |
| W6 | 导出功能 | HTML 导出、PDF 导出 |
| W7 | 打磨与测试 | 快捷键、Bug 修复、性能优化 |
| W8 | 发布 MVP | 打包、签名、发布 GitHub Release |
### 9.3 Phase 2 S3 同步详细排期6 周)
| 周次 | 里程碑 | 交付物 |
|------|--------|--------|
| W9-W10 | S3 连接层 | S3 Client 实现、连接配置 UI |
| W11 | 同步核心 | Hash 计算、Diff 引擎、上传/下载 |
| W12 | 冲突处理 | 冲突检测、三种冲突策略、状态 UI |
| W13 | 图片同步 | 图片上传 + 引用管理 |
| W14 | 打磨发布 | 首次配置向导、同步日志、发布 v0.2 |
### 9.4 关键指标
| 指标 | 目标值 |
|------|--------|
| 冷启动时间 | < 2 |
| 空闲内存占用 | < 100 MB |
| 打开 1000 行 .md 文件 | < 500ms |
| S3 同步 100 个文件 | < 30 首次/< 5 增量 |
| 冲突检测准确率 | 100%(不应有静默覆盖) |
---
## 10. 质量保障与测试策略
### 10.1 测试金字塔
```
┌───────┐
│ E2E │ 端到端测试 (Playwright)
│ 10% │
┌┴───────┴┐
│ 集成测试 │ 前后端联调 + 同步集成
│ 30% │
┌┴─────────┴┐
│ 单元测试 │ Rust + Vitest
│ 60% │
└────────────┘
```
### 10.2 各层测试策略
| 层级 | 工具 | 覆盖重点 |
|------|------|---------|
| Rust 单元测试 | `cargo test` | 文件操作、Hash 计算、同步协议逻辑 |
| Rust 集成测试 | `cargo test --test` | S3 客户端连接、同步引擎完整流程 |
| 前端单元测试 | Vitest + Vue Test Utils | 组件渲染、编辑器 API、状态管理 |
| E2E 测试 | Playwright | 核心用户路径(新建-编辑-保存-同步-导出) |
### 10.3 关键测试用例
- [ ] 创建新文件 → 编辑内容 → Ctrl+S 保存 → 文件系统验证内容正确
- [ ] 粘贴图片 → 图片保存到相对路径 → Markdown 引用正确
- [ ] 配置 S3 → 点击同步 → 文件出现在 S3 Bucket
- [ ] 两台设备修改同一文件 → 同步 → 检测到冲突 → 生成冲突副本
- [ ] 导出 PDF → 样式正确、图片完整、中文正常显示
- [ ] 暗色主题切换 → 编辑器/侧边栏/状态栏全部切换
---
## 11. 发布与运营策略
### 11.1 发布平台
| 平台 | 渠道 | 说明 |
|------|------|------|
| GitHub | Releases | 主要发布渠道,附带 Release Notes |
| macOS | Homebrew Cask | `brew install writeflow` |
| Windows | Winget / Chocolatey | 包管理器分发 |
| Linux | AppImage / Snap / Flatpak | 主流格式覆盖 |
| 官网 | writeflow.app | 下载页 + 文档 |
### 11.2 版本策略
- 采用语义化版本 `MAJOR.MINOR.PATCH`
- 偶数次版本号为稳定版,奇数次为开发版
- 每 4-6 周发布一个 MINOR 版本
### 11.3 开源策略
- **License**MIT最大化社区采用
- **贡献指南**CONTRIBUTING.md + Issue Template + PR Template
- **文档站点**VitePress 搭建,托管于 GitHub Pages
- **社区**GitHub Discussions + Discord Server
### 11.4 商业化(长期可选)
| 方式 | 说明 | 时机 |
|------|------|------|
| 完全免费开源 | 核心功能永久免费 | 从 Phase 1 起 |
| 赞助 | GitHub Sponsors / Open Collective | Phase 3 后 |
| 增值服务 | 官方托管同步服务WriteFlow Sync | Phase 5 后 |
| 企业版 | SAML/SSO、审计日志、集中管理 | Phase 5 后 |
---
## 12. 风险评估与应对
| 风险 | 影响 | 概率 | 应对措施 |
|------|------|------|---------|
| ProseMirror/Milkdown 集成复杂度超出预期 | 延期 2-4 周 | 中 | 备选方案:使用 markdown-it 双栏模式先上线 |
| S3 SDK 某些平台兼容性问题 | 同步功能不可用 | 低 | 多测试主力 S3 服务商,抽象接口可快速替换 |
| 文件系统同步冲突数据丢失 | 用户数据丢失 | 低 | 默认 `keep_both` 策略,绝不静默删除文件 |
| Tauri 2.x 大版本 API 变更 | 编译失败 | 中 | 锁定 Tauri 版本,定期评估升级风险 |
| 竞品Typora 复活/Obsidian 推出 WYSIWYG | 差异化被削弱 | 中 | 主打同步能力作为核心壁垒 |
| Linux 桌面碎片化导致兼容问题 | 部分 Linux 用户不可用 | 高 | AppImage 一份构建覆盖主流发行版 |
---
## 附录 A参考资源
- [Tauri v2 官方文档](https://v2.tauri.app/)
- [Milkdown - WYSIWYG Markdown Editor](https://milkdown.dev/)
- [ProseMirror 官方指南](https://prosemirror.net/docs/guide/)
- [Typora 产品设计参考](https://typora.io/)
- [S3 API 规范](https://docs.aws.amazon.com/AmazonS3/latest/API/Welcome.html)
- [WebDAV RFC 4918](https://www.rfc-editor.org/rfc/rfc4918)
- [Naive UI](https://www.naiveui.com/)
## 附录 B命名备选
| 名称 | 含义 | 可用性 |
|------|------|--------|
| WriteFlow | 写作流动 | 暂未注册 |
| MarkNote | Markdown + Note | 暂未注册 |
| InkWell | 墨池 | 暂未注册 |
| Scripta | 拉丁文"写作" | GitHub 同名项目存在 |
| TypeStone | 刻字石 | 暂未注册 |
| **WriteFlow** ✅ | 最终选择 | — |
---
> 文档维护:产品团队
> 下次评审时间Phase 1 MVP 完成后

View File

@ -0,0 +1,390 @@
# WriteFlow 开发提示词(迭代式开发指南)
> 本指南基于 [README.md](./README.md) 与 [产品策划开发手册](./产品策划开发手册.md) 制定,采用**增量迭代**方式推进。每个阶段均有明确的交付物、技术要点、验收标准,确保代码质量、功能完整性和用户体验一致性。
> **UI 开发必须遵循 `/ui-ux-pro-max` 规范**(即符合产品设计原则、使用 Naive UI 组件库、响应式布局、暗色/亮色主题无缝切换)。
---
## 总体开发原则
1. **本地优先,数据安全**:所有笔记以 `.md` 文件存储于本地,同步仅作为附加能力,不得破坏本地数据。
2. **所见即所得为核心体验**:编辑器必须达到 Typora 级别的流畅度和渲染效果。
3. **渐进增强**:先保证核心编辑与文件管理可用,再叠加同步、高级功能。
4. **跨平台一致性**:在 Windows/macOS/Linux 上行为一致UI 适配各平台习惯(如菜单栏、快捷键)。
5. **代码规范**
- 前端TypeScript + ESLint + PrettierVue 官方风格指南)
- 后端Rust 1.70+,遵循 Rustfmt + Clippy模块化设计错误处理清晰。
6. **测试覆盖**:关键路径(文件读写、同步、冲突处理)必须有单元测试和集成测试。
7. **性能目标**:冷启动 < 2s打开 1000 行文件 < 500ms空闲内存 < 100MB
---
## Phase 1 MVP 基础编辑器8 周)
**目标**:提供可用的本地 Markdown 所见即所得编辑器,包含文件管理、基础主题、导出 HTML/PDF。
**UI/UX 依据**:设计规范第 6 章(沉浸优先、无干扰、反馈及时),使用 Naive UI 组件(布局、按钮、弹窗、消息提示)。
---
### 迭代 1.1 项目骨架与编辑器内核W1
#### 任务清单
- [ ] **初始化 Tauri + Vue 3 + TypeScript 项目**
- 使用 `create-tauri-app` 选择 Vue 3 + TypeScript。
- 配置 `vite.config.ts`,启用别名 `@` 指向 `src`
- 安装依赖:`naive-ui`、`milkdown`(含 `@milkdown/vue`)、`@milkdown/theme-nord`(或其他主题)、`@milkdown/preset-commonmark`。
- [ ] **集成 Milkdown 编辑器组件**
- 在 `App.vue` 或独立 `Editor.vue` 中挂载 Milkdown 编辑器。
- 配置基本 CommonMark 预设,实现标题、加粗、斜体、列表、引用、代码块、链接、图片、表格(基础)的实时渲染。
- 关闭 Milkdown 的工具栏,实现**纯键盘驱动**的所见即所得(光标所在行自动隐藏标记符号)。
- [ ] **实现自定义标题栏(无系统边框)**
- 修改 `tauri.conf.json``"windows"` 配置 `"decorations": false`
- 在 Vue 中绘制标题栏,包含应用 Logo、标题当前文件名、窗口控制按钮最小化、最大化、关闭
- 实现窗口拖拽区域:使用 Tauri `data-tauri-drag-region` 属性。
- [ ] **基础的亮色/暗色主题**
- 定义 CSS 变量(色值参考设计规范 6.4)。
- 提供顶部菜单或设置项切换主题,利用 Naive UI 的 `n-config-provider` 配合自定义类。
#### 关键技术点
- Milkdown 编辑器的 `editor` 实例管理(通过 `ref` 获取,用于后续内容读写)。
- Tauri 的 `window` API 控制窗口大小和位置。
- 使用 `@tauri-apps/api/event` 监听系统主题变更(可选)。
#### 验收标准
- [ ] 启动后编辑器可输入Markdown 语法正确渲染。
- [ ] 标题栏可拖拽移动窗口,控制按钮功能正常。
- [ ] 主题切换后编辑器背景、文字、边框颜色同步变化。
- [ ] 无控制台报错,内存占用 < 50MB空文档
---
### 迭代 1.2 文件系统集成W2W3
#### 任务清单
- [ ] **实现文件树侧边栏**
- 使用 Naive UI 的 `n-tree` 组件,绑定目录结构数据。
- 通过 Tauri `fs` 模块(`read_dir`)递归读取工作区目录。
- 支持展开/折叠文件夹,点击 `.md` 文件在编辑器中打开。
- [ ] **新建/打开/保存文件**
- 新建:生成 `untitled.md`,弹窗让用户重命名(或自动生成时间戳文件名)。
- 打开:使用 `dialog.open()` 选择 `.md` 文件,读取内容并渲染。
- 保存:使用 `fs.writeFile()`,支持 `Ctrl+S` 快捷键,同时自动保存到当前文件。
- [ ] **最近打开文件列表**
- 使用 `localStorage` 或 Tauri `store` 插件存储最近 10 个文件路径。
- 在菜单或侧边栏显示快速入口。
- [ ] **工作区管理(单工作区 MVP**
- 首次启动引导用户选择笔记目录(`dialog.open({ directory: true })`)。
- 将路径持久化(`store` 或配置文件),后续启动自动加载该目录。
#### 技术要点
- 文件读写使用 Tauri 的 `tauri::fs``tauri::dialog` 模块,注意权限配置(`capabilities`)。
- 文件树数据使用 `ref` 响应式,结合 `watch` 监听目录变更(后续可升级为 `notify` 后端监听)。
- 编辑器内容与文件路径双向绑定:打开文件 → 设置 `editor` 内容,保存时读取 `editor` 内容写入文件。
#### 验收标准
- [ ] 文件树正确显示目录及 `.md` 文件,点击文件立即在编辑器中打开。
- [ ] 新建文件后保存,文件出现在文件树中。
- [ ] 最近文件列表正确记录,重启后依然存在。
- [ ] 打开非文本文件或非 MD 文件时,给出友好提示。
---
### 迭代 1.3 编辑体验增强W4
#### 任务清单
- [ ] **自动保存与手动保存状态**
- 每隔 30 秒自动保存当前文件(若有更改)。
- 状态栏显示“已保存”或“正在保存...”图标Naive UI `n-icon`)。
- [ ] **快捷键体系(核心)**
- `Ctrl+N` 新建,`Ctrl+O` 打开,`Ctrl+S` 保存,`Ctrl+Shift+S` 另存为。
- `Ctrl+Z`/`Ctrl+Y` 撤销/重做Milkdown 内置)。
- `Ctrl+F` 查找(后续迭代)。
- 使用 Tauri `globalShortcut` 或前端 `@vueuse/core``useMagicKeys`
- [ ] **字数统计与文档信息**
- 状态栏显示当前文档字数(中英文混合计数)、行数、光标位置。
- 使用 `@milkdown/utils``prose` 或自行解析 AST 统计。
- [ ] **焦点模式(可选)**
- 快捷键 `F11` 或菜单切换:隐藏侧边栏,编辑器全屏。
- 使用 CSS 动画过渡,保持平滑。
#### UI/UX 规范检查
- 状态栏位于底部,信息左对齐,同步状态(尚无)预留位置。
- 快捷键提示可悬浮显示(使用 `n-tooltip`)。
#### 验收标准
- [ ] 自动保存生效,关闭窗口或切换文件时不丢失编辑内容。
- [ ] 核心快捷键全平台Win/Cmd 适配)正常。
- [ ] 字数统计准确(英文单词、中文字符、总字符)。
- [ ] 焦点模式切换流畅,无闪烁。
---
### 迭代 1.4 主题与导出W5W6
#### 任务清单
- [ ] **完整的亮色/暗色主题切换**
- 提供至少三种配色默认亮色、默认暗色、Sepia暖色
- 使用 Naive UI 的 `n-config-provider` 全局管理主题同时自定义编辑器样式Milkdown 的样式覆盖)。
- [ ] **导出 HTML**
- 将当前 Markdown 内容转换为 HTML可借助 Milkdown 的 `getMarkdown` + `markdown-it` 或直接使用 Milkdown 渲染结果)。
- 打包为一个自包含的 `.html` 文件(含样式),通过 Tauri `dialog.save()` 选择保存位置。
- [ ] **导出 PDF**
- 方案一:使用 `window.print()` 配合 `@media print` 样式实现打印为 PDF跨平台简单
- 方案二:使用 Rust 后端(`printpdf`)生成 PDF更专业但复杂度高。MVP 采用方案一,后续替换。
- 导出前确保样式一致(保留主题)。
- [ ] **设置存储**
- 使用 Tauri `store` 插件(或 `confy` crate保存用户偏好主题、最近工作区、自动保存间隔等。
#### 技术要点
- 导出 HTML 时,需内联主题 CSS 和 Milkdown 渲染样式,确保外部打开显示正常。
- PDF 导出使用 Tauri 的 `WebviewWindow``print` 方法或调用系统打印对话框。
#### 验收标准
- [ ] 三种主题切换后,编辑器、侧边栏、状态栏颜色同步。
- [ ] 导出 HTML 文件,在浏览器中打开与编辑器内显示一致。
- [ ] 导出 PDF 内容完整,中文不乱码,图片(若有)正常显示。
- [ ] 用户设置(主题、最近工作区)在重启后保留。
---
### 迭代 1.5 错误处理与基础测试W7W8
#### 任务清单
- [ ] **全局错误处理**
- 前端Vue 全局错误处理器(`app.config.errorHandler`)捕获并显示友好提示(`n-notification`)。
- Rust使用 `anyhow` 或自定义错误类型Tauri Command 返回 `Result`,错误信息通过 `invoke` 传递到前端。
- [ ] **文件操作异常场景**
- 文件被外部修改(只读、删除)时的提示。
- 磁盘空间不足、权限错误等提示。
- [ ] **单元测试Rust**
- 文件读写函数、Hash 计算、路径处理等工具函数。
- 使用 `cargo test`,覆盖率 > 70%。
- [ ] **前端单元测试Vitest**
- 测试文件树组件、编辑器组件(通过 Mock 编辑器 API
- [ ] **E2E 测试Playwright**
- 核心路径:启动 → 新建文件 → 输入内容 → 保存 → 重新打开 → 内容一致。
- 导出 HTML 和 PDF 可验证文件生成。
#### 验收标准
- [ ] 所有错误场景有对应的用户提示,不崩溃。
- [ ] 单元测试全部通过。
- [ ] E2E 测试通过。
- [ ] 打包后的应用在 Windows/macOS/Linux 上可运行(基础功能)。
---
### Phase 1 交付物
- 可安装的桌面应用(`.msi`/`.dmg`/`.AppImage`)。
- 源码仓库包含完整的开发文档README、CONTRIBUTING
- 项目网站VitePress上线包含下载链接和用户手册。
---
## Phase 2 S3 同步引擎6 周)
**目标**:实现基于 S3 协议的云端同步,支持手动/自动同步、冲突检测、图片上传。
---
### 迭代 2.1 S3 连接层W9W10
#### 任务清单
- [ ] **添加 Rust S3 客户端**
- 依赖 `aws-sdk-s3``rust-s3`,实现 `SyncProtocol` trait参考设计文档 8.4)。
- 实现 `connect`、`list_files`、`upload`、`download`、`delete`、`get_metadata` 方法。
- [ ] **凭据安全存储**
- 使用 `keyring` cratemacOS/Windows/Linux 统一)或 Tauri 的 `store` 加密存储 AccessKey/SecretKey。
- 前端提供配置表单Endpoint、Bucket、Region、AccessKey、SecretKey通过 Tauri Command 保存。
- [ ] **连接测试**
- 提供“测试连接”按钮验证凭据是否正确Bucket 是否可访问。
#### 技术要点
- 使用 `async`/`await`,超时控制(`tokio::time::timeout`)。
- 处理 S3 的路径前缀(`prefix`)作为远程目录。
#### 验收标准
- [ ] 配置正确的 S3 信息后,测试连接成功。
- [ ] 凭据加密存储,无法在明文配置文件中读取。
- [ ] 列出远程文件(含前缀)正常。
---
### 迭代 2.2 同步核心W11
#### 任务清单
- [ ] **计算文件 Hash**
- 使用 `sha2``blake3` 计算文件内容的哈希值。
- 存储本地元数据:`last_modified`、`hash`、`sync_status`。
- [ ] **Diff 引擎**
- 对比本地文件、远程文件、上次同步基线(存储在本地 `.writeflow/sync_meta.json`)。
- 分类:`local_newer`、`remote_newer`、`both_changed`(冲突)、`identical`。
- [ ] **上传/下载执行**
- 实现批量上传(并发控制,避免过多连接)。
- 实现增量下载(仅下载变更文件)。
- 进度反馈:通过 Tauri 事件向前端发送进度信息(`emit`)。
#### 技术要点
- 使用 `tokio` 并发,控制并发数(如 5 个)。
- 前端显示同步进度条(`n-progress`)。
#### 验收标准
- [ ] 本地新增文件 → 同步后出现在远程。
- [ ] 远程新增文件 → 同步后下载到本地。
- [ ] 本地文件修改 → 同步后远程更新。
- [ ] 远程文件修改 → 同步后本地更新。
---
### 迭代 2.3 冲突处理与状态 UIW12
#### 任务清单
- [ ] **冲突检测与策略**
- 实现三种策略:`keep_local`、`keep_remote`、`keep_both`(默认)。
- `keep_both` 生成 `filename_conflict_YYYYMMDDHHMMSS.md`
- [ ] **同步状态 UI**
- 状态栏显示同步状态(空闲、同步中、冲突、错误)。
- 文件树中每个文件显示同步图标(绿勾、橙色感叹号、红色叉)。
- 点击状态可查看详细信息(使用 `n-drawer``n-modal`)。
- [ ] **手动/自动同步**
- 提供“立即同步”按钮。
- 定时同步(可配置间隔,默认 5 分钟)。
#### 验收标准
- [ ] 两台设备修改同一文件,同步后生成冲突副本,原文件保留不丢失。
- [ ] 状态指示器实时更新,冲突有明确提示。
- [ ] 自动同步按设定间隔执行。
---
### 迭代 2.4 图片同步与首次配置向导W13W14
#### 任务清单
- [ ] **图片自动上传**
- 监听编辑器中的图片插入(拖拽或粘贴),使用 Tauri `fs` 读取图片文件并上传至 S3。
- 替换 Markdown 中的图片路径为相对路径(如 `images/xxx.png`),同时上传到远程对应路径。
- [ ] **图片引用管理**
- 确保本地和远程图片目录结构一致。
- [ ] **首次配置向导**
- 启动时检测是否已配置同步,若无则弹出向导(`n-steps`)。
- 引导选择工作区、配置 S3 信息、测试连接。
- [ ] **同步日志**
- 提供查看同步日志的界面(`n-log` 或 `n-textarea`),便于调试。
#### 验收标准
- [ ] 粘贴图片到编辑器,自动上传到 S3并在本地生成相对路径引用。
- [ ] 在另一设备同步后,图片正常显示。
- [ ] 向导流程完整,可跳过或稍后配置。
- [ ] 同步日志记录关键操作。
---
### Phase 2 交付物
- 完整的 S3 同步功能支持公开云AWS、阿里云等和私有部署MinIO
- 冲突处理机制完善,数据安全有保障。
---
## Phase 3 高级编辑功能8 周)
**目标**:补齐 Typora 的高级能力,包括代码高亮、数学公式、图表、目录大纲、多种导出格式。
> 由于篇幅,此处仅列出关键迭代,细节可参考产品手册 4.3。
---
### 迭代 3.1 代码高亮与数学公式
- 引入 `Shiki`Rust 端可预高亮)或前端 `prism.js` 实现语法高亮。
- 使用 `KaTeX` 渲染行内和块级公式。
- 配置 Milkdown 插件:`@milkdown/plugin-math`(需等待适配)或自定义。
### 迭代 3.2 Mermaid 图表与表格编辑器
- 集成 `Mermaid.js`,在渲染阶段将代码块 `mermaid` 转换为 SVG。
- 使用 Naive UI 的 `n-table` 构建可视化表格编辑器(弹窗形式),支持增删行列、对齐。
### 迭代 3.3 文档大纲与导航
- 解析 Markdown 标题,在侧边栏或独立面板生成大纲树,点击跳转。
- 使用 `@milkdown/plugin-toc` 或自行解析。
### 迭代 3.4 导出增强PDF、Word、图片
- 替换 `window.print()` 为 Rust `genpdf``html-to-pdf` 引擎,提升 PDF 质量。
- 集成 `docx` crate 或调用 Pandoc需用户安装导出 Word。
- 导出为 PNG截图
---
## Phase 4 多协议同步6 周)
**目标**:支持 WebDAV、Git、WebRTC 等协议,扩展同步场景。
> 每个协议作为一个独立迭代,实现相同的 `SyncProtocol` trait复用核心引擎。
---
### 迭代 4.1 WebDAV
- 使用 `reqwest` 实现 PROPFIND、PUT、GET、DELETE。
- 支持摘要认证和 OAuth2如坚果云
### 迭代 4.2 Git
- 集成 `git2-rs`,实现 clone、pull、push、commit。
- 提供版本历史查看(类似 Git 日志)。
### 迭代 4.3 WebRTC
- 使用 `webrtc-rs` 或通过 Tauri 调用前端 WebRTC实现 P2P 直连同步。
- 需要信令服务器(简单实现)。
---
## Phase 5 体验打磨与生态(持续)
**目标**:多标签页、全文搜索、快捷键自定义、主题市场、插件系统。
---
### 迭代 5.1 多标签页
- 使用 `n-tabs` 管理多个打开的文件,支持拖拽排序。
- 每个标签页独立编辑器实例(或共享实例切换内容)。
### 迭代 5.2 全文搜索
- 使用 `tauri` 后端遍历文件内容,结合 `tantivy``ripgrep` 实现高速搜索。
- 前端结果列表,点击跳转并高亮匹配项。
### 迭代 5.3 快捷键自定义
- 提供设置界面,使用 `n-dynamic-tags` 编辑快捷键映射。
- 存储为 JSON动态注册/注销。
### 迭代 5.4 插件系统
- 基于 `deno``quickjs` 嵌入 JavaScript 引擎,允许用户编写插件扩展。
- 提供插件 API`onSave`、`onRender`)。
---
## 开发流程与质量保障
### 代码审查
- 每次 PR 必须通过 CI检查 lint、test、build
- 至少一名 Reviewer 批准。
### 持续集成
- GitHub Actions
- 前端 lint + test
- Rust clippy + test
- 构建 Tauri 应用(仅 PR 检查,不发布)
### 文档同步
- 所有 API 变更更新 TSDoc/Rustdoc。
- 用户手册同步更新VitePress
---
## 附录UI 开发规范(/ui-ux-pro-max
1. **使用 Naive UI 组件**:优先使用 `n-*` 组件,避免自定义基础组件(除非必要)。
2. **响应式适配**:窗口缩放时编辑器宽度自适应,侧边栏可折叠。
3. **暗色/亮色完全支持**:所有颜色使用 CSS 变量,随主题切换。
4. **交互反馈**:操作成功/失败使用 `n-message``n-notification`,加载状态使用 `n-spin`
5. **可访问性**使用语义化标签键盘导航Tab 顺序合理)。
6. **布局**:遵循设计规范第 6 章,标题栏高度 32px状态栏 28px编辑器填充适当留白。
---
**开始开发**:请从 **Phase 1 迭代 1.1** 入手,完成后再推进后续。每个迭代完成后进行自测,并更新 CHANGELOG。祝开发顺利