Tauri入门教程

  目录

从零开始构建「云笔记」桌面应用(Tauri 2 + Vue 3 + TypeScript)

本教程带你一步步实现一个功能完整的桌面笔记应用。完成后的应用包含:

  • 登录界面(账号 admin / 密码 123456,校验失败提示并抖动)
  • 笔记的新建、编辑、删除、搜索、自动保存
  • Markdown 编辑与预览(编辑 / 预览 / 分屏三种模式)
  • 置顶与收藏(置顶优先排序 + 只看收藏筛选)
  • 深色 / 浅色主题切换(可记忆偏好)
  • 回收站(软删除,可恢复或彻底清除)
  • 数据持久化(localStorage)

技术栈:Tauri 2(Rust 后端 + WebView 前端)+ Vue 3(组合式 API + <script setup>)+ Vite + TypeScript + markdown-it


目录

  1. 项目简介
  2. 环境准备
  3. 创建项目
  4. 项目结构说明
  5. 安装依赖与前端配置
  6. 编写前端代码
    • 6.1 入口 main.ts
    • 6.2 数据处理 useNotes.ts
    • 6.3 主题切换 useTheme.ts
    • 6.4 Markdown 渲染 markdown.ts
    • 6.5 登录组件 LoginView.vue
    • 6.6 主界面 NotesView.vue
    • 6.7 编辑器 NoteEditor.vue
    • 6.8 回收站 TrashPanel.vue
    • 6.9 根组件 App.vue
    • 6.10 全局样式 styles.css
  7. Rust 后端说明
  8. 运行与调试
  9. 打包发布
  10. 常见问题

1. 项目简介

最终成果是一个两屏的桌面应用:

  • 登录屏:校验账号密码,失败出现错误提示并抖动表单。
  • 主界面:左侧是筛选标签(全部 / 收藏 / 回收站)+ 搜索框 + 笔记列表 + 用户信息与工具栏;右侧根据选中状态显示编辑器、空状态或回收站面板。

核心能力一览:

能力 说明
增删改查 新建、编辑标题/正文、删除(带确认,移入回收站)
搜索 按标题 + 正文实时过滤
自动保存 停止输入 400ms 后自动写入 localStorage,右上角显示「保存中… / 已保存」
Markdown 编辑 / 预览 / 分屏三种模式,实时渲染
置顶收藏 置顶优先排序,可只看收藏,列表显示小图标标记
主题切换 深色 / 浅色,偏好存入 localStorage
回收站 软删除,可恢复、彻底删除、清空

登录状态、笔记数据、主题偏好都只存在前端,无需后端接口,非常适合作为 Tauri 入门项目。看懂它,你就掌握了:Vue 3 组合式 API、响应式计算属性、组件通信、组合式函数复用、CSS 变量主题、Markdown 渲染等一整套前端实用技能。


2. 环境准备

安装以下工具(以 Windows 为例)。

2.1 Node.js(≥ 18)

下载安装:https://nodejs.org/(推荐 LTS 版本)

验证:

1
2
node --version
npm --version

2.2 Rust 工具链

Windows 推荐用 rustup 安装:https://rustup.rs/

1
2
rustc --version
cargo --version

2.3 Tauri 前置(Windows 专属)

Tauri 需要 WebView2(Windows 10/11 通常已内置)和 Microsoft C++ Build Tools(含 Windows SDK)。

参考官方文档:https://v2.tauri.org.cn/start/prerequisites/

如果后续 tauri dev 编译失败,多数是缺少 MSVC 构建工具。安装「Visual Studio Build Tools」时勾选「使用 C++ 的桌面开发」即可。

知识点延申:Tauri 为什么需要 WebView2?
Tauri 不做自己的渲染内核,而是复用操作系统自带的 WebView:Windows 用 WebView2(Edge Chromium)、macOS 用 WKWebView、Linux 用 WebKitGTK。所以用它打包的应用体积可以做到很小(几 MB),前端部分就是普通的 HTML/CSS/JS。你写的 Vue 代码,最终就是被加载进这个 WebView 里运行的。


3. 创建项目

3.1 用脚手架生成骨架

在任意目录执行(会创建名为 yunnote 的文件夹):

1
npm create tauri-app@latest yunnote -t vue-ts -m npm -y

参数说明:

  • yunnote:项目名/目录名
  • -t vue-ts:使用 Vue + TypeScript 模板
  • -m npm:包管理器用 npm
  • -y:跳过交互提问,使用默认值

进入项目目录:

1
cd yunnote

此时目录结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
yunnote/
├─ src/ # 前端源码(Vue)
├─ src-tauri/ # Rust 后端
│ ├─ src/ # main.rs / lib.rs
│ ├─ icons/ # 应用图标
│ ├─ capabilities/ # 权限配置
│ ├─ Cargo.toml
│ ├─ tauri.conf.json # Tauri 配置
│ └─ build.rs
├─ index.html
├─ package.json
├─ vite.config.ts
└─ tsconfig.json

说明:create-tauri-app 生成的 src/ 里是模板示例代码,接下来我们会全部替换成自己的代码。


4. 项目结构说明

先理解关键文件的作用:

文件 作用
package.json 前端依赖与脚本命令
vite.config.ts Vite 打包配置(含 Vue 插件、端口 1420)
src-tauri/tauri.conf.json Tauri 应用配置(窗口、打包、构建命令)
src-tauri/Cargo.toml Rust 依赖与包名配置

执行流程:

  • 开发时:npm run tauri dev → 先启动 Vite(端口 1420)→ 再编译 Rust 并打开窗口加载 http://localhost:1420
  • 打包时:npm run build 产出 dist/ → Tauri 把 dist/ 和 Rust 二进制一起打成安装包

知识点延申:为什么开发时有两个「进程」?
开发模式其实是两个东西同时跑:① Vite 开发服务器(提供带热更新的前端页面),② 编译后的 Rust 原生窗口(负责真正的桌面窗口)。Rust 窗口里加载的 URL 指向 Vite 服务器(devUrl),所以你改前端代码,Vite 热更新,窗口里立刻就能看到。打包后则不再需要 Vite,改成直接加载本地 dist/ 目录。


5. 安装依赖与前端配置

5.1 替换 package.json

相比模板,我们新增了三个依赖:vue(Vue 运行时,模板已含)、markdown-it(Markdown 渲染)、@types/markdown-it(类型声明)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"name": "yunnote",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"markdown-it": "^14.1.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^6.0.3"
}
}

关键点:

  • dependencies vs devDependencies:dependencies 是运行时需要的(会打进最终产物),markdown-it 和 vue 都在这里;devDependencies 只在开发/构建阶段用(如 typescript、vite、插件、类型包)。
  • build 脚本先跑 tsc 做类型检查,再跑 vite build 打包。

5.2 替换 vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

const host = process.env.TAURI_DEV_HOST;

export default defineConfig(async () => ({
plugins: [vue()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));

关键点:

  • plugins: [vue()] 注册 Vue 插件,否则 Vite 不认识 .vue 文件
  • port: 1420 与 tauri.conf.json 里的 devUrl 对应,必须一致
  • strictPort: true 端口被占用直接报错,避免 Tauri 连不上

5.3 tsconfig.json

增加了 esModuleInterop: true,这是为了用默认导入的方式 import MarkdownIt from "markdown-it"(markdown-it 的类型用的是 CommonJS 导出风格)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"esModuleInterop": true,

"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,

"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

知识点延申:esModuleInterop 是什么?
很多老牌 npm 包(如 markdown-it)是用 CommonJS 的 module.exports = ... 发布的。TypeScript 默认把它当成「导出对象」而不是「默认导出」。开启 esModuleInterop 后,import MarkdownIt from "markdown-it" 这类默认导入就能正确工作,等价于自动处理了 CJS ↔ ESM 的互操作。现代项目里这一步几乎必开。

5.4 新增 src/vite-env.d.ts

让 TypeScript 认识 .vue 和 .css 文件:

1
2
3
4
5
6
7
8
9
/// <reference types="vite/client" />

declare module "*.vue" {
import type { Component } from "vue";
const component: Component;
export default component;
}

declare module "*.css";

知识点延申:为什么需要这个文件?
TypeScript 编译器本身不认识 .vue 文件,import App from "./App.vue" 会报「找不到模块」。declare module "*.vue" 是「环境声明」,告诉 TS:任何以 .vue 结尾的导入都是一个 Vue 组件,别报错。/// <reference types="vite/client" /> 则引入了 Vite 提供的环境类型(如对 .css、图片等资源导入的支持)。

5.5 替换 index.html

1
2
3
4
5
6
7
8
9
10
11
12
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云笔记</title>
<script type="module" src="/src/main.ts" defer></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

只有一个 <div id="app">,Vue 会把整个应用挂载到这里。

5.6 安装依赖

1
npm install

6. 编写前端代码

前端采用组合式 API + <script setup>。目录结构如下(相比之前多了 composables 里的主题函数、utils、以及拆分的编辑器与回收站组件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
src/
├─ main.ts # Vue 入口
├─ App.vue # 根组件(登录/主界面切换 + 主题初始化)
├─ styles.css # 全局样式(深色 + 浅色主题)
├─ vite-env.d.ts # 类型声明
├─ composables/
│ ├─ useNotes.ts # 笔记数据与全部业务逻辑(单例 store)
│ └─ useTheme.ts # 主题切换
├─ utils/
│ └─ markdown.ts # Markdown 渲染
└─ components/
├─ LoginView.vue # 登录页
├─ NotesView.vue # 笔记主界面(侧边栏 + 路由到编辑器/回收站)
├─ NoteEditor.vue # 编辑器(含三模式 + 置顶/收藏/删除)
└─ TrashPanel.vue # 回收站面板

6.1 入口 src/main.ts

1
2
3
4
5
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";

createApp(App).mount("#app");

几乎没变化:创建 Vue 应用,导入全局样式,挂载到 #app。

6.2 数据处理 src/composables/useNotes.ts

这是整个应用的「心脏」,集中管理笔记的状态与业务逻辑。和简单教程里「把逻辑写在组件里」不同,这里把数据逻辑抽离成组合式函数,并且做成了模块级单例,让多个组件能共享同一份数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
import { computed, ref } from "vue";

export interface Note {
id: string;
title: string;
content: string;
createdAt: number;
updatedAt: number;
pinned: boolean;
favorite: boolean;
deleted: boolean;
deletedAt?: number;
}

const STORAGE_KEY = "yunnote.notes.v1";

function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

// 兼容旧数据:为缺失字段补上默认值
function normalize(n: Partial<Note>): Note {
const now = Date.now();
return {
id: n.id ?? uid(),
title: n.title ?? "",
content: n.content ?? "",
createdAt: n.createdAt ?? now,
updatedAt: n.updatedAt ?? now,
pinned: n.pinned ?? false,
favorite: n.favorite ?? false,
deleted: n.deleted ?? false,
deletedAt: n.deletedAt,
};
}

function loadNotes(): Note[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Partial<Note>[];
return Array.isArray(parsed) ? parsed.map(normalize) : [];
} catch {
return [];
}
}

export function formatTime(ts: number): string {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(ts);
}

// —— 以下是模块级共享状态(单例),多个组件调用 useNotes() 拿到的都是同一份数据 ——
const notes = ref<Note[]>(loadNotes());
const currentId = ref<string | null>(null);
const searchQuery = ref("");
const saveStatus = ref("已保存");
let saveTimer: number | undefined;

const current = computed<Note | null>(() => {
const n = currentId.value ? notes.value.find((x) => x.id === currentId.value) : undefined;
// 已删除的笔记不能作为正在编辑的当前笔记
return n && !n.deleted ? n : null;
});

// 未删除的笔记:置顶优先,其余按更新时间倒序
const activeNotes = computed<Note[]>(() =>
notes.value
.filter((n) => !n.deleted)
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt)
);

// 回收站中的笔记:按删除时间倒序
const trashNotes = computed<Note[]>(() =>
notes.value
.filter((n) => n.deleted)
.sort((a, b) => (b.deletedAt ?? 0) - (a.deletedAt ?? 0))
);

function persist(): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(notes.value));
}

function markDirty(): void {
saveStatus.value = "保存中…";
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
persist();
saveStatus.value = "已保存";
}, 400);
}

function selectNote(id: string): void {
const n = notes.value.find((x) => x.id === id);
if (!n || n.deleted) return;
currentId.value = id;
saveStatus.value = "已保存";
}

function newNote(): void {
const now = Date.now();
const note: Note = {
id: uid(),
title: "",
content: "",
createdAt: now,
updatedAt: now,
pinned: false,
favorite: false,
deleted: false,
};
notes.value.unshift(note);
currentId.value = note.id;
saveStatus.value = "已保存";
persist();
}

function updateCurrent(patch: { title?: string; content?: string }): void {
if (!currentId.value) return;
const n = notes.value.find((x) => x.id === currentId.value);
if (!n) return;
if (patch.title !== undefined) n.title = patch.title;
if (patch.content !== undefined) n.content = patch.content;
n.updatedAt = Date.now();
markDirty();
}

function togglePinned(): void {
if (!current.value) return;
current.value.pinned = !current.value.pinned;
persist();
}

function toggleFavorite(): void {
if (!current.value) return;
current.value.favorite = !current.value.favorite;
persist();
}

// 软删除:移入回收站
function softDelete(): void {
if (!current.value) return;
current.value.deleted = true;
current.value.deletedAt = Date.now();
currentId.value = null;
saveStatus.value = "已保存";
window.clearTimeout(saveTimer);
persist();
}

function restore(id: string): void {
const n = notes.value.find((x) => x.id === id);
if (!n) return;
n.deleted = false;
n.deletedAt = undefined;
persist();
}

// 彻底删除单篇
function purge(id: string): void {
notes.value = notes.value.filter((x) => x.id !== id);
persist();
}

// 清空回收站
function emptyTrash(): void {
notes.value = notes.value.filter((x) => !x.deleted);
persist();
}

function flush(): void {
window.clearTimeout(saveTimer);
persist();
}

export function useNotes() {
return {
notes,
currentId,
searchQuery,
saveStatus,
current,
activeNotes,
trashNotes,
selectNote,
newNote,
updateCurrent,
togglePinned,
toggleFavorite,
softDelete,
restore,
purge,
emptyTrash,
flush,
};
}

逻辑要点:

  • notes:所有笔记(响应式数组,含已删除的)
  • currentId:当前正在编辑的笔记 id
  • current:根据 currentId 计算出的当前笔记(computed),且排除了已删除的
  • activeNotes:未删除、且「置顶优先 + 更新时间倒序」排序后的列表
  • trashNotes:已删除、按删除时间倒序
  • markDirty():每次输入都重置一个 400ms 的定时器,停止输入 400ms 后才真正写入 localStorage(防抖)
  • softDelete() / restore() / purge() / emptyTrash():软删除、恢复、彻底删除、清空
  • flush():立即落盘,用于退出登录 / 关闭窗口时的兜底保存

知识点延申 1:为什么把 useNotes 做成「模块级单例」?

普通的组合式函数每次调用都会创建一套全新的 ref。但我们的笔记数据需要被 NotesView(列表)、NoteEditor(编辑器)、TrashPanel(回收站)三个组件同时访问、共同修改。如果每个组件各自 useNotes() 得到一份独立拷贝,它们之间的数据就不会同步。

解决办法是把 notes、currentId 等 ref 定义在模块顶层(函数体之外),useNotes() 只是把这些共享引用返回出去。这样无论谁调用,拿到的都是同一份数据——这就是「组合式函数 + 单例」构成的一个轻量 store,比引入 Pinia 更简单,适合这种中小规模应用。

知识点延申 2:防抖(debounce)

用户连续输入时,如果每敲一个字就写一次 localStorage,会产生大量无意义的写操作。防抖的思路是:每次输入都取消上一个定时器,重新计时,只有「真的停下来了」才执行保存。400ms 就是「停顿判定阈值」。与它相对的「节流(throttle)」则是每固定间隔最多执行一次,两者用途不同。

知识点延申 3:软删除 vs 硬删除

  • 硬删除:直接从数组删掉,数据没了(旧版本就是这么做的)。
  • 软删除:给数据打一个 deleted = true 标记,数据仍在,只是不显示。

软删除的好处是误删可恢复,符合「回收站」的交互。这就是为什么 Note 接口里多了 deleted 和 deletedAt 两个字段。真实项目的数据库里,这通常对应一个 is_deleted 字段 + deleted_at 时间戳,配合定时任务清理过期的回收站数据。

知识点延申 4:数据迁移(normalize)

给 Note 增加字段后,旧用户 localStorage 里存着的旧数据没有 pinned 等字段。loadNotes 里用 normalize 给缺失字段补默认值(n.pinned ?? false),保证老数据升级后依然可用。?? 是「空值合并运算符」,只有左边是 null 或 undefined 时才取右边,比 || 更精确(不会把 0、false、空串也当成空值)。

6.3 主题切换 src/composables/useTheme.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { ref } from "vue";

export type Theme = "dark" | "light";

const THEME_KEY = "yunnote.theme";

function loadTheme(): Theme {
try {
return localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark";
} catch {
return "dark";
}
}

const theme = ref<Theme>(loadTheme());

function applyTheme(): void {
document.documentElement.setAttribute("data-theme", theme.value);
}

export function useTheme() {
function initTheme(): void {
applyTheme();
}

function toggleTheme(): void {
theme.value = theme.value === "dark" ? "light" : "dark";
try {
localStorage.setItem(THEME_KEY, theme.value);
} catch {
/* 忽略存储失败 */
}
applyTheme();
}

return { theme, toggleTheme, initTheme };
}

逻辑要点:

  • theme 同样是模块级共享的 ref(登录页和主界面都能响应主题变化)。
  • applyTheme() 干的关键动作是给 <html> 元素设置 data-theme 属性,CSS 通过 [data-theme="light"] 选择器覆盖变量,从而实现换肤。
  • initTheme() 在应用启动时调用一次,避免刷新后闪回默认主题。

知识点延申:CSS 变量 + 属性选择器实现主题切换

实现换肤最优雅的方式不是「写两套 CSS」,而是:

  1. 把颜色统一定义成 CSS 变量(见 6.10 的 :root)。
  2. 用 [data-theme="light"] 选择器只覆盖变量的值,其余所有样式的定义(用 var(--xxx) 的地方)都不用改动。

这样切换主题只要改一个属性,浏览器自动把用到这些变量的地方全部更新。document.documentElement.setAttribute("data-theme", ...) 正是切换这个属性的入口。

6.4 Markdown 渲染 src/utils/markdown.ts

1
2
3
4
5
6
7
8
9
10
11
12
import MarkdownIt from "markdown-it";

// html: false 会把原始 HTML 转义,避免渲染时注入脚本
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
});

export function renderMarkdown(src: string): string {
return md.render(src ?? "");
}

知识点延申:markdown-it 与 XSS 安全

把用户输入的内容用 v-html 渲染是有风险的:如果笔记里包含 <script> 或被注入的恶意 HTML,就可能执行任意脚本(XSS)。markdown-it 的 html: false(默认值)会把原始 HTML 当成普通文本转义,而不是透传,从根上堵住了这条注入路径。

三个常见选项:

  • html: false:禁止内联 HTML(安全,推荐默认)
  • linkify: true:自动把 URL 文本变成可点击链接
  • breaks: true:把单个换行 \n 也渲染成 <br>(更接近普通文本编辑习惯)

需要渲染还是交给 markdown-it 的 render(),它返回一串 HTML 字符串,再由组件的 v-html 输出。

6.5 登录组件 src/components/LoginView.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<script setup lang="ts">
import { ref } from "vue";

const emit = defineEmits<{ login: [] }>();

const username = ref("");
const password = ref("");
const error = ref("");
const shaking = ref(false);

function submit() {
if (username.value.trim() === "admin" && password.value === "123456") {
error.value = "";
emit("login");
} else {
error.value = "用户名或密码错误";
shaking.value = true;
}
}
</script>

<template>
<div id="login-view" class="view">
<div class="login-card">
<div class="login-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M8 2v20" />
<path d="M12 8h5M12 12h5M12 16h5" />
</svg>
</div>
<h1 class="login-title">云笔记</h1>
<p class="login-subtitle">登录以管理你的笔记</p>
<form
id="login-form"
:class="{ shake: shaking }"
autocomplete="off"
@submit.prevent="submit"
@animationend="shaking = false"
>
<label class="field">
<span class="field-label">用户名</span>
<input v-model="username" type="text" placeholder="请输入用户名" autocomplete="username" />
</label>
<label class="field">
<span class="field-label">密码</span>
<input v-model="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
</label>
<p class="login-error" role="alert">{{ error }}</p>
<button type="submit" class="btn btn-primary btn-block">登 录</button>
</form>
<p class="login-hint">默认账号 admin / 123456</p>
</div>
</div>
</template>

逻辑要点:

  • defineEmits 声明 login 事件,登录成功时 emit("login") 通知父组件切换到主界面
  • 校验失败设置 error 文案,同时给表单加上 shake 类触发抖动动画
  • @animationend 在抖动动画结束后把 shaking 复位,保证下次错误能再次触发动画

知识点延申:子组件如何「通知」父组件?
Vue 的数据流是「单向」的:父传子用 props(属性),子传父用 emit(事件)。defineEmits<{ login: [] }>() 用 TS 泛型声明了这个组件会发出名为 login、无参数的事件;父组件里 @login="..." 监听它。这样登录组件本身不需要知道「登录之后干嘛」,只需发出「我登录成功了」这件事,由父组件决定跳转逻辑——职责清晰、可复用。

6.6 主界面 src/components/NotesView.vue

这个组件负责整体布局:左侧侧边栏(新建、筛选、搜索、列表、用户信息),右侧根据当前筛选状态,路由到「编辑器」「空状态」或「回收站」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { formatTime, useNotes } from "../composables/useNotes";
import type { Note } from "../composables/useNotes";
import { useTheme } from "../composables/useTheme";
import NoteEditor from "./NoteEditor.vue";
import TrashPanel from "./TrashPanel.vue";

const emit = defineEmits<{ logout: [] }>();

const { currentId, searchQuery, current, activeNotes, trashNotes, selectNote, newNote, flush } = useNotes();
const { theme, toggleTheme } = useTheme();

const filter = ref<"all" | "favorite" | "trash">("all");
const editorRef = ref<InstanceType<typeof NoteEditor> | null>(null);

function matches(n: Note): boolean {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return true;
return `${n.title} ${n.content}`.toLowerCase().includes(q);
}

const shownActive = computed(() => activeNotes.value.filter(matches));
const shownFavorite = computed(() => shownActive.value.filter((n) => n.favorite));
const shownTrash = computed(() => trashNotes.value.filter(matches));

const displayed = computed(() => (filter.value === "favorite" ? shownFavorite.value : shownActive.value));

function snippet(n: Note): string {
return n.content.replace(/\s+/g, " ").trim() || "空白笔记";
}

function onNewNote(): void {
newNote();
filter.value = "all";
nextTick(() => editorRef.value?.focusTitle());
}

function onLogout(): void {
flush();
emit("logout");
}

function onBeforeUnload(): void {
flush();
}

onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onBeforeUnmount(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>

<template>
<div id="app-view" class="view">
<aside class="sidebar">
<div class="sidebar-header">
<div class="brand">
<span class="brand-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M8 2v20" />
<path d="M12 8h5M12 12h5M12 16h5" />
</svg>
</span>
<span class="brand-name">云笔记</span>
</div>
<button class="btn btn-primary btn-block" @click="onNewNote">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
<span>新建笔记</span>
</button>
</div>

<div class="filters">
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button>
<button :class="{ active: filter === 'favorite' }" @click="filter = 'favorite'">收藏</button>
<button :class="{ active: filter === 'trash' }" @click="filter = 'trash'">回收站</button>
</div>

<div class="search-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input v-model="searchQuery" type="text" :placeholder="filter === 'trash' ? '搜索回收站...' : '搜索笔记...'" />
</div>

<ul class="note-list">
<li v-if="filter === 'trash'" class="note-empty">
{{ trashNotes.length ? `回收站中有 ${trashNotes.length} 篇笔记` : "回收站是空的" }}
</li>
<template v-else>
<li v-if="displayed.length === 0" class="note-empty">
{{ searchQuery.trim() ? "没有匹配的笔记" : filter === 'favorite' ? "还没有收藏的笔记" : "还没有笔记" }}
</li>
<li
v-for="n in displayed"
:key="n.id"
class="note-item"
:class="{ active: n.id === currentId }"
@click="selectNote(n.id)"
>
<div class="note-item-title">
<span v-if="n.pinned" class="badge pin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17v5" />
<path d="M9 4h6l1 7 2 2H6l2-2 1-7z" />
</svg>
</span>
<span v-if="n.favorite" class="badge star">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</span>
<span class="title-text">{{ n.title.trim() || "无标题" }}</span>
</div>
<div class="note-item-snippet">{{ snippet(n) }}</div>
<div class="note-item-time">{{ formatTime(n.updatedAt) }}</div>
</li>
</template>
</ul>

<div class="sidebar-footer">
<div class="user-chip">
<div class="avatar">A</div>
<div class="user-meta">
<span class="user-name">admin</span>
<span class="user-role">管理员</span>
</div>
</div>
<div class="footer-actions">
<button class="btn btn-ghost" :title="theme === 'dark' ? '切换到浅色' : '切换到深色'" @click="toggleTheme">
<svg v-if="theme === 'dark'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<button class="btn btn-ghost" title="退出登录" @click="onLogout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5M21 12H9" />
</svg>
</button>
</div>
</div>
</aside>

<main class="editor">
<TrashPanel v-if="filter === 'trash'" />
<div v-else-if="!current" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M8 2v20" />
<path d="M12 8h5M12 12h5M12 16h5" />
</svg>
</div>
<h2>{{ filter === 'favorite' ? '选择一篇收藏的笔记' : '选择或创建一篇笔记' }}</h2>
<p>点击左上角「新建笔记」开始记录</p>
</div>
<NoteEditor v-else ref="editorRef" />
</main>
</div>
</template>

逻辑要点:

  • 顶部三个筛选按钮切换 filter(all / favorite / trash),右侧内容区随 filter 变化而切换
  • matches() 做搜索过滤,shownActive / shownFavorite / shownTrash 是三个派生列表
  • displayed 是「全部」或「收藏」两种情况下实际显示的列表
  • 列表项标题里,v-if="n.pinned" / v-if="n.favorite" 渲染置顶/收藏小图标
  • 主题按钮用 v-if / v-else 在太阳/月亮两个图标间切换
  • 新建笔记后先切回 all 筛选,再 nextTick 聚焦编辑器标题

知识点延申 1:v-for 的 :key

v-for 必须有稳定的 :key,Vue 用 key 来识别每个列表项,做增删时的最小化 DOM 更新(diff)。用 n.id 这种唯一且不变的值最合适;不要用数组下标当 key,那会导致数据顺序变化时复用错误的 DOM。

知识点延申 2:computed 派生状态,不要让多个 state 来回同步

shownActive、shownFavorite、displayed 都不是单独的 ref,而是从 notes + searchQuery + filter 计算出来的。这符合 Vue 的核心思想:单一数据源,其余全是派生。你只需要维护原始数据,UI 列表用 computed 自动算出来,永远不会出现「忘了手动同步」导致的 bug。

知识点延申 3:子组件暴露方法(defineExpose + ref)

父组件想让子组件「做一件事」(比如聚焦标题输入框),除了事件和 props,还可以:子组件用 defineExpose({ focusTitle }) 把方法暴露出去,父组件用 ref="editorRef" 拿到组件实例后调用 editorRef.value?.focusTitle()。这比从父组件直接操作子组件内部 DOM 更干净。

6.7 编辑器 src/components/NoteEditor.vue

编辑器独立成一个组件,负责:标题输入、置顶/收藏/删除操作、保存状态展示,以及「编辑 / 预览 / 分屏」三模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
<script setup lang="ts">
import { computed, nextTick, ref } from "vue";
import { formatTime, useNotes } from "../composables/useNotes";
import { renderMarkdown } from "../utils/markdown";

const { current, saveStatus, updateCurrent, togglePinned, toggleFavorite, softDelete } = useNotes();

const mode = ref<"edit" | "preview" | "split">("edit");
const titleInput = ref<HTMLInputElement | null>(null);

const rendered = computed(() => (current.value ? renderMarkdown(current.value.content) : ""));
const wordCount = computed(() => (current.value ? current.value.content.length : 0));
const dateText = computed(() =>
current.value
? `创建于 ${formatTime(current.value.createdAt)} · 更新于 ${formatTime(current.value.updatedAt)}`
: ""
);

function onTitleInput(e: Event): void {
updateCurrent({ title: (e.target as HTMLInputElement).value });
}

function onContentInput(e: Event): void {
updateCurrent({ content: (e.target as HTMLTextAreaElement).value });
}

function onDelete(): void {
if (!current.value) return;
const ok = window.confirm(`将「${current.value.title.trim() || "无标题"}」移入回收站?`);
if (ok) softDelete();
}

// 预览区里的链接点击时不跳转,避免离开应用
function onPreviewClick(e: MouseEvent): void {
const a = (e.target as HTMLElement).closest("a");
if (a) e.preventDefault();
}

function focusTitle(): void {
nextTick(() => titleInput.value?.focus());
}

defineExpose({ focusTitle });
</script>

<template>
<div class="editor-pane">
<div class="editor-toolbar">
<input
ref="titleInput"
class="note-title"
type="text"
placeholder="无标题"
:value="current?.title"
@input="onTitleInput"
/>
<div class="toolbar-actions">
<button
class="btn btn-icon"
:class="{ active: current?.pinned }"
:title="current?.pinned ? '取消置顶' : '置顶'"
@click="togglePinned"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17v5" />
<path d="M9 4h6l1 7 2 2H6l2-2 1-7z" />
</svg>
</button>
<button
class="btn btn-icon"
:class="{ active: current?.favorite }"
:title="current?.favorite ? '取消收藏' : '收藏'"
@click="toggleFavorite"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<span class="save-status">{{ saveStatus }}</span>
<button class="btn btn-danger" @click="onDelete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
</svg>
<span>删除</span>
</button>
</div>
</div>

<div class="editor-meta">
<span>{{ dateText }}</span>
<span class="word-count">{{ wordCount }} 字</span>
</div>

<div class="mode-switch">
<button :class="{ active: mode === 'edit' }" @click="mode = 'edit'">编辑</button>
<button :class="{ active: mode === 'preview' }" @click="mode = 'preview'">预览</button>
<button :class="{ active: mode === 'split' }" @click="mode = 'split'">分屏</button>
</div>

<div class="editor-body" :class="mode">
<textarea
v-show="mode !== 'preview'"
class="note-content"
placeholder="支持 Markdown:标题、列表、代码块、链接等"
spellcheck="false"
:value="current?.content"
@input="onContentInput"
></textarea>
<div v-show="mode !== 'edit'" class="markdown-body" v-html="rendered" @click="onPreviewClick"></div>
</div>
</div>
</template>

逻辑要点:

  • mode 控制三种模式,通过 v-show 决定显示源码编辑区还是预览区,通过 :class="mode" 让容器在纵向堆叠和左右分屏间切换
  • rendered 用 markdown-it 把正文实时渲染成 HTML;v-html 输出
  • wordCount 统计正文字符数(中文场景按字符计数即可)
  • 置顶/收藏按钮的 :class="{ active: ... }" 控制高亮,图标内联 SVG
  • 删除改为「移入回收站」的确认提示
  • 预览区点击链接时 preventDefault() 阻止跳转,避免 WebView 离开应用

知识点延申 1:v-show vs v-if 的选择

  • v-if:条件为假时根本不渲染这个 DOM 节点,切换有「创建/销毁」成本,适合频繁切换较少的场景。
  • v-show:始终渲染,只是用 display: none 隐藏,切换成本极低,适合频繁切换。

编辑器里在「编辑/预览/分屏」之间切换很频繁,且 textarea 需要保持输入焦点和滚动位置,所以用 v-show 更合适(如果用 v-if,每次切回编辑模式 textarea 会被重建,光标位置可能丢失)。

知识点延申 2:v-html 的注意点

v-html 会把字符串当作 HTML 渲染,不会做任何转义。所以绝不能把未经处理的用户输入直接塞进 v-html。这里的 rendered 虽然也来自用户输入(笔记正文),但经过了 html: false 的 markdown-it 处理(原始 HTML 被转义),属于安全输出。

6.8 回收站 src/components/TrashPanel.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<script setup lang="ts">
import { formatTime, useNotes } from "../composables/useNotes";
import type { Note } from "../composables/useNotes";

const { trashNotes, restore, purge, emptyTrash } = useNotes();

function onRestore(n: Note): void {
restore(n.id);
}

function onPurge(n: Note): void {
const ok = window.confirm(`彻底删除「${n.title.trim() || "无标题"}」?此操作不可恢复。`);
if (ok) purge(n.id);
}

function onEmpty(): void {
const ok = window.confirm("确定清空回收站吗?此操作不可恢复。");
if (ok) emptyTrash();
}
</script>

<template>
<div class="trash-panel">
<div class="trash-header">
<h2>回收站</h2>
<button class="btn btn-danger btn-sm" :disabled="trashNotes.length === 0" @click="onEmpty">清空回收站</button>
</div>

<div v-if="trashNotes.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
</svg>
</div>
<h2>回收站是空的</h2>
<p>删除的笔记会暂存在这里,可恢复或彻底清除</p>
</div>

<ul v-else class="trash-list">
<li v-for="n in trashNotes" :key="n.id" class="trash-item">
<div class="info">
<div class="title">{{ n.title.trim() || "无标题" }}</div>
<div class="time">删除于 {{ n.deletedAt ? formatTime(n.deletedAt) : "未知时间" }}</div>
</div>
<button class="btn btn-ghost btn-sm" @click="onRestore(n)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
</svg>
<span>恢复</span>
</button>
<button class="btn btn-danger btn-sm" @click="onPurge(n)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
</svg>
<span>彻底删除</span>
</button>
</li>
</ul>
</div>
</template>

逻辑要点:

  • trashNotes 直接从 useNotes() 拿到(单例共享,和列表、编辑器是同一份数据)
  • 「恢复」「彻底删除」「清空」都调用了 useNotes 里对应的数据操作;「彻底删除」和「清空」是不可逆操作,加二次确认
  • :disabled 在回收站为空时禁用「清空」按钮

6.9 根组件 src/App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts">
import { ref } from "vue";
import LoginView from "./components/LoginView.vue";
import NotesView from "./components/NotesView.vue";
import { useTheme } from "./composables/useTheme";

const { initTheme } = useTheme();
initTheme();

const authed = ref(false);
</script>

<template>
<Transition name="fade" mode="out-in">
<LoginView v-if="!authed" key="login" @login="authed = true" />
<NotesView v-else key="notes" @logout="authed = false" />
</Transition>
</template>

逻辑要点:

  • 用一个 authed 状态控制显示登录页还是主界面
  • @login / @logout 事件在父子组件间传递
  • <Transition> 提供淡入淡出切换动画,mode="out-in" 表示先旧元素退场、新元素再进场
  • initTheme() 在应用启动时立即套用已保存的主题(要在渲染前执行,避免闪烁)

知识点延申:key 在 Transition 里的作用

<LoginView key="login"> 和 <NotesView key="notes"> 这两个 key 告诉 Vue:这是两个不同的元素,切换时应当执行完整的「离场→进场」动画。如果没有 key,当 v-if/v-else 切换的两个组件结构相似时,Vue 可能复用同一个 DOM 节点,导致动画不生效。

6.10 全局样式 src/styles.css

样式较长,这里完整给出(分为:主题变量、基础、按钮、登录、主布局、编辑器、Markdown、回收站、滚动条几大块)。核心思路是用 CSS 变量统一管理颜色,再通过 [data-theme="light"] 覆盖变量值实现换肤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
:root {
--bg-0: #0b1020;
--bg-1: #151b36;
--accent-1: #6366f1;
--accent-2: #a855f7;
--accent-3: #22d3ee;
--text: #eceef8;
--text-dim: #98a1bf;
--muted: #6a7395;
--placeholder: #4a5372;
--star: #fbbf24;
--surface: rgba(255, 255, 255, 0.06);
--surface-2: rgba(255, 255, 255, 0.1);
--border: rgba(255, 255, 255, 0.1);
--hover: rgba(255, 255, 255, 0.06);
--input-bg: rgba(255, 255, 255, 0.05);
--input-bg-focus: rgba(255, 255, 255, 0.08);
--ring: rgba(99, 102, 241, 0.22);
--sidebar-bg: rgba(11, 16, 32, 0.62);
--accent-soft: rgba(99, 102, 241, 0.16);
--accent-soft-border: rgba(99, 102, 241, 0.4);
--danger: #f43f5e;
--danger-text: #fecdd3;
--danger-bg: rgba(244, 63, 94, 0.14);
--danger-border: rgba(244, 63, 94, 0.28);
--danger-bg-hover: rgba(244, 63, 94, 0.24);
--code-bg: rgba(255, 255, 255, 0.08);
--scrollbar: rgba(255, 255, 255, 0.14);
--scrollbar-hover: rgba(255, 255, 255, 0.24);
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
--font: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
}

[data-theme="light"] {
--bg-0: #eef1f8;
--bg-1: #f7f9fc;
--text: #1f2430;
--text-dim: #5b6478;
--muted: #8a92a6;
--placeholder: #b3bacc;
--surface: rgba(255, 255, 255, 0.7);
--surface-2: rgba(255, 255, 255, 0.9);
--border: rgba(15, 23, 42, 0.1);
--hover: rgba(15, 23, 42, 0.05);
--input-bg: rgba(15, 23, 42, 0.04);
--input-bg-focus: rgba(255, 255, 255, 0.9);
--ring: rgba(99, 102, 241, 0.25);
--sidebar-bg: rgba(255, 255, 255, 0.75);
--accent-soft: rgba(99, 102, 241, 0.12);
--accent-soft-border: rgba(99, 102, 241, 0.35);
--danger: #e11d48;
--danger-text: #be123c;
--danger-bg: rgba(244, 63, 94, 0.1);
--danger-border: rgba(244, 63, 94, 0.3);
--danger-bg-hover: rgba(244, 63, 94, 0.18);
--code-bg: rgba(15, 23, 42, 0.06);
--scrollbar: rgba(15, 23, 42, 0.16);
--scrollbar-hover: rgba(15, 23, 42, 0.28);
--shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
}

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

html,
body {
height: 100%;
}

body {
font-family: var(--font);
color: var(--text);
background:
radial-gradient(1200px 700px at 85% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
radial-gradient(1000px 620px at -10% 105%, rgba(168, 85, 247, 0.28), transparent 55%),
linear-gradient(160deg, var(--bg-0), var(--bg-1));
min-height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}

[data-theme="light"] body {
background:
radial-gradient(1200px 700px at 85% -10%, rgba(99, 102, 241, 0.16), transparent 60%),
radial-gradient(1000px 620px at -10% 105%, rgba(168, 85, 247, 0.12), transparent 55%),
linear-gradient(160deg, var(--bg-0), var(--bg-1));
}

button,
input,
textarea {
font-family: inherit;
}

.view {
position: fixed;
inset: 0;
}

.hidden {
display: none !important;
}

.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

/* ============ 通用按钮 ============ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease, opacity 0.2s ease, color 0.2s ease, border 0.2s ease;
}
.btn:active {
transform: scale(0.97);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.btn svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}

.btn-primary {
color: #fff;
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
box-shadow: 0 10px 24px rgba(99, 102, 241, 0.4);
}
.btn-primary:hover {
box-shadow: 0 14px 30px rgba(99, 102, 241, 0.55);
filter: brightness(1.05);
}

.btn-block {
width: 100%;
padding: 12px 16px;
}

.btn-danger {
color: var(--danger-text);
background: var(--danger-bg);
border: 1px solid var(--danger-border);
}
.btn-danger:hover {
background: var(--danger-bg-hover);
}

.btn-ghost {
color: var(--text-dim);
background: transparent;
padding: 8px;
border-radius: 8px;
}
.btn-ghost:hover {
color: var(--text);
background: var(--hover);
}

.btn-icon {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid transparent;
border-radius: 9px;
color: var(--text-dim);
background: transparent;
}
.btn-icon:hover {
color: var(--text);
background: var(--hover);
}
.btn-icon.active {
color: var(--star);
background: rgba(251, 191, 36, 0.14);
border-color: rgba(251, 191, 36, 0.3);
}
.btn-icon.active svg {
fill: currentColor;
}

.btn-sm {
padding: 7px 12px;
font-size: 12px;
gap: 6px;
}
.btn-sm svg {
width: 14px;
height: 14px;
}

/* ============ 登录视图 ============ */
#login-view {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}

.login-card {
width: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 24px;
padding: 40px 36px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--shadow);
text-align: center;
animation: rise 0.5s ease;
}

@keyframes rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.login-logo {
width: 64px;
height: 64px;
margin: 0 auto 18px;
display: grid;
place-items: center;
border-radius: 18px;
color: #fff;
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
box-shadow: 0 12px 28px rgba(99, 102, 241, 0.45);
}
.login-logo svg {
width: 34px;
height: 34px;
}

.login-title {
font-size: 26px;
font-weight: 700;
letter-spacing: 1px;
}

.login-subtitle {
margin: 8px 0 26px;
color: var(--text-dim);
font-size: 14px;
}

.field {
display: block;
text-align: left;
margin-bottom: 16px;
}

.field-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-dim);
}

.field input {
width: 100%;
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
font-size: 14px;
outline: none;
transition: border 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.field input::placeholder {
color: var(--muted);
}
.field input:focus {
border-color: var(--accent-1);
background: var(--input-bg-focus);
box-shadow: 0 0 0 3px var(--ring);
}

.login-error {
min-height: 20px;
margin: -4px 0 10px;
color: var(--danger);
font-size: 13px;
text-align: left;
}

.login-hint {
margin-top: 18px;
font-size: 12px;
color: var(--muted);
}

.shake {
animation: shake 0.4s ease;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-8px);
}
40%,
80% {
transform: translateX(8px);
}
}

/* ============ 主应用布局 ============ */
#app-view {
display: flex;
}

.sidebar {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}

.sidebar-header {
display: flex;
flex-direction: column;
gap: 16px;
}

.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 2px;
}
.brand-logo {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
}
.brand-logo svg {
width: 20px;
height: 20px;
}
.brand-name {
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
}

.filters {
display: flex;
gap: 4px;
padding: 4px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.filters button {
flex: 1;
padding: 7px 0;
border: none;
background: transparent;
color: var(--text-dim);
font-size: 13px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.filters button:hover {
color: var(--text);
}
.filters button.active {
color: #fff;
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
}

.search-box {
position: relative;
}
.search-box svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-dim);
pointer-events: none;
}
.search-box input {
width: 100%;
padding: 10px 12px 10px 36px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
font-size: 14px;
outline: none;
transition: border 0.2s ease, box-shadow 0.2s ease;
}
.search-box input::placeholder {
color: var(--muted);
}
.search-box input:focus {
border-color: var(--accent-1);
box-shadow: 0 0 0 3px var(--ring);
}

.note-list {
flex: 1;
overflow-y: auto;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 2px;
}

.note-empty {
text-align: center;
color: var(--text-dim);
font-size: 13px;
padding: 32px 0;
}

.note-item {
padding: 12px 14px;
border-radius: 12px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s ease, border 0.15s ease;
}
.note-item:hover {
background: var(--hover);
}
.note-item.active {
background: var(--accent-soft);
border-color: var(--accent-soft-border);
}

.note-item-title {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
font-weight: 600;
}
.note-item-title .title-text {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
display: inline-flex;
flex-shrink: 0;
}
.badge svg {
width: 13px;
height: 13px;
}
.badge.pin {
color: var(--accent-2);
}
.badge.star {
color: var(--star);
}
.badge.star svg {
fill: currentColor;
}

.note-item-snippet {
margin-top: 4px;
font-size: 12px;
color: var(--text-dim);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

.note-item-time {
margin-top: 8px;
font-size: 11px;
color: var(--muted);
}

.sidebar-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 14px;
border-top: 1px solid var(--border);
}

.user-chip {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, var(--accent-3), var(--accent-2));
}
.user-meta {
display: flex;
flex-direction: column;
}
.user-name {
font-size: 13px;
font-weight: 600;
}
.user-role {
font-size: 11px;
color: var(--text-dim);
}

.footer-actions {
display: flex;
align-items: center;
gap: 4px;
}

/* ============ 编辑器 ============ */
.editor {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
padding: 24px 32px;
min-width: 0;
}

.empty-state {
margin: auto;
text-align: center;
color: var(--text-dim);
}
.empty-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
display: grid;
place-items: center;
border-radius: 22px;
color: var(--text-dim);
background: var(--surface);
border: 1px solid var(--border);
}
.empty-icon svg {
width: 40px;
height: 40px;
}
.empty-state h2 {
color: var(--text);
font-size: 18px;
margin-bottom: 8px;
}
.empty-state p {
font-size: 13px;
}

.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}

.editor-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}

.note-title {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 28px;
font-weight: 700;
padding: 4px 0;
}
.note-title::placeholder {
color: var(--placeholder);
}

.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}

.save-status {
font-size: 12px;
color: var(--text-dim);
transition: color 0.2s ease;
}

.editor-meta {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
font-size: 12px;
color: var(--muted);
}
.word-count {
white-space: nowrap;
}

.mode-switch {
display: flex;
gap: 4px;
align-self: flex-start;
padding: 3px;
margin-bottom: 12px;
border-radius: 9px;
background: var(--surface);
border: 1px solid var(--border);
}
.mode-switch button {
border: none;
background: transparent;
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
padding: 6px 14px;
border-radius: 7px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.mode-switch button:hover {
color: var(--text);
}
.mode-switch button.active {
color: #fff;
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
}

.editor-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editor-body.split {
flex-direction: row;
gap: 20px;
}

.note-content {
flex: 1;
min-height: 0;
resize: none;
border: none;
outline: none;
background: transparent;
color: var(--text);
font-size: 16px;
line-height: 1.7;
padding: 8px 0 32px;
}
.note-content::placeholder {
color: var(--placeholder);
}

/* ============ Markdown 预览 ============ */
.markdown-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 0 32px;
color: var(--text);
font-size: 16px;
line-height: 1.7;
word-break: break-word;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 1.2em 0 0.5em;
font-weight: 700;
line-height: 1.3;
}
.markdown-body h1 {
font-size: 1.7em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.4em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.3em;
}
.markdown-body h3 {
font-size: 1.2em;
}
.markdown-body p {
margin: 0.6em 0;
}
.markdown-body ul,
.markdown-body ol {
margin: 0.6em 0;
padding-left: 1.6em;
}
.markdown-body li {
margin: 0.25em 0;
}
.markdown-body blockquote {
margin: 0.8em 0;
padding: 0.4em 1em;
color: var(--text-dim);
border-left: 3px solid var(--accent-1);
background: var(--surface);
border-radius: 0 8px 8px 0;
}
.markdown-body code {
font-family: "Consolas", "SFMono-Regular", "Menlo", monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
border-radius: 5px;
background: var(--code-bg);
}
.markdown-body pre {
margin: 0.8em 0;
padding: 14px 16px;
overflow-x: auto;
border-radius: 10px;
background: var(--code-bg);
border: 1px solid var(--border);
}
.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 0.88em;
line-height: 1.6;
}
.markdown-body a {
color: var(--accent-3);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body hr {
margin: 1.2em 0;
border: none;
border-top: 1px solid var(--border);
}
.markdown-body img {
max-width: 100%;
border-radius: 8px;
}
.markdown-body table {
border-collapse: collapse;
margin: 0.8em 0;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 6px 12px;
}

/* ============ 回收站 ============ */
.trash-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

.trash-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.trash-header h2 {
font-size: 20px;
color: var(--text);
}

.trash-list {
flex: 1;
overflow-y: auto;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 16px;
}

.trash-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
}
.trash-item .info {
flex: 1;
min-width: 0;
}
.trash-item .title {
font-size: 14px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trash-item .time {
margin-top: 4px;
font-size: 11px;
color: var(--muted);
}

/* ============ 滚动条 ============ */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar);
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
}

样式要点:

  • :root 定义深色主题变量,[data-theme="light"] 只覆盖变量的值
  • 背景用「两团径向渐变 + 一个线性渐变」叠加出渐变氛围;浅色主题单独覆盖了 body 背景
  • backdrop-filter: blur() + 半透明背景实现玻璃拟态
  • 图标全部使用内联 SVG,无需额外图标库
  • .markdown-body 定义了完整的 Markdown 排版(标题、列表、代码、引用、表格等)
  • .editor-body.split 通过 flex-direction: row 把编辑区和预览区从纵向堆叠变为左右并排(分屏核心)

知识点延申:玻璃拟态(Glassmorphism)三要素

本项目视觉风格的「高级感」主要来自背景的叠加渐变 + backdrop-filter: blur()(背景模糊)+ 半透明且带边框的卡片。三件事缺一不可:如果只有半透明而没有 blur,文字会看不清;有了 blur,透出的底层渐变光晕让卡片有一种「磨砂玻璃」的质感。


7. Rust 后端说明

src-tauri/ 由脚手架生成,本项目无需改动逻辑,只需微调窗口配置。

7.1 src-tauri/tauri.conf.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "YunNote",
"version": "0.1.0",
"identifier": "com.yunnote.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "云笔记",
"width": 1080,
"height": 720,
"minWidth": 860,
"minHeight": 620,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

关键字段:

  • productName:打包后安装包/应用显示名(可改成中文「云笔记」)
  • devUrl:开发时前端地址,需与 vite 端口一致
  • frontendDist:打包时前端产物目录
  • windows:窗口标题与尺寸

7.2 其余文件(默认生成、保持不变)

  • Cargo.toml:定义 Rust 包名([package] name 决定 .exe 名字,[lib] name 决定库 crate 名 tauri_app_lib)
  • src/main.rs:二进制入口,调用 tauri_app_lib::run()
  • src/lib.rs:库入口,Tauri 构建器 + 注册命令
  • capabilities/default.json:主窗口权限
  • build.rs:构建脚本

知识点延申:为什么分 main.rs 和 lib.rs 两个文件?
Tauri 项目里,业务逻辑放 lib.rs(库 target),main.rs(二进制 target)只做一件事:调用库里的 run()。这样做的原因是 Tauri 2 支持移动端,移动端入口(mobile_entry_point)也需要复用同一套 Builder 代码,而 iOS/Android 上并没有 main.rs。把 Builder 抽到 lib.rs,桌面和移动端就能共用同一份逻辑。

本项目纯前端实现,Rust 端的示例命令 greet 未被 UI 调用,可保留也可按需删除。如果你想让前端调用 Rust 代码,就在 lib.rs 里写 #[tauri::command] 函数并注册到 invoke_handler,前端用 @tauri-apps/api 的 invoke 调用。


8. 运行与调试

8.1 开发模式(热更新)

1
npm run tauri dev

首次运行会下载并编译 Rust 依赖(约几百个 crate),耗时较长,请耐心等待。之后会弹出应用窗口,修改前端代码会自动热更新。

8.2 只预览前端(浏览器)

如果只想快速看界面(不启动 Rust / 窗口):

1
npm run dev

浏览器打开 http://localhost:1420 即可。

8.3 手动验证清单

  1. 用错误账号登录 → 出现「用户名或密码错误」并抖动
  2. 用 admin / 123456 登录 → 进入主界面
  3. 点「新建笔记」→ 光标自动聚焦标题
  4. 输入标题/内容 → 右上角状态从「保存中…」变「已保存」;左侧列表实时更新摘要
  5. 搜索关键词 → 列表实时过滤
  6. 点击「编辑 / 预览 / 分屏」切换模式 → 预览实时渲染、分屏左右并排
  7. 点「置顶」「收藏」图标 → 列表重新排序 / 出现小图标,切到「收藏」标签只显示收藏项
  8. 点「删除」→ 提示「移入回收站」,笔记从列表消失
  9. 切到「回收站」→ 可见删除的笔记,「恢复」回到列表,「彻底删除」/「清空」带确认且不可逆
  10. 点侧边栏底部太阳/月亮 → 深浅色主题切换,转换平滑
  11. 关闭窗口重开 → 笔记、主题偏好仍在(localStorage 持久化生效,登录状态需重新登录)

9. 打包发布

9.1 打包命令

1
npm run tauri build

产物位置:

  • src-tauri/target/release/ 下生成可执行文件(名字由 Cargo.toml 的 [package] name 决定,默认 tauri-app.exe)
  • src-tauri/target/release/bundle/ 下生成安装包(.msi 和 NSIS 的 .exe)

9.2 自定义 exe / 安装包名字

想改什么 改哪里
可执行文件 .exe 名 Cargo.toml 的 [package] name(或 tauri.conf.json 顶层加 mainBinaryName)
安装包名 / 应用显示名 tauri.conf.json 的 productName

更多细节见官方配置文档:https://v2.tauri.org.cn/reference/config/


10. 常见问题

Q1:tauri dev 报错「error: linker link.exe not found」或编译 MSVC 相关错误
→ 未安装 C++ Build Tools。安装 Visual Studio Build Tools,勾选「使用 C++ 的桌面开发」。

Q2:开发时改了前端没反应?
→ 确认窗口加载的是 http://localhost:1420,且 vite.config.ts 端口与 tauri.conf.json 的 devUrl 一致。

Q3:登录后重启应用又回到登录页?
→ 这是预期行为:我们只持久化了笔记和主题,没有持久化「登录状态」。如需记住登录,可用 localStorage 存一个 token,但这会失去「登录」的演示意义。生产环境应在后端做真正的鉴权。

Q4:笔记存在哪?
→ 浏览器 webview 的 localStorage,笔记 key 为 yunnote.notes.v1,主题 key 为 yunnote.theme。Tauri 的 WebView2 数据目录独立于系统浏览器,卸载应用会一并清除。若要存成文件,可改用 Tauri 的 fs 插件或 Rust 命令。

Q5:打包后窗口白屏?
→ 确认 frontendDist 指向 ../dist,且打包前 npm run build 成功生成了 dist/。

Q6:npm run build 报 markdown-it 的类型错误?
→ 确认 tsconfig.json 已开启 esModuleInterop: true,且 @types/markdown-it 已安装。

Q7:浅色主题下有些地方颜色不对?
→ 检查该处颜色是否写死了具体值而没有用 CSS 变量。所有需要随主题变化的颜色都应定义为变量,浅色主题只覆盖 [data-theme="light"] 里的变量值。


结语

至此你已经完成了一个功能较完整的 Tauri 2 + Vue 3 桌面笔记应用。回顾关键点:

  1. Tauri 把现代化的 Web 前端(Vue)和轻量的 Rust 后端组合成原生桌面应用
  2. Vue 3 组合式 API 让状态与逻辑清晰、可复用;useNotes 是一个典型「组合式函数 + 单例」的例子
  3. computed 自动派生了「列表过滤、当前笔记、回收站列表」等 UI 状态,避免手动同步
  4. 防抖保存 + beforeunload 兜底,保证了数据不丢失
  5. CSS 变量 + data-theme 属性实现优雅的深浅主题切换
  6. markdown-it 的 html: false 保证了 Markdown 渲染的安全

在此基础上,你还可以继续扩展:

  • 接真实后端 / 数据库:把 localStorage 换成 API 或 SQLite(Tauri 有官方 SQL 插件)
  • 文件持久化:用 Tauri fs 插件把笔记存成 .md 文件,实现真正的本地文件笔记
  • 多用户:后端做用户系统与鉴权
  • 更丰富的编辑器:代码高亮(highlight.js)、数学公式(KaTeX)、待办勾选等
  • 国际化:接入 vue-i18n 支持多语言

项目源码