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:
parent
7efa839f7a
commit
c20fee000e
|
|
@ -0,0 +1,285 @@
|
|||
# Vue3 开发效率翻倍!2026 年必备 20+ 实用 npm 插件库精选
|
||||
|
||||
Original 尔嵘 尔嵘
|
||||
|
||||
---
|
||||
|
||||
在 Vue3 + Vite 生态下,优质的 npm 插件能大幅简化开发流程、解决常见痛点、提升项目性能与交互体验。本文整理了前端开发、Vue3 专属、工具类、UI 增强、网络请求、表单/表格、可视化、工程化八大场景的实用插件,全部支持 Vue3 + TypeScript,开箱即用,附核心用途 + 快速上手代码,新手也能直接用。
|
||||
|
||||
---
|
||||
|
||||
## 一、Vue3 核心生态插件(必装)
|
||||
|
||||
### 1. Pinia - Vue3 官方状态管理库
|
||||
|
||||
替代 Vuex,Vue3 官方推荐的状态管理,语法简洁、支持 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,无兼容坑点。
|
||||
|
||||
- 新手优先安装核心生态 + 工具类插件,快速上手开发;
|
||||
- 中大型项目可补充表单表格、可视化、工程化插件,提升团队效率;
|
||||
- 所有插件均提供官方安装命令,直接复制即可使用。
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
[package]
|
||||
name = "part6"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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` + 组合键
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Tauri App",
|
||||
"title": "WriteFlow",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"decorations": false
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@
|
|||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 MVP(Phase 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
|
||||

|
||||
```
|
||||
|
||||
同步时策略:
|
||||
- **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 加密存储在本机 Keychain(macOS 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 完成后
|
||||
|
|
@ -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 + Prettier(Vue 官方风格指南)
|
||||
- 后端: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 – 文件系统集成(W2–W3)
|
||||
|
||||
#### 任务清单
|
||||
- [ ] **实现文件树侧边栏**
|
||||
- 使用 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 – 主题与导出(W5–W6)
|
||||
|
||||
#### 任务清单
|
||||
- [ ] **完整的亮色/暗色主题切换**
|
||||
- 提供至少三种配色:默认亮色、默认暗色、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 – 错误处理与基础测试(W7–W8)
|
||||
|
||||
#### 任务清单
|
||||
- [ ] **全局错误处理**
|
||||
- 前端: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 连接层(W9–W10)
|
||||
|
||||
#### 任务清单
|
||||
- [ ] **添加 Rust S3 客户端**
|
||||
- 依赖 `aws-sdk-s3` 或 `rust-s3`,实现 `SyncProtocol` trait(参考设计文档 8.4)。
|
||||
- 实现 `connect`、`list_files`、`upload`、`download`、`delete`、`get_metadata` 方法。
|
||||
- [ ] **凭据安全存储**
|
||||
- 使用 `keyring` crate(macOS/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 – 冲突处理与状态 UI(W12)
|
||||
|
||||
#### 任务清单
|
||||
- [ ] **冲突检测与策略**
|
||||
- 实现三种策略:`keep_local`、`keep_remote`、`keep_both`(默认)。
|
||||
- `keep_both` 生成 `filename_conflict_YYYYMMDDHHMMSS.md`。
|
||||
- [ ] **同步状态 UI**
|
||||
- 状态栏显示同步状态(空闲、同步中、冲突、错误)。
|
||||
- 文件树中每个文件显示同步图标(绿勾、橙色感叹号、红色叉)。
|
||||
- 点击状态可查看详细信息(使用 `n-drawer` 或 `n-modal`)。
|
||||
- [ ] **手动/自动同步**
|
||||
- 提供“立即同步”按钮。
|
||||
- 定时同步(可配置间隔,默认 5 分钟)。
|
||||
|
||||
#### 验收标准
|
||||
- [ ] 两台设备修改同一文件,同步后生成冲突副本,原文件保留不丢失。
|
||||
- [ ] 状态指示器实时更新,冲突有明确提示。
|
||||
- [ ] 自动同步按设定间隔执行。
|
||||
|
||||
---
|
||||
|
||||
### 迭代 2.4 – 图片同步与首次配置向导(W13–W14)
|
||||
|
||||
#### 任务清单
|
||||
- [ ] **图片自动上传**
|
||||
- 监听编辑器中的图片插入(拖拽或粘贴),使用 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。祝开发顺利!
|
||||
Loading…
Reference in New Issue