Initial commit: DeskFloat 桌面透明浮动工具栏

Tauri 2 + React:任务列表、快捷方式、番茄钟、系统托盘、钉子穿透模式等。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wjl
2026-05-26 20:01:43 +08:00
commit 8e9d108995
103 changed files with 12278 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# 项目级 cargo 镜像配置:解决 crates.io 在国内网络下载超时的问题
# 仅作用于本项目,不影响全局
[source.crates-io]
replace-with = "rsproxy-sparse"
[source.rsproxy]
registry = "https://rsproxy.cn/crates.io-index"
[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"
[registries.rsproxy]
index = "https://rsproxy.cn/crates.io-index"
[net]
git-fetch-with-cli = true
+22
View File
@@ -0,0 +1,22 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
.vscode/*
!.vscode/extensions.json
# Tauri
src-tauri/target
src-tauri/gen
# TypeScript / Vite 构建缓存
*.tsbuildinfo
vite.config.js
vite.config.d.ts
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
+90
View File
@@ -0,0 +1,90 @@
# DeskFloat 开发进度
最后更新:2026-05-25
## 总体状态:MVP 完成 ✅
7 个里程碑全部完成,可运行/可构建,待真实环境联调与样式打磨。
---
## 里程碑详情
### 1. 项目骨架 ✅
- [x] `package.json` / `tsconfig.json` / `vite.config.ts`
- [x] `src-tauri/Cargo.toml` / `tauri.conf.json` / `build.rs`
- [x] `src-tauri/capabilities/default.json`(窗口/fs/shell/notification/global-shortcut 等权限)
- [x] 应用图标:用 `mmx image generate` 生成紫蓝玻璃风格 256² PNG,再用 `npx tauri icon` 转出 ICO/ICNS/各分辨率 PNG
- [x] 透明无边框窗口配置(decorations: false, transparent: true, alwaysOnTop, skipTaskbar
- [x] `data-tauri-drag-region` 拖拽热区
### 2. JSON 持久化 + Zustand ✅
- [x] `storage.rs``AppState` 结构 + `data_path/ensure_data_file/read_state/write_state`
- [x] 启动时若 `data.json` 不存在则写入默认值(含 2 个示例快捷方式)
- [x] `commands.rs``load_state` / `save_state` 双向通道
- [x] `useAppStore.ts`Zustand 全局 store + 300ms 防抖落盘
- [x] 启动 hydrate → store 落地 → `ready` 标志门控渲染
### 3. 任务列表 ✅
- [x] 工具栏触发按钮 `TasksTrigger`(含未完成数量徽标)
- [x] 展开面板 `TasksPanel`:输入框(回车新增)+ 列表
- [x] 单条任务:勾选、双击编辑、悬停显示删除/编辑按钮
- [x] @dnd-kit 拖拽排序
- [x] "清除已完成"批量动作
### 4. 自定义快捷按钮 ✅
- [x] `ShortcutsBar`:工具栏内横向滚动按钮区
- [x] `ShortcutsPanel`:管理面板(列表 + 增删改)
- [x] `ShortcutEditor`:编辑表单(名称 / 类型 / 目标 / 参数 / 工作目录 / 图标网格)
- [x] 4 种执行器:
- `url` → 前端 `@tauri-apps/plugin-opener`
- `app` / `cmd` / `powershell` → Rust `runner.rs::execute`(带 `CREATE_NO_WINDOW` 防控制台闪烁)
- [x] 27 个 lucide 图标白名单可选(避免引入全量包)
### 5. 番茄钟 ✅
- [x] `pomodoroStore.ts`:阶段(work/break+ 剩余秒 + tick 逻辑
- [x] `PomodoroIndicator`:工具栏指示器(脉冲点 + MM:SS)
- [x] `PomodoroPanel`:开始/暂停/重置 + 进度条 + 可调工作/休息分钟
- [x] 阶段切换自动调用 `notify_user` 发系统通知
- [x] 监听 `deskfloat:toggle-pomodoro` 自定义事件(响应全局快捷键)
### 6. 设置 + 全局快捷键 + 自启 ✅
- [x] `SettingsPanel`:置顶 / 透明度 / 自启 / 数据目录入口
- [x] 三个全局热键编辑(`showHide` / `newTask` / `togglePomodoro`
- [x] `useHotkeys.ts``register` + `unregisterAll`,配置变更时重新注册
- [x] 透明度通过 CSS 变量 `--bg-opacity` 实时刷新
- [x] 置顶通过 `set_always_on_top` 命令同步到窗口
- [x] 开机自启使用 `tauri-plugin-autostart`
### 7. 打磨 ✅
- [x] 折叠态(180×40)/ 正常态(560×56)/ 展开态(自适应)三档窗口尺寸动态切换(`set_window_size`
- [x] 抽屉式面板下滑动画
- [x] 自定义 toggle / range 控件样式
- [x] README + 当前 PROGRESS 文档
- [x] 前端 `tsc -b && vite build` 通过:bundle 245 KB JS / 17 KB CSSgzip 后 77 KB / 3.6 KB
- [x] 后端 `cargo check` 通过
---
## 验证清单(手工 QA TODO
下次需要在真机上跑 `npm run tauri:dev` 后逐项验证:
- [ ] 窗口透明 + 置顶 + 拖动正常
- [ ] 任务增删改查 + 拖拽排序持久化
- [ ] 4 种快捷方式分别执行成功(URL/notepad/cmd echo/PS
- [ ] 番茄钟阶段切换通知到达
- [ ] 3 个全局快捷键在窗口失焦时仍生效
- [ ] 修改快捷键 → 旧热键释放、新热键注册
- [ ] 开机自启开关可来回切换
- [ ] `npm run tauri:build` 出 MSI / NSIS 安装包并能安装运行
---
## 后续可选增强(非 MVP
- ~~系统托盘 + 右键菜单(show/hide/quit~~ ✅ 已实现(`src-tauri/src/tray.rs`
- 番茄钟"专注次数"统计(用户在初版选择不要)
- 快捷方式拖拽排序
- 多主题切换(浅色 / 高对比)
- 多窗口/多个独立工具栏布局
+152
View File
@@ -0,0 +1,152 @@
# DeskFloat · 桌面透明浮动工具栏
一个资源占用极低的 Windows 桌面浮动工具栏,使用 **Tauri 2 + React + TypeScript** 实现。
主要功能:
- **任务列表**:增删改查、勾选完成、拖拽排序、未完成数量徽标
- **自定义快捷按钮**:支持 4 种执行类型
- `url` — 用系统默认浏览器打开网页
- `app` — 启动可执行文件(支持参数 / 工作目录)
- `cmd` — 运行 `cmd.exe /C ...` 命令(隐藏控制台)
- `powershell` — 运行 PowerShell 命令(隐藏控制台)
- **番茄钟**:工作/休息双阶段倒计时 + 系统通知 + 可调时长
- **全局快捷键**:显示/隐藏窗口、快速新建任务、番茄钟开始暂停
- **系统托盘**:任务栏右下角托盘图标,右键菜单可显示/隐藏/退出
- **设置**:背景透明度、始终置顶、开机自启、数据目录跳转
- **持久化**JSON 文件存于 `%APPDATA%\com.deskfloat.app\data.json`,写入有 300ms 防抖
预期资源占用:内存约 50–80MB,安装包 < 10MB。
---
## 技术栈
| 层 | 技术 |
|---|---|
| 桌面壳 | Tauri 2.xRust 主进程 + 系统 WebView |
| 前端 | React 18 + TypeScript + Vite |
| 状态 | Zustand(轻量) |
| 图标 | lucide-react(按需引入) |
| 拖拽 | @dnd-kit |
| 后端能力 | tauri-plugin-{fs, opener, shell, notification, global-shortcut, autostart} |
---
## 目录结构
```
deskTopFloat/
├── src-tauri/ # Rust 后端
│ ├── src/
│ │ ├── main.rs # 入口
│ │ ├── lib.rs # 注册插件 / 命令
│ │ ├── storage.rs # JSON 读写
│ │ ├── runner.rs # 快捷方式执行
│ │ └── commands.rs # invoke 命令导出
│ ├── icons/ # 应用图标(由 mmx 生成 + tauri icon 打包)
│ ├── capabilities/default.json
│ ├── Cargo.toml
│ └── tauri.conf.json
├── src/ # 前端
│ ├── App.tsx
│ ├── main.tsx
│ ├── api/tauri.ts # invoke 封装
│ ├── store/
│ │ ├── types.ts # 类型定义
│ │ └── useAppStore.ts # Zustand store(含防抖落盘)
│ ├── hooks/useHotkeys.ts # 全局快捷键注册
│ ├── components/Toolbar/ # 工具栏外壳
│ ├── modules/
│ │ ├── tasks/ # 任务列表
│ │ ├── shortcuts/ # 自定义快捷按钮
│ │ ├── pomodoro/ # 番茄钟
│ │ └── settings/ # 设置面板
│ └── styles/global.css
├── package.json
├── vite.config.ts
├── tsconfig.json
└── PROGRESS.md # 开发进度
```
---
## 环境要求
- **Node.js** 18+(实测 22.22.0
- **Rust** 1.77+(实测 1.93.0
- **Windows 10/11**,需安装 WebView2Win10 1803+ 自带)
- 推荐安装 **Visual Studio Build Tools**(含 MSVC C++ 工具链,Tauri 编译需要)
---
## 快速开始
```powershell
# 安装前端依赖
npm install
# 开发模式(启动 Vite + Tauri 主窗口)
npm run tauri:dev
# 生产构建(生成 MSI 安装包 + NSIS 安装程序)
npm run tauri:build
```
构建产物路径:`src-tauri\target\release\bundle\msi\*.msi``src-tauri\target\release\bundle\nsis\*-setup.exe`
---
## 使用说明
### 默认全局快捷键
| 快捷键 | 功能 |
|---|---|
| `Ctrl + Alt + \`` | 显示 / 隐藏主窗口 |
| `Ctrl + Alt + T` | 调出窗口并定位到任务输入框(快速记一条任务) |
| `Ctrl + Alt + P` | 番茄钟 开始 / 暂停 |
可在 **设置面板** 中自定义。
### 系统托盘(退出应用)
主窗口无标题栏关闭按钮。要结束程序请使用 **任务栏右下角托盘区** 的 DeskFloat 图标:
| 操作 | 效果 |
|------|------|
| **右键** → 显示工具栏 | 显示浮动工具栏 |
| **右键** → 隐藏工具栏 | 隐藏浮动工具栏(进程仍在) |
| **右键** → **退出** | 完全关闭应用 |
| **左键单击** 托盘图标 | 切换显示 / 隐藏 |
若触发系统级「关闭窗口」,也会改为隐藏到托盘,不会直接退出。
### 快捷方式编辑
工具栏上的"快捷区"显示已配置按钮:
- **左键** 单击 → 执行
- **右键** 单击 → 打开管理面板(编辑/删除)
- **"+"** 号 → 新增
类型示例:
```jsonc
{ "type": "url", "target": "https://github.com" }
{ "type": "app", "target": "C:\\Program Files\\Cursor\\Cursor.exe", "args": [] }
{ "type": "cmd", "target": "echo hello && pause", "cwd": "D:\\projects" }
{ "type": "powershell", "target": "Get-Process | Sort CPU -Desc | Select -First 5" }
```
### 数据存储
所有数据均写入 `%APPDATA%\com.deskfloat.app\data.json`,可在设置面板 → "数据目录" 直接跳转。
---
## 开发须知
- 前端写入 store 后会自动防抖 300ms 整体落盘
- Rust 侧执行子进程时会 `CREATE_NO_WINDOW`0x0800_0000),避免黑色控制台一闪而过
- 透明度由 CSS 变量 `--bg-opacity` 控制,实时响应设置面板滑块
- 全局快捷键通过 `tauri-plugin-global-shortcut`,修改后会重新注册
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeskFloat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2133
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "desk-top-float",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "桌面透明浮动工具栏 - 任务/快捷方式/番茄钟",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:exe": "tauri build --no-bundle",
"tauri:build:nsis": "tauri build --bundles nsis"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-autostart": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.3",
"@tauri-apps/plugin-global-shortcut": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.1",
"@tauri-apps/plugin-opener": "^2.2.5",
"@tauri-apps/plugin-shell": "^2.0.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+5580
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "desk-top-float"
version = "0.1.0"
description = "桌面透明浮动工具栏"
authors = ["you"]
edition = "2021"
[lib]
name = "desk_top_float_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "2"
[target."cfg(windows)".dependencies]
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "默认窗口能力:允许前端调用基础窗口/文件/通知/快捷键/打开 URL 等接口",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-set-always-on-top",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-set-decorations",
"core:window:allow-start-dragging",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-set-ignore-cursor-events",
"core:webview:default",
"core:app:default",
"core:path:default",
"core:event:default",
"shell:default",
"opener:default",
"opener:allow-open-url",
"fs:default",
"notification:default",
"notification:allow-notify",
"global-shortcut:default",
"autostart:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

+114
View File
@@ -0,0 +1,114 @@
use crate::pin_mode;
use crate::runner::{self, Shortcut};
use crate::storage;
use tauri::{AppHandle, Manager, PhysicalSize};
use tauri_plugin_notification::NotificationExt;
/// 统一的命令返回错误类型(serde 友好)
#[derive(Debug, thiserror::Error)]
pub enum CmdError {
#[error("{0}")]
Anyhow(String),
}
impl From<anyhow::Error> for CmdError {
fn from(e: anyhow::Error) -> Self {
CmdError::Anyhow(format!("{e:#}"))
}
}
impl serde::Serialize for CmdError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
type CmdResult<T> = Result<T, CmdError>;
/// 读取整份 JSON 数据返回给前端
#[tauri::command]
pub fn load_state(app: AppHandle) -> CmdResult<String> {
Ok(storage::read_state(&app)?)
}
/// 前端整体保存 JSON
#[tauri::command]
pub fn save_state(app: AppHandle, json: String) -> CmdResult<()> {
storage::write_state(&app, &json)?;
Ok(())
}
/// 执行一个快捷方式(app/cmd/powershellurl 由前端 opener 处理)
#[tauri::command]
pub fn run_shortcut(shortcut: Shortcut) -> CmdResult<()> {
runner::execute(&shortcut)?;
Ok(())
}
/// 切换主窗口的"始终置顶"
#[tauri::command]
pub fn set_always_on_top(app: AppHandle, on: bool) -> CmdResult<()> {
if let Some(win) = app.get_webview_window("main") {
win.set_always_on_top(on)
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
}
Ok(())
}
/// 调整主窗口大小(折叠/展开抽屉时使用)
#[tauri::command]
pub fn set_window_size(app: AppHandle, width: u32, height: u32) -> CmdResult<()> {
if let Some(win) = app.get_webview_window("main") {
win.set_size(PhysicalSize::new(width, height))
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
}
Ok(())
}
/// 在文件资源管理器中打开数据目录
#[tauri::command]
pub fn open_data_dir(app: AppHandle) -> CmdResult<String> {
let path = storage::data_path(&app)?;
let dir = path
.parent()
.ok_or_else(|| CmdError::Anyhow("no parent dir".into()))?;
// 用 Windows explorer 打开目录
#[cfg(target_os = "windows")]
{
use std::process::Command;
Command::new("explorer.exe")
.arg(dir)
.spawn()
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
}
Ok(dir.display().to_string())
}
/// 钉子模式:开启后窗口默认点击穿透,仅钉子按钮热区可点击
#[tauri::command]
pub fn set_pin_mode(app: AppHandle, on: bool) -> CmdResult<()> {
pin_mode::set_pin_mode(&app, on).map_err(CmdError::Anyhow)?;
Ok(())
}
/// 更新钉子按钮在窗口内的热区(供轮询判断何时恢复接收鼠标)
#[tauri::command]
pub fn update_pin_hotzone(x: f64, y: f64, width: f64, height: f64, active: bool) -> CmdResult<()> {
pin_mode::set_hotzone(x, y, width, height, active);
Ok(())
}
/// 发送系统通知(番茄钟阶段切换提示)
#[tauri::command]
pub fn notify_user(app: AppHandle, title: String, body: String) -> CmdResult<()> {
app.notification()
.builder()
.title(&title)
.body(&body)
.show()
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
Ok(())
}
+58
View File
@@ -0,0 +1,58 @@
mod storage;
mod runner;
mod commands;
mod tray;
mod pin_mode;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
/// Tauri 应用入口:注册插件、初始化窗口属性、暴露所有 invoke 命令
pub fn run() {
tauri::Builder::default()
// 文件读写(用于 JSON 持久化)
.plugin(tauri_plugin_fs::init())
// 用系统默认浏览器打开 URL
.plugin(tauri_plugin_opener::init())
// 执行系统命令 / 启动应用
.plugin(tauri_plugin_shell::init())
// 系统通知(番茄钟提醒)
.plugin(tauri_plugin_notification::init())
// 全局快捷键
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// 开机自启
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
))
.setup(|app| {
// 启动后保证数据文件就绪
if let Err(e) = storage::ensure_data_file(&app.handle()) {
eprintln!("[DeskFloat] init data file failed: {e:?}");
}
// 主窗口在 Windows 上的额外微调:取消阴影、保持半透明
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_always_on_top(true);
let _ = win.set_skip_taskbar(true);
}
// 系统托盘:右键「退出」才真正结束进程
tray::setup_tray(app.handle())?;
Ok(())
})
.on_window_event(|window, event| {
tray::on_window_event(window, event);
})
.invoke_handler(tauri::generate_handler![
commands::load_state,
commands::save_state,
commands::run_shortcut,
commands::set_always_on_top,
commands::set_window_size,
commands::open_data_dir,
commands::notify_user,
commands::set_pin_mode,
commands::update_pin_hotzone,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// 防止 Windows release 构建时弹出 cmd 窗口
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
desk_top_float_lib::run()
}
+128
View File
@@ -0,0 +1,128 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Manager};
/// 钉子按钮在窗口内的热区(逻辑像素,相对窗口客户区左上角)
#[derive(Clone, Copy, Default)]
struct Hotzone {
x: f64,
y: f64,
w: f64,
h: f64,
active: bool,
}
static HOTZONE: Mutex<Hotzone> = Mutex::new(Hotzone {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
active: false,
});
static POLLING: AtomicBool = AtomicBool::new(false);
/// 前端布局变化时更新钉子按钮热区坐标
pub fn set_hotzone(x: f64, y: f64, width: f64, height: f64, active: bool) {
if let Ok(mut hz) = HOTZONE.lock() {
*hz = Hotzone {
x,
y,
w: width,
h: height,
active,
};
}
}
/// 开启/关闭钉子模式;开启时后台轮询鼠标位置,仅在热区内恢复窗口接收点击
pub fn set_pin_mode(app: &AppHandle, on: bool) -> Result<(), String> {
if on {
if let Some(win) = app.get_webview_window("main") {
// 默认先穿透,等热区上报后再由轮询精确切换
win.set_ignore_cursor_events(true)
.map_err(|e| e.to_string())?;
}
start_polling(app.clone());
} else {
stop_polling();
if let Ok(mut hz) = HOTZONE.lock() {
hz.active = false;
}
if let Some(win) = app.get_webview_window("main") {
win.set_ignore_cursor_events(false)
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn start_polling(app: AppHandle) {
if POLLING.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
while POLLING.load(Ordering::SeqCst) {
poll_once(&app);
std::thread::sleep(Duration::from_millis(32));
}
});
}
fn stop_polling() {
POLLING.store(false, Ordering::SeqCst);
}
fn poll_once(app: &AppHandle) {
let hz = match HOTZONE.lock() {
Ok(h) => *h,
Err(_) => return,
};
let Some(win) = app.get_webview_window("main") else {
return;
};
if !hz.active || hz.w <= 0.0 || hz.h <= 0.0 {
let _ = win.set_ignore_cursor_events(true);
return;
}
let (cursor_x, cursor_y) = match cursor_screen_position() {
Some(p) => p,
None => return,
};
let Some(win_pos) = win.outer_position().ok() else {
return;
};
// 热区为前端 getBoundingClientRect 的逻辑像素;光标与窗口位置为物理像素
let scale = win.scale_factor().unwrap_or(1.0);
let local_x = (cursor_x - win_pos.x as f64) / scale;
let local_y = (cursor_y - win_pos.y as f64) / scale;
let in_hotzone = local_x >= hz.x
&& local_x <= hz.x + hz.w
&& local_y >= hz.y
&& local_y <= hz.y + hz.h;
let _ = win.set_ignore_cursor_events(!in_hotzone);
}
#[cfg(target_os = "windows")]
fn cursor_screen_position() -> Option<(f64, f64)> {
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
let mut pt = POINT::default();
unsafe {
GetCursorPos(&mut pt).ok()?;
}
Some((pt.x as f64, pt.y as f64))
}
#[cfg(not(target_os = "windows"))]
fn cursor_screen_position() -> Option<(f64, f64)> {
None
}
+129
View File
@@ -0,0 +1,129 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::process::Command;
/// 前端传过来的"快捷方式"定义,与 JSON 中的 shortcut 项保持一致
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shortcut {
pub id: String,
pub label: String,
#[serde(default)]
pub icon: String,
/// "url" | "app" | "cmd" | "powershell"
#[serde(rename = "type")]
pub kind: String,
pub target: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub cwd: Option<String>,
/// 是否静默执行(仅对 cmd/powershell 生效):
/// - false(默认):弹出控制台窗口,用户能看到输出,适合调试或交互命令(pause/read)
/// - true:隐藏控制台,适合后台脚本
#[serde(default)]
pub silent: bool,
}
/// 根据 type 分发执行:
/// - url:交给前端用 opener 打开(这里直接返回标记,让前端处理)
/// - app:直接以独立进程方式启动可执行文件
/// - cmd:通过 cmd.exe /C 执行
/// - powershell:通过 powershell.exe -NoProfile -Command 执行
pub fn execute(shortcut: &Shortcut) -> Result<()> {
match shortcut.kind.as_str() {
"url" => {
// url 类型在前端通过 opener 插件处理;保留这里以便扩展(例如内置浏览器)
anyhow::bail!("url shortcuts should be handled by frontend opener");
}
"app" => spawn_app(shortcut),
"cmd" => spawn_cmd(shortcut),
"powershell" => spawn_powershell(shortcut),
other => anyhow::bail!("unsupported shortcut type: {other}"),
}
}
fn spawn_app(s: &Shortcut) -> Result<()> {
let mut cmd = Command::new(&s.target);
if !s.args.is_empty() {
cmd.args(&s.args);
}
apply_cwd(&mut cmd, &s.cwd);
// 启动可执行文件本身不弹出控制台
hide_console(&mut cmd);
cmd.spawn()
.with_context(|| format!("failed to spawn app: {}", s.target))?;
Ok(())
}
/// 用 `cmd.exe /C` 执行一行命令。默认显式打开一个新控制台窗口让用户能看到输出。
fn spawn_cmd(s: &Shortcut) -> Result<()> {
let mut cmd = Command::new("cmd.exe");
// /K 保持窗口不关闭,便于用户看完输出;silent 模式用 /C 静默
if s.silent {
cmd.arg("/C").arg(&s.target);
hide_console(&mut cmd);
} else {
// 用 start "title" cmd /K 在新窗口里运行,这样窗口不会因为父进程退出而立即销毁
// 这里直接走 cmd /K 的方式,CREATE_NEW_CONSOLE 标志可见
cmd.arg("/C")
.arg("start")
.arg("") // title 占位
.arg("cmd.exe")
.arg("/K")
.arg(&s.target);
new_console(&mut cmd);
}
apply_cwd(&mut cmd, &s.cwd);
cmd.spawn()
.with_context(|| format!("failed to spawn cmd: {}", s.target))?;
Ok(())
}
/// 用 powershell.exe 执行一段脚本。
fn spawn_powershell(s: &Shortcut) -> Result<()> {
let mut cmd = Command::new("powershell.exe");
cmd.arg("-NoProfile");
if s.silent {
cmd.arg("-WindowStyle").arg("Hidden");
cmd.arg("-Command").arg(&s.target);
hide_console(&mut cmd);
} else {
// -NoExit 让窗口在命令执行完后保留,方便查看输出
cmd.arg("-NoExit").arg("-Command").arg(&s.target);
new_console(&mut cmd);
}
apply_cwd(&mut cmd, &s.cwd);
cmd.spawn()
.with_context(|| format!("failed to spawn powershell: {}", s.target))?;
Ok(())
}
fn apply_cwd(cmd: &mut Command, cwd: &Option<String>) {
if let Some(c) = cwd {
if !c.trim().is_empty() {
cmd.current_dir(c);
}
}
}
/// Windows 下隐藏子进程的控制台窗口
#[cfg(target_os = "windows")]
fn hide_console(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW = 0x08000000
cmd.creation_flags(0x0800_0000);
}
#[cfg(not(target_os = "windows"))]
fn hide_console(_cmd: &mut Command) {}
/// Windows 下显式给子进程开一个新的可见控制台窗口
#[cfg(target_os = "windows")]
fn new_console(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
// CREATE_NEW_CONSOLE = 0x00000010
cmd.creation_flags(0x0000_0010);
}
#[cfg(not(target_os = "windows"))]
fn new_console(_cmd: &mut Command) {}
+110
View File
@@ -0,0 +1,110 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
const DATA_FILE: &str = "data.json";
/// 应用全量持久化数据(JSON 根对象)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppState {
#[serde(default)]
pub tasks: serde_json::Value,
#[serde(default)]
pub shortcuts: serde_json::Value,
#[serde(default)]
pub pomodoro: serde_json::Value,
#[serde(default)]
pub settings: serde_json::Value,
}
impl Default for AppState {
fn default() -> Self {
AppState {
tasks: serde_json::json!([]),
shortcuts: serde_json::json!([
{
"id": "demo-1",
"label": "GitHub",
"icon": "github",
"type": "url",
"target": "https://github.com",
"args": []
},
{
"id": "demo-2",
"label": "记事本",
"icon": "file-text",
"type": "app",
"target": "notepad.exe",
"args": []
}
]),
pomodoro: serde_json::json!({
"workMin": 25,
"breakMin": 5,
"soundOn": true
}),
settings: serde_json::json!({
"alwaysOnTop": true,
"opacity": 0.85,
"globalHotkeys": {
"newTask": "Ctrl+Alt+T",
"togglePomodoro": "Ctrl+Alt+P",
"showHide": "Ctrl+Alt+`"
},
"autoStart": false
}),
}
}
}
/// 获取数据文件完整路径,若目录不存在则创建
pub fn data_path(app: &AppHandle) -> Result<PathBuf> {
let dir = app
.path()
.app_data_dir()
.context("failed to resolve app data dir")?;
fs::create_dir_all(&dir).with_context(|| format!("create dir {}", dir.display()))?;
Ok(dir.join(DATA_FILE))
}
/// 启动时调用:若 data.json 不存在则写入默认值
pub fn ensure_data_file(app: &AppHandle) -> Result<()> {
let path = data_path(app)?;
if !path.exists() {
let default = AppState::default();
let json = serde_json::to_string_pretty(&default)?;
fs::write(&path, json).with_context(|| format!("write default {}", path.display()))?;
}
Ok(())
}
/// 读取 JSON 字符串(前端直接解析)。若文件损坏则返回默认值。
pub fn read_state(app: &AppHandle) -> Result<String> {
let path = data_path(app)?;
if !path.exists() {
ensure_data_file(app)?;
}
let content = fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
// 校验一下结构,失败则用默认覆盖
if serde_json::from_str::<AppState>(&content).is_err() {
let default = AppState::default();
let json = serde_json::to_string_pretty(&default)?;
fs::write(&path, &json)?;
return Ok(json);
}
Ok(content)
}
/// 写入 JSON 字符串(前端整体保存)
pub fn write_state(app: &AppHandle, json: &str) -> Result<()> {
let path = data_path(app)?;
// 简单校验,避免写入非法 JSON
let _: serde_json::Value =
serde_json::from_str(json).context("invalid JSON when saving state")?;
fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, Runtime,
};
/// 初始化 Windows 系统托盘(通知区域):
/// - 右键菜单:显示 / 隐藏 / 退出
/// - 左键单击:切换工具栏显示/隐藏
pub fn setup_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "显示工具栏", true, None::<&str>)?;
let hide = MenuItem::with_id(app, "hide", "隐藏工具栏", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
let icon = app
.default_window_icon()
.cloned()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "default window icon missing")
})?;
let _tray = TrayIconBuilder::with_id("main-tray")
.icon(icon)
.tooltip("DeskFloat")
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"hide" => hide_main_window(app),
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
toggle_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
/// 关闭窗口请求时改为隐藏到托盘,避免误关导致进程仍在后台却无入口
pub fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
}
fn show_main_window<R: Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
fn hide_main_window<R: Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.hide();
}
}
fn toggle_main_window<R: Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let visible = win.is_visible().unwrap_or(true);
if visible {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "DeskFloat",
"version": "0.1.0",
"identifier": "com.deskfloat.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "DeskFloat",
"width": 520,
"height": 64,
"minWidth": 320,
"minHeight": 56,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": true,
"shadow": false,
"center": false,
"x": 200,
"y": 80,
"visible": true,
"fullscreen": false,
"dragDropEnabled": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["msi", "nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Productivity",
"shortDescription": "桌面透明浮动工具栏",
"longDescription": "包含任务列表、自定义快捷按钮、番茄钟的桌面浮动工具栏"
}
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect } from "react";
import { Toolbar } from "./components/Toolbar/Toolbar";
import { PinnedTasksOverlay } from "./modules/tasks/PinnedTasksOverlay";
import { useAppStore } from "./store/useAppStore";
import { registerGlobalHotkeys } from "./hooks/useHotkeys";
import { setAlwaysOnTop } from "./api/tauri";
/**
* 应用根组件:
* 1. 启动时 hydrate 持久化数据
* 2. 同步系统级设置(置顶 / 全局热键 / 透明度)
* 3. 渲染浮动工具栏
*/
export function App() {
const ready = useAppStore((s) => s.ready);
const hydrate = useAppStore((s) => s.hydrate);
const settings = useAppStore((s) => s.settings);
const taskPinMode = useAppStore((s) => s.taskPinMode);
// 启动加载
useEffect(() => {
void hydrate();
}, [hydrate]);
// 透明度变化时同步到 CSS 变量(钉子模式使用专用更低透明度)
useEffect(() => {
if (taskPinMode) return;
document.documentElement.style.setProperty(
"--bg-opacity",
String(settings.opacity)
);
}, [settings.opacity, taskPinMode]);
// 置顶状态同步到窗口
useEffect(() => {
if (!ready) return;
void setAlwaysOnTop(settings.alwaysOnTop);
}, [ready, settings.alwaysOnTop]);
// 全局快捷键
useEffect(() => {
if (!ready) return;
const unregister = registerGlobalHotkeys(settings.globalHotkeys);
return () => {
void unregister();
};
}, [ready, settings.globalHotkeys]);
if (!ready) return null;
if (taskPinMode) return <PinnedTasksOverlay />;
return <Toolbar />;
}
+56
View File
@@ -0,0 +1,56 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl as opener } from "@tauri-apps/plugin-opener";
import type { Shortcut } from "../store/types";
/**
* Tauri 命令的薄封装层,集中处理 invoke 名称与错误打印,方便前端调用。
*/
export async function loadState(): Promise<string> {
return invoke<string>("load_state");
}
export async function saveState(json: string): Promise<void> {
await invoke("save_state", { json });
}
/** 执行一个快捷方式:url 走 opener,其他类型走 Rust runner */
export async function runShortcut(s: Shortcut): Promise<void> {
if (s.type === "url") {
await opener(s.target);
return;
}
await invoke("run_shortcut", { shortcut: s });
}
export async function setAlwaysOnTop(on: boolean): Promise<void> {
await invoke("set_always_on_top", { on });
}
export async function setWindowSize(width: number, height: number): Promise<void> {
await invoke("set_window_size", { width: Math.round(width), height: Math.round(height) });
}
export async function openDataDir(): Promise<string> {
return invoke<string>("open_data_dir");
}
export async function notifyUser(title: string, body: string): Promise<void> {
await invoke("notify_user", { title, body });
}
/** 钉子模式:窗口点击穿透,仅热区可点 */
export async function setPinMode(on: boolean): Promise<void> {
await invoke("set_pin_mode", { on });
}
/** 上报钉子按钮热区(逻辑像素) */
export async function updatePinHotzone(
x: number,
y: number,
width: number,
height: number,
active: boolean
): Promise<void> {
await invoke("update_pin_hotzone", { x, y, width, height, active });
}
+146
View File
@@ -0,0 +1,146 @@
.root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 4px;
}
.bar {
height: 48px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
background: rgba(var(--bg-color), var(--bg-opacity));
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
border: 1px solid rgba(var(--border), 0.08);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
transition: all 0.18s ease-out;
}
.barCollapsed {
width: fit-content;
padding: 0 4px;
height: 32px;
}
/* 锁定时整条工具栏使用更醒目的边框色 */
.barLocked {
border-color: rgba(var(--warning), 0.4);
box-shadow: var(--shadow), 0 0 0 1px rgba(var(--warning), 0.2);
}
/* 禁用区域:降低透明度 + 屏蔽鼠标 */
.disabledRegion {
pointer-events: none;
opacity: 0.45;
}
.dragHandle {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 100%;
color: rgba(var(--fg-dim), 0.7);
cursor: grab;
flex-shrink: 0;
border-radius: var(--radius-sm);
transition: background 0.12s, color 0.12s;
-webkit-app-region: drag;
}
.dragHandle:hover {
background: rgba(var(--border), 0.08);
color: rgb(var(--fg-color));
}
.dragHandle:active {
cursor: grabbing;
background: rgba(var(--border), 0.12);
}
/* 锁定态:拖动柄禁用。必须显式覆盖 -webkit-app-region
否则浏览器/WebView 在 mousedown 之前就已接管为原生拖动区,JS 拦不住 */
.dragHandleLocked {
cursor: not-allowed !important;
opacity: 0.4;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.dragHandleLocked:hover,
.dragHandleLocked:active {
background: transparent;
color: rgba(var(--fg-dim), 0.7);
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
color: rgb(var(--fg-color));
transition: background 0.12s ease, color 0.12s ease;
}
.iconBtn:hover {
background: rgba(var(--border), 0.08);
}
.iconBtnActive {
background: rgba(var(--accent), 0.18);
color: rgb(var(--accent));
}
.iconBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.iconBtn:disabled:hover {
background: transparent;
}
/* 锁定按钮启用态:黄色高亮提示当前锁定中 */
.lockBtnActive {
background: rgba(var(--warning), 0.2);
color: rgb(var(--warning));
}
.lockBtnActive:hover {
background: rgba(var(--warning), 0.3) !important;
}
.divider {
width: 1px;
height: 22px;
background: rgba(var(--border), 0.1);
margin: 0 2px;
}
.shortcutsRegion {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
gap: 4px;
padding: 0 2px;
scrollbar-width: thin;
}
.shortcutsRegion::-webkit-scrollbar {
height: 4px;
}
.panelWrap {
margin-top: 6px;
background: rgba(var(--bg-color), var(--bg-opacity));
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
border: 1px solid rgba(var(--border), 0.08);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 10px 12px;
overflow: hidden;
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useLayoutEffect, useRef, type MouseEvent } from "react";
import clsx from "clsx";
import {
Zap,
Settings as SettingsIcon,
ChevronDown,
ChevronUp,
GripVertical,
Lock,
Unlock,
} from "lucide-react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAppStore } from "../../store/useAppStore";
import { setWindowSize } from "../../api/tauri";
import { TasksPanel, TasksTrigger } from "../../modules/tasks/TasksPanel";
import {
ShortcutsBar,
ShortcutsPanel,
} from "../../modules/shortcuts/ShortcutsPanel";
import {
PomodoroIndicator,
PomodoroPanel,
} from "../../modules/pomodoro/PomodoroPanel";
import { SettingsPanel } from "../../modules/settings/SettingsPanel";
import styles from "./Toolbar.module.css";
const BAR_WIDTH = 620;
const BAR_HEIGHT = 56;
const BAR_COLLAPSED_W = 200;
const BAR_COLLAPSED_H = 40;
const PANEL_WIDTH = 620;
const FALLBACK_PANEL_HEIGHT = 320;
/**
* 浮动工具栏外壳:
* - 拖动柄主动调用 startDragging,避免 data-tauri-drag-region 在部分环境下失效
* - 抽屉面板高度通过 ResizeObserver 实时测量并同步到窗口尺寸
*/
export function Toolbar() {
const activePanel = useAppStore((s) => s.activePanel);
const setActivePanel = useAppStore((s) => s.setActivePanel);
const collapsed = useAppStore((s) => s.collapsed);
const toggleCollapsed = useAppStore((s) => s.toggleCollapsed);
const locked = useAppStore((s) => s.settings.locked);
const updateSettings = useAppStore((s) => s.updateSettings);
// 进入锁定模式时关闭所有抽屉
useEffect(() => {
if (locked && activePanel) {
useAppStore.setState({ activePanel: null });
}
}, [locked, activePanel]);
const toggleLock = () => updateSettings({ locked: !locked });
const panelRef = useRef<HTMLDivElement | null>(null);
/** 显式调用 startDragging,最可靠的拖动方式;锁定状态下禁止拖动 */
const startDrag = async (e: MouseEvent) => {
if (e.button !== 0) return; // 仅左键
if (locked) return; // 仅悬浮模式:拖动也禁用
try {
await getCurrentWindow().startDragging();
} catch (err) {
console.warn("[DeskFloat] startDragging failed", err);
}
};
// 根据折叠/抽屉状态调整窗口尺寸(首次同步)
useLayoutEffect(() => {
if (collapsed) {
void setWindowSize(BAR_COLLAPSED_W, BAR_COLLAPSED_H);
return;
}
if (!activePanel) {
void setWindowSize(BAR_WIDTH, BAR_HEIGHT);
return;
}
const ph = panelRef.current?.offsetHeight ?? FALLBACK_PANEL_HEIGHT;
void setWindowSize(BAR_WIDTH, BAR_HEIGHT + ph + 12);
}, [activePanel, collapsed]);
// 抽屉打开时,监听其实际高度变化(例如编辑器展开/折叠时)实时调整窗口
useEffect(() => {
if (collapsed || !activePanel || !panelRef.current) return;
const el = panelRef.current;
const ro = new ResizeObserver(() => {
const ph = el.offsetHeight || FALLBACK_PANEL_HEIGHT;
void setWindowSize(BAR_WIDTH, BAR_HEIGHT + ph + 12);
});
ro.observe(el);
return () => ro.disconnect();
}, [activePanel, collapsed]);
return (
<div className={styles.root}>
<div
className={clsx(
styles.bar,
collapsed && styles.barCollapsed,
locked && styles.barLocked
)}
>
{/* 左侧拖拽柄:主动 startDragging,避免环境差异;锁定时禁用 */}
<div
className={clsx(
styles.dragHandle,
locked && styles.dragHandleLocked
)}
onMouseDown={startDrag}
title={locked ? "已锁定(请先解锁)" : "按住拖动移动窗口"}
>
<GripVertical size={14} />
</div>
{!collapsed && (
<>
<TasksTrigger
active={activePanel === "tasks"}
onClick={() => !locked && setActivePanel("tasks")}
disabled={locked}
/>
<div className={styles.divider} />
<div
className={clsx(
styles.shortcutsRegion,
locked && styles.disabledRegion
)}
>
<ShortcutsBar disabled={locked} />
</div>
<div className={styles.divider} />
<PomodoroIndicator
active={activePanel === "pomodoro"}
onClick={() => !locked && setActivePanel("pomodoro")}
disabled={locked}
/>
<button
className={clsx(
styles.iconBtn,
activePanel === "shortcuts" && styles.iconBtnActive
)}
title={locked ? "已锁定(请先解锁)" : "管理快捷方式"}
onClick={() => !locked && setActivePanel("shortcuts")}
disabled={locked}
>
<Zap size={16} />
</button>
<button
className={clsx(
styles.iconBtn,
activePanel === "settings" && styles.iconBtnActive
)}
title={locked ? "已锁定(请先解锁)" : "设置"}
onClick={() => !locked && setActivePanel("settings")}
disabled={locked}
>
<SettingsIcon size={16} />
</button>
</>
)}
{/* 锁定/解锁按钮:永远可点击,与折叠按钮并列 */}
<button
className={clsx(styles.iconBtn, locked && styles.lockBtnActive)}
title={locked ? "解锁(恢复交互)" : "锁定(仅悬浮 / 防误触)"}
onClick={toggleLock}
>
{locked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
{/* 折叠/展开按钮 */}
<button
className={styles.iconBtn}
title={collapsed ? "展开" : "折叠"}
onClick={toggleCollapsed}
>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
{activePanel && !collapsed && !locked && (
<div
className={styles.panelWrap}
ref={panelRef}
style={{ width: PANEL_WIDTH - 8 }}
>
{activePanel === "tasks" && <TasksPanel />}
{activePanel === "shortcuts" && <ShortcutsPanel />}
{activePanel === "pomodoro" && <PomodoroPanel />}
{activePanel === "settings" && <SettingsPanel />}
</div>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import {
register,
unregisterAll,
} from "@tauri-apps/plugin-global-shortcut";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAppStore } from "../store/useAppStore";
import type { GlobalHotkeys } from "../store/types";
/**
* 注册三个全局快捷键:
* - newTask: 显示窗口 + 打开任务面板 + 聚焦输入框
* - togglePomodoro: 切换番茄钟开始/暂停(通过自定义事件分发)
* - showHide: 显示/隐藏主窗口
*
* 返回 cleanup 函数。
*/
export function registerGlobalHotkeys(map: GlobalHotkeys): () => Promise<void> {
const win = getCurrentWindow();
(async () => {
try {
await unregisterAll();
if (map.newTask) {
await register(map.newTask, async (e) => {
if (e.state !== "Pressed") return;
await win.show();
await win.setFocus();
useAppStore.getState().setActivePanel("tasks");
// 让任务输入框感知到聚焦请求
window.dispatchEvent(new CustomEvent("deskfloat:focus-new-task"));
});
}
if (map.togglePomodoro) {
await register(map.togglePomodoro, (e) => {
if (e.state !== "Pressed") return;
window.dispatchEvent(new CustomEvent("deskfloat:toggle-pomodoro"));
});
}
if (map.showHide) {
await register(map.showHide, async (e) => {
if (e.state !== "Pressed") return;
const visible = await win.isVisible();
if (visible) {
await win.hide();
} else {
await win.show();
await win.setFocus();
}
});
}
} catch (e) {
console.error("[DeskFloat] hotkey register failed", e);
}
})();
return async () => {
try {
await unregisterAll();
} catch (e) {
console.warn("[DeskFloat] hotkey unregister failed", e);
}
};
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,155 @@
.indicator {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
height: 30px;
border-radius: var(--radius-md);
color: rgb(var(--fg-color));
font-variant-numeric: tabular-nums;
transition: background 0.12s;
}
.indicator:hover {
background: rgba(var(--border), 0.08);
}
.indicatorActive {
background: rgba(var(--accent), 0.18);
color: rgb(var(--accent));
}
.indicator:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.indicator:disabled:hover {
background: transparent;
}
.time {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(var(--fg-dim), 0.5);
transition: background 0.2s;
}
.dotIdle {
background: rgba(var(--fg-dim), 0.4);
}
.dotWork {
background: rgb(var(--success));
box-shadow: 0 0 6px rgba(var(--success), 0.6);
animation: pulse 1.6s ease-in-out infinite;
}
.dotBreak {
background: rgb(var(--warning));
box-shadow: 0 0 6px rgba(var(--warning), 0.6);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.panel {
width: 100%;
height: 220px;
display: flex;
flex-direction: column;
gap: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 12px;
}
.muted {
color: rgba(var(--fg-dim), 0.85);
font-weight: 600;
font-size: 16px;
font-variant-numeric: tabular-nums;
margin-left: auto;
}
.progress {
width: 100%;
height: 4px;
background: rgba(var(--border), 0.08);
border-radius: 2px;
overflow: hidden;
}
.progressFill {
height: 100%;
transition: width 1s linear;
border-radius: 2px;
}
.fillWork {
background: rgb(var(--success));
}
.fillBreak {
background: rgb(var(--warning));
}
.controls {
display: flex;
gap: 6px;
}
.primaryBtn,
.secondaryBtn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
font-size: 12px;
border-radius: var(--radius-sm);
transition: all 0.12s;
}
.primaryBtn {
background: rgba(var(--accent), 0.22);
color: rgb(var(--accent));
}
.primaryBtn:hover {
background: rgba(var(--accent), 0.32);
}
.secondaryBtn {
background: rgba(var(--border), 0.06);
color: rgba(var(--fg-dim), 0.95);
}
.secondaryBtn:hover {
background: rgba(var(--border), 0.12);
color: rgb(var(--fg-color));
}
.config {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 10px;
color: rgba(var(--fg-dim), 0.85);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.field input {
height: 28px;
font-size: 12px;
text-align: center;
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import clsx from "clsx";
import { Play, Pause, RotateCcw, Coffee, Timer as TimerIcon } from "lucide-react";
import { useAppStore } from "../../store/useAppStore";
import { notifyUser } from "../../api/tauri";
import { usePomodoroStore } from "./pomodoroStore";
import styles from "./PomodoroPanel.module.css";
/**
* 工具栏中的番茄钟指示器:显示 MM:SS + 阶段点
*/
export function PomodoroIndicator({
active,
onClick,
disabled = false,
}: {
active: boolean;
onClick: () => void;
disabled?: boolean;
}) {
const phase = usePomodoroStore((s) => s.phase);
const remaining = usePomodoroStore((s) => s.remaining);
const running = usePomodoroStore((s) => s.running);
const text = formatTime(remaining);
return (
<button
className={clsx(styles.indicator, active && styles.indicatorActive)}
title={disabled ? "已锁定" : "番茄钟(点击展开)"}
onClick={onClick}
disabled={disabled}
>
<span
className={clsx(
styles.dot,
running && phase === "work" && styles.dotWork,
running && phase === "break" && styles.dotBreak,
!running && styles.dotIdle
)}
/>
<span className={styles.time}>{text}</span>
</button>
);
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
.toString()
.padStart(2, "0");
const s = Math.floor(sec % 60)
.toString()
.padStart(2, "0");
return `${m}:${s}`;
}
/**
* 番茄钟展开面板:开始/暂停/重置 + 可调工作/休息时长
*/
export function PomodoroPanel() {
const config = useAppStore((s) => s.pomodoro);
const updatePomodoro = useAppStore((s) => s.updatePomodoro);
const phase = usePomodoroStore((s) => s.phase);
const remaining = usePomodoroStore((s) => s.remaining);
const running = usePomodoroStore((s) => s.running);
const start = usePomodoroStore((s) => s.start);
const pause = usePomodoroStore((s) => s.pause);
const reset = usePomodoroStore((s) => s.reset);
const tick = usePomodoroStore((s) => s.tick);
const setConfigInTimer = usePomodoroStore((s) => s.setConfig);
// 同步配置到计时器
useEffect(() => {
setConfigInTimer({ workSec: config.workMin * 60, breakSec: config.breakMin * 60 });
}, [config.workMin, config.breakMin, setConfigInTimer]);
// 计时器主 tick
useEffect(() => {
if (!running) return;
const id = window.setInterval(() => {
const finished = tick();
if (finished) {
const next = phase === "work" ? "break" : "work";
const title = next === "break" ? "工作完成" : "休息结束";
const body =
next === "break"
? `休息 ${config.breakMin} 分钟,放松一下吧。`
: `继续专注 ${config.workMin} 分钟。`;
void notifyUser(title, body);
}
}, 1000);
return () => window.clearInterval(id);
}, [running, tick, phase, config.workMin, config.breakMin]);
// 监听全局快捷键事件
useEffect(() => {
const handler = () => {
if (usePomodoroStore.getState().running) pause();
else start();
};
window.addEventListener("deskfloat:toggle-pomodoro", handler);
return () => window.removeEventListener("deskfloat:toggle-pomodoro", handler);
}, [start, pause]);
const [workMin, setWorkMin] = useState(config.workMin);
const [breakMin, setBreakMin] = useState(config.breakMin);
useEffect(() => setWorkMin(config.workMin), [config.workMin]);
useEffect(() => setBreakMin(config.breakMin), [config.breakMin]);
const total = phase === "work" ? config.workMin * 60 : config.breakMin * 60;
const progress = total === 0 ? 0 : ((total - remaining) / total) * 100;
return (
<div className={styles.panel}>
<div className={styles.header}>
<div className={styles.title}>
{phase === "work" ? <TimerIcon size={14} /> : <Coffee size={14} />}
{phase === "work" ? "工作中" : "休息中"}
<span className={styles.muted}>{formatTime(remaining)}</span>
</div>
</div>
<div className={styles.progress}>
<div
className={clsx(
styles.progressFill,
phase === "work" ? styles.fillWork : styles.fillBreak
)}
style={{ width: `${progress}%` }}
/>
</div>
<div className={styles.controls}>
{running ? (
<button className={styles.primaryBtn} onClick={pause}>
<Pause size={14} />
</button>
) : (
<button className={styles.primaryBtn} onClick={start}>
<Play size={14} />
</button>
)}
<button className={styles.secondaryBtn} onClick={reset}>
<RotateCcw size={14} />
</button>
</div>
<div className={styles.config}>
<div className={styles.field}>
<label> ()</label>
<input
type="number"
min={1}
max={180}
value={workMin}
onChange={(e) => setWorkMin(Number(e.target.value))}
onBlur={() => {
const v = Math.max(1, Math.min(180, workMin || 25));
updatePomodoro({ workMin: v });
setWorkMin(v);
}}
/>
</div>
<div className={styles.field}>
<label> ()</label>
<input
type="number"
min={1}
max={60}
value={breakMin}
onChange={(e) => setBreakMin(Number(e.target.value))}
onBlur={() => {
const v = Math.max(1, Math.min(60, breakMin || 5));
updatePomodoro({ breakMin: v });
setBreakMin(v);
}}
/>
</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { create } from "zustand";
/**
* 番茄钟内存状态(不持久化,只保留剩余秒数 + 阶段 + 运行标志)
*/
type Phase = "work" | "break";
interface PomodoroTimer {
phase: Phase;
remaining: number;
running: boolean;
workSec: number;
breakSec: number;
setConfig: (c: { workSec: number; breakSec: number }) => void;
start: () => void;
pause: () => void;
reset: () => void;
/** 1 秒 tick;返回 true 表示该秒发生了阶段切换 */
tick: () => boolean;
}
export const usePomodoroStore = create<PomodoroTimer>((set, get) => ({
phase: "work",
remaining: 25 * 60,
running: false,
workSec: 25 * 60,
breakSec: 5 * 60,
setConfig({ workSec, breakSec }) {
const s = get();
set({ workSec, breakSec });
if (!s.running) {
set({ remaining: s.phase === "work" ? workSec : breakSec });
}
},
start() {
set({ running: true });
},
pause() {
set({ running: false });
},
reset() {
const s = get();
set({
running: false,
phase: "work",
remaining: s.workSec,
});
},
tick() {
const s = get();
if (!s.running) return false;
if (s.remaining > 1) {
set({ remaining: s.remaining - 1 });
return false;
}
const nextPhase: Phase = s.phase === "work" ? "break" : "work";
const nextRemaining = nextPhase === "work" ? s.workSec : s.breakSec;
set({ phase: nextPhase, remaining: nextRemaining });
return true;
},
}));
@@ -0,0 +1,188 @@
.panel {
width: 100%;
height: 320px;
display: flex;
flex-direction: column;
gap: 8px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 12px;
}
.linkBtn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: var(--radius-sm);
font-size: 10px;
color: rgba(var(--fg-dim), 0.95);
}
.linkBtn:hover {
background: rgba(var(--border), 0.08);
color: rgb(var(--fg-color));
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 2px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: rgba(var(--border), 0.03);
}
.rowCol {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: rgba(var(--border), 0.03);
}
.rowLabel {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgb(var(--fg-color));
}
.muted {
color: rgba(var(--fg-dim), 0.8);
font-size: 10px;
margin-left: 6px;
}
.divider {
height: 1px;
background: rgba(var(--border), 0.08);
margin: 6px 0;
}
.sectionTitle {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(var(--fg-dim), 0.8);
margin-bottom: 2px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
background: rgba(var(--border), 0.1);
border-radius: 2px;
outline: none;
}
.range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgb(var(--accent));
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.95);
}
/* Toggle */
.toggle {
position: relative;
width: 32px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggleTrack {
position: absolute;
inset: 0;
background: rgba(var(--border), 0.2);
border-radius: 999px;
transition: background 0.18s;
}
.toggleThumb {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
transition: transform 0.18s;
}
.toggle input:checked + .toggleTrack {
background: rgb(var(--accent));
}
.toggle input:checked + .toggleTrack .toggleThumb {
transform: translateX(14px);
}
/* Hotkey row */
.hotkeyRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
}
.hotkeyLabel {
font-size: 12px;
color: rgba(var(--fg-color), 0.95);
}
.hotkeyInput {
width: 140px;
height: 26px;
font-size: 11px;
font-family: "Consolas", "Menlo", monospace;
text-align: center;
padding: 2px 6px;
}
.hint {
font-size: 10px;
color: rgba(var(--fg-dim), 0.7);
margin-top: 4px;
padding: 0 8px;
line-height: 1.5;
}
.kbd {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
background: rgba(var(--border), 0.1);
border: 1px solid rgba(var(--border), 0.12);
font-family: "Consolas", "Menlo", monospace;
font-size: 10px;
margin: 0 1px;
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useState } from "react";
import {
Settings as SettingsIcon,
ExternalLink,
Power,
Pin,
Keyboard,
Eye,
Lock,
} from "lucide-react";
import { useAppStore } from "../../store/useAppStore";
import { openDataDir } from "../../api/tauri";
import {
disable as disableAutostart,
enable as enableAutostart,
isEnabled as isAutostartEnabled,
} from "@tauri-apps/plugin-autostart";
import styles from "./SettingsPanel.module.css";
/** 设置面板:置顶 / 透明度 / 三个全局热键 / 开机自启 / 数据目录 */
export function SettingsPanel() {
const settings = useAppStore((s) => s.settings);
const updateSettings = useAppStore((s) => s.updateSettings);
const [autostart, setAutostart] = useState(false);
// 启动时读取系统当前的自启状态
useEffect(() => {
isAutostartEnabled()
.then((v) => setAutostart(v))
.catch(() => {});
}, []);
const toggleAutostart = async (v: boolean) => {
try {
if (v) await enableAutostart();
else await disableAutostart();
setAutostart(v);
updateSettings({ autoStart: v });
} catch (e) {
console.error("[DeskFloat] autostart toggle failed:", e);
alert("开机自启切换失败:" + (e as Error).message);
}
};
const updateHotkey = (
key: keyof typeof settings.globalHotkeys,
val: string
) => {
updateSettings({
globalHotkeys: { ...settings.globalHotkeys, [key]: val },
});
};
return (
<div className={styles.panel}>
<div className={styles.header}>
<div className={styles.title}>
<SettingsIcon size={14} />
</div>
<button
className={styles.linkBtn}
onClick={() => void openDataDir().catch(() => {})}
>
<ExternalLink size={11} />
</button>
</div>
<div className={styles.body}>
{/* 显示与置顶 */}
<div className={styles.row}>
<div className={styles.rowLabel}>
<Pin size={12} />
</div>
<Toggle
checked={settings.alwaysOnTop}
onChange={(v) => updateSettings({ alwaysOnTop: v })}
/>
</div>
<div className={styles.row}>
<div className={styles.rowLabel}>
<Lock size={12} />
</div>
<Toggle
checked={settings.locked}
onChange={(v) => updateSettings({ locked: v })}
/>
</div>
<div className={styles.rowCol}>
<div className={styles.rowLabel}>
<Eye size={12} />
<span className={styles.muted}>
{Math.round(settings.opacity * 100)}%
</span>
</div>
<input
type="range"
min={0.3}
max={1}
step={0.05}
value={settings.opacity}
onChange={(e) =>
updateSettings({ opacity: Number(e.target.value) })
}
className={styles.range}
/>
</div>
<div className={styles.row}>
<div className={styles.rowLabel}>
<Power size={12} />
</div>
<Toggle checked={autostart} onChange={toggleAutostart} />
</div>
<div className={styles.divider} />
{/* 全局快捷键 */}
<div className={styles.sectionTitle}>
<Keyboard size={12} />
</div>
<HotkeyField
label="显示 / 隐藏窗口"
value={settings.globalHotkeys.showHide}
onChange={(v) => updateHotkey("showHide", v)}
placeholder="Ctrl+Alt+`"
/>
<HotkeyField
label="新建任务"
value={settings.globalHotkeys.newTask}
onChange={(v) => updateHotkey("newTask", v)}
placeholder="Ctrl+Alt+T"
/>
<HotkeyField
label="番茄钟 开始/暂停"
value={settings.globalHotkeys.togglePomodoro}
onChange={(v) => updateHotkey("togglePomodoro", v)}
placeholder="Ctrl+Alt+P"
/>
<div className={styles.hint}>
+ {" "}
<span className={styles.kbd}>Ctrl+Alt+T</span>
</div>
</div>
</div>
);
}
/** 自定义开关组件 */
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className={styles.toggle}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className={styles.toggleTrack}>
<span className={styles.toggleThumb} />
</span>
</label>
);
}
/** 单个全局快捷键编辑行 */
function HotkeyField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
return (
<div className={styles.hotkeyRow}>
<span className={styles.hotkeyLabel}>{label}</span>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== value) onChange(draft.trim());
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
(e.target as HTMLInputElement).blur();
}
}}
placeholder={placeholder}
className={styles.hotkeyInput}
/>
</div>
);
}
@@ -0,0 +1,364 @@
.barBtn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
height: 28px;
border-radius: var(--radius-sm);
color: rgb(var(--fg-color));
font-size: 11px;
flex-shrink: 0;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.12s;
}
.barBtn span {
overflow: hidden;
text-overflow: ellipsis;
}
.barBtn:hover {
background: rgba(var(--border), 0.1);
}
.barBtn:active {
background: rgba(var(--accent), 0.18);
}
.barAddBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 4px;
color: rgba(var(--fg-dim), 0.8);
border: 1px dashed rgba(var(--border), 0.18);
}
.barAddBtn:hover {
color: rgb(var(--accent));
border-color: rgba(var(--accent), 0.4);
}
.emptyBtn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 10px;
height: 26px;
border-radius: var(--radius-sm);
color: rgba(var(--fg-dim), 0.9);
border: 1px dashed rgba(var(--border), 0.18);
font-size: 11px;
}
.emptyBtn:hover {
color: rgb(var(--accent));
border-color: rgba(var(--accent), 0.4);
}
.panel {
width: 100%;
height: 320px;
display: flex;
flex-direction: column;
gap: 8px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 12px;
}
.muted {
color: rgba(var(--fg-dim), 0.8);
font-weight: 400;
font-size: 11px;
margin-left: 4px;
}
.addBtn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
background: rgba(var(--accent), 0.18);
color: rgb(var(--accent));
font-size: 11px;
}
.addBtn:hover {
background: rgba(var(--accent), 0.28);
}
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.empty {
text-align: center;
color: rgba(var(--fg-dim), 0.7);
font-size: 11px;
padding: 16px 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
border-radius: var(--radius-sm);
transition: background 0.12s;
}
.row:hover {
background: rgba(var(--border), 0.05);
}
.rowIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
background: rgba(var(--accent), 0.12);
color: rgb(var(--accent));
flex-shrink: 0;
}
.rowMain {
flex: 1;
min-width: 0;
}
.rowLabel {
font-size: 12px;
color: rgb(var(--fg-color));
}
.rowMeta {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: rgba(var(--fg-dim), 0.85);
margin-top: 2px;
}
.typeTag {
background: rgba(var(--border), 0.08);
padding: 1px 5px;
border-radius: 4px;
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.5px;
}
.target {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowActions {
display: inline-flex;
gap: 2px;
opacity: 0;
transition: opacity 0.12s;
}
.row:hover .rowActions {
opacity: 1;
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
color: rgba(var(--fg-dim), 0.9);
}
.iconBtn:hover {
background: rgba(var(--border), 0.1);
color: rgb(var(--fg-color));
}
.danger:hover {
background: rgba(var(--danger), 0.18);
color: rgb(var(--danger));
}
/* Inline Editor */
.editor {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.editorHeader {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(var(--border), 0.06);
}
.backBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
color: rgba(var(--fg-dim), 0.95);
}
.backBtn:hover {
background: rgba(var(--border), 0.08);
color: rgb(var(--fg-color));
}
.editorTitle {
font-weight: 600;
font-size: 13px;
}
.editorBody {
display: flex;
flex-direction: column;
gap: 10px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.field label {
font-size: 10px;
color: rgba(var(--fg-dim), 0.85);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.field input {
height: 30px;
font-size: 12px;
width: 100%;
}
.typeRow {
display: flex;
gap: 4px;
}
.typeBtn {
flex: 1;
height: 30px;
border-radius: var(--radius-sm);
background: rgba(var(--border), 0.05);
color: rgba(var(--fg-dim), 0.95);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.12s;
}
.typeBtn:hover {
background: rgba(var(--border), 0.1);
}
.typeBtnActive {
background: rgba(var(--accent), 0.2);
color: rgb(var(--accent));
}
.iconGrid {
display: grid;
grid-template-columns: repeat(14, 1fr);
gap: 4px;
max-height: 84px;
overflow-y: auto;
padding: 6px;
background: rgba(var(--border), 0.04);
border-radius: var(--radius-sm);
}
.iconCell {
width: 100%;
aspect-ratio: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: rgba(var(--fg-dim), 0.9);
min-width: 24px;
}
.iconCell:hover {
background: rgba(var(--border), 0.1);
color: rgb(var(--fg-color));
}
.iconCellActive {
background: rgba(var(--accent), 0.22);
color: rgb(var(--accent));
}
.silentRow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--radius-sm);
background: rgba(var(--border), 0.04);
font-size: 12px;
cursor: pointer;
flex-wrap: wrap;
}
.silentRow input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: rgb(var(--accent));
margin: 0;
}
.silentHint {
width: 100%;
font-size: 10px;
color: rgba(var(--fg-dim), 0.7);
padding-left: 22px;
}
.editorActions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid rgba(var(--border), 0.06);
}
.cancelBtn,
.saveBtn {
padding: 6px 18px;
height: 30px;
border-radius: var(--radius-sm);
font-size: 12px;
}
.cancelBtn {
color: rgba(var(--fg-dim), 0.95);
background: rgba(var(--border), 0.05);
}
.cancelBtn:hover {
background: rgba(var(--border), 0.1);
color: rgb(var(--fg-color));
}
.saveBtn {
background: rgb(var(--accent));
color: white;
}
.saveBtn:hover {
background: rgb(var(--accent-strong));
}
+325
View File
@@ -0,0 +1,325 @@
import { useState } from "react";
import clsx from "clsx";
import { Plus, Pencil, Trash2, Zap, ArrowLeft } from "lucide-react";
import { useAppStore } from "../../store/useAppStore";
import type { Shortcut, ShortcutType } from "../../store/types";
import { runShortcut } from "../../api/tauri";
import { getIcon, ICON_OPTIONS } from "./iconMap";
import styles from "./ShortcutsPanel.module.css";
/**
* 主工具栏上的横向快捷按钮区
*/
export function ShortcutsBar({ disabled = false }: { disabled?: boolean }) {
const shortcuts = useAppStore((s) => s.shortcuts);
const setActivePanel = useAppStore((s) => s.setActivePanel);
async function exec(s: Shortcut) {
try {
await runShortcut(s);
} catch (e) {
console.error("[DeskFloat] run shortcut failed", e);
alert(`执行失败:${(e as Error).message || e}`);
}
}
if (shortcuts.length === 0) {
return (
<button
className={styles.emptyBtn}
title="添加你的第一个快捷方式"
onClick={() => setActivePanel("shortcuts")}
disabled={disabled}
>
<Plus size={12} />
</button>
);
}
return (
<>
{shortcuts.map((s) => {
const Icon = getIcon(s.icon);
return (
<button
key={s.id}
className={styles.barBtn}
title={`${s.label}\n${s.type.toUpperCase()}: ${s.target}`}
onClick={() => exec(s)}
onContextMenu={(e) => {
e.preventDefault();
setActivePanel("shortcuts");
}}
disabled={disabled}
>
<Icon size={14} />
<span>{s.label}</span>
</button>
);
})}
<button
className={styles.barAddBtn}
title="管理 / 新增快捷方式"
onClick={() => setActivePanel("shortcuts")}
disabled={disabled}
>
<Plus size={12} />
</button>
</>
);
}
/**
* 展开的"管理快捷方式"面板:列表视图 / 编辑视图(inline 切换)
*/
export function ShortcutsPanel() {
const shortcuts = useAppStore((s) => s.shortcuts);
const removeShortcut = useAppStore((s) => s.removeShortcut);
// null = 列表视图;"new" = 新增;Shortcut = 编辑现有
const [editing, setEditing] = useState<Shortcut | "new" | null>(null);
if (editing) {
return (
<ShortcutEditor
initial={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
/>
);
}
return (
<div className={styles.panel}>
<div className={styles.header}>
<div className={styles.title}>
<Zap size={14} />
<span className={styles.muted}>{shortcuts.length}</span>
</div>
<button className={styles.addBtn} onClick={() => setEditing("new")}>
<Plus size={12} />
</button>
</div>
<div className={styles.list}>
{shortcuts.length === 0 && (
<div className={styles.empty}> + </div>
)}
{shortcuts.map((s) => {
const Icon = getIcon(s.icon);
return (
<div key={s.id} className={styles.row}>
<div className={styles.rowIcon}>
<Icon size={14} />
</div>
<div className={styles.rowMain}>
<div className={styles.rowLabel}>{s.label}</div>
<div className={styles.rowMeta}>
<span className={styles.typeTag}>{s.type}</span>
<span className={styles.target}>{s.target}</span>
</div>
</div>
<div className={styles.rowActions}>
<button
className={styles.iconBtn}
title="编辑"
onClick={() => setEditing(s)}
>
<Pencil size={12} />
</button>
<button
className={clsx(styles.iconBtn, styles.danger)}
title="删除"
onClick={() => removeShortcut(s.id)}
>
<Trash2 size={12} />
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
function ShortcutEditor({
initial,
onClose,
}: {
initial: Shortcut | null;
onClose: () => void;
}) {
const addShortcut = useAppStore((s) => s.addShortcut);
const updateShortcut = useAppStore((s) => s.updateShortcut);
const [label, setLabel] = useState(initial?.label ?? "");
const [icon, setIcon] = useState(initial?.icon ?? "zap");
const [type, setType] = useState<ShortcutType>(initial?.type ?? "url");
const [target, setTarget] = useState(initial?.target ?? "");
const [argsText, setArgsText] = useState((initial?.args ?? []).join(" "));
const [cwd, setCwd] = useState(initial?.cwd ?? "");
const [silent, setSilent] = useState(initial?.silent ?? false);
function save() {
if (!label.trim() || !target.trim()) {
alert("名称和目标都不能为空");
return;
}
const args =
argsText
.trim()
.split(/\s+/)
.filter(Boolean) ?? [];
const data = {
label: label.trim(),
icon,
type,
target: target.trim(),
args,
cwd: cwd.trim() || undefined,
silent: type === "cmd" || type === "powershell" ? silent : undefined,
};
if (initial) updateShortcut(initial.id, data);
else addShortcut(data);
onClose();
}
const placeholderByType: Record<ShortcutType, string> = {
url: "https://example.com",
app: "C:\\Path\\To\\App.exe",
cmd: "echo hello && pause",
powershell: "Get-Process | Select-Object -First 5",
};
return (
<div className={styles.editor}>
<div className={styles.editorHeader}>
<button
className={styles.backBtn}
onClick={onClose}
title="返回列表"
>
<ArrowLeft size={14} />
</button>
<div className={styles.editorTitle}>
{initial ? "编辑快捷方式" : "新增快捷方式"}
</div>
</div>
<div className={styles.editorBody}>
<div className={styles.grid2}>
<div className={styles.field}>
<label></label>
<input
autoFocus
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="按钮显示文本"
/>
</div>
<div className={styles.field}>
<label></label>
<div className={styles.typeRow}>
{(["url", "app", "cmd", "powershell"] as ShortcutType[]).map(
(t) => (
<button
key={t}
className={clsx(
styles.typeBtn,
type === t && styles.typeBtnActive
)}
onClick={() => setType(t)}
>
{t}
</button>
)
)}
</div>
</div>
</div>
<div className={styles.field}>
<label></label>
<input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={placeholderByType[type]}
/>
</div>
{(type === "app" || type === "cmd" || type === "powershell") && (
<>
<div className={styles.grid2}>
{type === "app" ? (
<div className={styles.field}>
<label></label>
<input
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
placeholder="--flag value"
/>
</div>
) : (
<div className={styles.field} />
)}
<div className={styles.field}>
<label></label>
<input
value={cwd}
onChange={(e) => setCwd(e.target.value)}
placeholder="D:\\projects\\app"
/>
</div>
</div>
{(type === "cmd" || type === "powershell") && (
<label className={styles.silentRow}>
<input
type="checkbox"
checked={silent}
onChange={(e) => setSilent(e.target.checked)}
/>
<span></span>
<span className={styles.silentHint}>
便
</span>
</label>
)}
</>
)}
<div className={styles.field}>
<label></label>
<div className={styles.iconGrid}>
{ICON_OPTIONS.map((o) => {
const Icon = o.Icon;
return (
<button
key={o.name}
className={clsx(
styles.iconCell,
icon === o.name && styles.iconCellActive
)}
title={o.name}
onClick={() => setIcon(o.name)}
>
<Icon size={14} />
</button>
);
})}
</div>
</div>
</div>
<div className={styles.editorActions}>
<button className={styles.cancelBtn} onClick={onClose}>
</button>
<button className={styles.saveBtn} onClick={save}>
</button>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import {
Globe,
Github,
Code,
Terminal,
FileText,
Hammer,
Play,
Folder,
Music,
Image,
Mail,
Search,
Zap,
Star,
Heart,
Bookmark,
Settings,
Chrome,
Cpu,
Bot,
Box,
Cloud,
Coffee,
Calendar,
Send,
Download,
Power,
type LucideIcon,
} from "lucide-react";
/** 可在编辑器中选择的图标白名单(保持小而精,避免引入全量图标) */
export const ICON_OPTIONS: { name: string; Icon: LucideIcon }[] = [
{ name: "globe", Icon: Globe },
{ name: "github", Icon: Github },
{ name: "code", Icon: Code },
{ name: "terminal", Icon: Terminal },
{ name: "file-text", Icon: FileText },
{ name: "hammer", Icon: Hammer },
{ name: "play", Icon: Play },
{ name: "folder", Icon: Folder },
{ name: "music", Icon: Music },
{ name: "image", Icon: Image },
{ name: "mail", Icon: Mail },
{ name: "search", Icon: Search },
{ name: "zap", Icon: Zap },
{ name: "star", Icon: Star },
{ name: "heart", Icon: Heart },
{ name: "bookmark", Icon: Bookmark },
{ name: "settings", Icon: Settings },
{ name: "chrome", Icon: Chrome },
{ name: "cpu", Icon: Cpu },
{ name: "bot", Icon: Bot },
{ name: "box", Icon: Box },
{ name: "cloud", Icon: Cloud },
{ name: "coffee", Icon: Coffee },
{ name: "calendar", Icon: Calendar },
{ name: "send", Icon: Send },
{ name: "download", Icon: Download },
{ name: "power", Icon: Power },
];
const MAP: Record<string, LucideIcon> = ICON_OPTIONS.reduce(
(acc, o) => ({ ...acc, [o.name]: o.Icon }),
{} as Record<string, LucideIcon>
);
/** 根据 icon 名取组件,找不到时回退到 Zap */
export function getIcon(name: string | undefined): LucideIcon {
if (!name) return Zap;
return MAP[name] ?? Zap;
}
@@ -0,0 +1,88 @@
.root {
width: fit-content;
min-width: 180px;
max-width: 420px;
padding: 6px 10px 8px;
pointer-events: none; /* 列表区域不拦截鼠标,穿透到下层 */
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
pointer-events: none;
}
/* 仅钉子按钮可接收点击(配合 Rust 热区轮询) */
.pinBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
color: rgb(var(--accent));
background: rgba(var(--accent), 0.25);
border: 1px solid rgba(var(--accent), 0.45);
pointer-events: auto;
flex-shrink: 0;
transition: transform 0.12s ease, background 0.12s;
}
.pinBtn:hover {
background: rgba(var(--accent), 0.4);
transform: scale(1.05);
}
.pinBtnActive {
box-shadow: 0 0 10px rgba(var(--accent), 0.45);
}
.label {
font-size: 12px;
font-weight: 600;
color: rgba(var(--fg-color), 0.75);
letter-spacing: 0.3px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
}
.item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 6px;
border-radius: var(--radius-sm);
}
.dot {
width: 6px;
height: 6px;
margin-top: 7px;
border-radius: 50%;
background: rgb(var(--accent));
flex-shrink: 0;
opacity: 0.9;
}
.title {
font-size: 15px;
font-weight: 500;
line-height: 1.45;
color: rgba(var(--fg-color), 0.92);
word-break: break-word;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}
.empty {
font-size: 13px;
color: rgba(var(--fg-dim), 0.7);
padding: 8px 6px;
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import clsx from "clsx";
import { Pin } from "lucide-react";
import { useAppStore } from "../../store/useAppStore";
import { setPinMode, setWindowSize, updatePinHotzone } from "../../api/tauri";
import styles from "./PinnedTasksOverlay.module.css";
const MIN_W = 200;
const MAX_W = 420;
const ROW_H = 28;
const CHROME_H = 44;
/**
* 任务钉子模式:极透明浮层,只列出未完成任务;窗口默认点击穿透,
* 鼠标移到钉子按钮上才可点击退出。
*/
export function PinnedTasksOverlay() {
const tasks = useAppStore((s) => s.tasks);
const toggleTaskPinMode = useAppStore((s) => s.toggleTaskPinMode);
const rootRef = useRef<HTMLDivElement>(null);
const pinRef = useRef<HTMLButtonElement>(null);
const activeTasks = tasks
.filter((t) => !t.done)
.sort((a, b) => a.order - b.order);
// 进入/退出时同步系统点击穿透与窗口尺寸
useEffect(() => {
document.documentElement.classList.add("task-pin-mode");
void setPinMode(true);
return () => {
document.documentElement.classList.remove("task-pin-mode");
void setPinMode(false);
void updatePinHotzone(0, 0, 0, 0, false);
};
}, []);
const syncLayout = () => {
const root = rootRef.current;
if (!root) return;
const w = Math.min(MAX_W, Math.max(MIN_W, root.scrollWidth + 16));
const h = CHROME_H + Math.max(activeTasks.length, 1) * ROW_H + 8;
void setWindowSize(w, h);
const pin = pinRef.current;
if (pin) {
const r = pin.getBoundingClientRect();
void updatePinHotzone(r.left, r.top, r.width, r.height, true);
}
};
useLayoutEffect(() => {
syncLayout();
}, [activeTasks.length, activeTasks.map((t) => t.title).join("|")]);
useEffect(() => {
const ro = new ResizeObserver(() => syncLayout());
if (rootRef.current) ro.observe(rootRef.current);
const t = window.setInterval(syncLayout, 500);
return () => {
ro.disconnect();
window.clearInterval(t);
};
}, [activeTasks.length]);
return (
<div className={styles.root} ref={rootRef}>
<div className={styles.header}>
<button
ref={pinRef}
type="button"
className={clsx(styles.pinBtn, styles.pinBtnActive)}
title="取消钉子模式"
onClick={() => toggleTaskPinMode()}
>
<Pin size={16} fill="currentColor" />
</button>
<span className={styles.label}> {activeTasks.length}</span>
</div>
<ul className={styles.list}>
{activeTasks.length === 0 ? (
<li className={styles.empty}></li>
) : (
activeTasks.map((t) => (
<li key={t.id} className={styles.item}>
<span className={styles.dot} />
<span className={styles.title}>{t.title}</span>
</li>
))
)}
</ul>
</div>
);
}
+336
View File
@@ -0,0 +1,336 @@
.triggerGroup {
display: inline-flex;
align-items: center;
gap: 2px;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
height: 30px;
border-radius: var(--radius-md);
color: rgb(var(--fg-color));
transition: background 0.12s;
position: relative;
}
.pinTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
color: rgba(var(--fg-dim), 0.9);
transition: all 0.12s;
}
.pinTrigger:hover:not(:disabled) {
background: rgba(var(--border), 0.1);
color: rgb(var(--fg-color));
}
.pinTriggerOn {
color: rgb(var(--accent));
background: rgba(var(--accent), 0.2);
}
.pinTrigger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.trigger:hover {
background: rgba(var(--border), 0.08);
}
.triggerActive {
background: rgba(var(--accent), 0.18);
color: rgb(var(--accent));
}
.trigger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.trigger:disabled:hover {
background: transparent;
}
.badge {
background: rgba(var(--accent), 0.25);
color: rgb(var(--accent));
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
min-width: 16px;
text-align: center;
}
.panel {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
/* 固定高度:避免 active/done tab 切换时列表长度变化引发窗口尺寸抖动 */
height: 320px;
}
/* ============ Tabs ============ */
.tabs {
display: flex;
align-items: center;
gap: 4px;
padding-bottom: 6px;
position: relative;
border-bottom: 1px solid rgba(var(--border), 0.06);
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius-sm);
color: rgba(var(--fg-dim), 0.95);
font-size: 13px;
background: transparent;
position: relative;
z-index: 1;
transition: color 0.18s ease;
}
.tab:hover {
color: rgb(var(--fg-color));
}
.tab:active {
transform: scale(0.96);
transition: transform 0.08s ease, color 0.18s ease;
}
.tabActive {
color: rgb(var(--accent));
font-weight: 600;
}
/* 底部滑块指示器:跟随激活 tab 平滑滑动 */
.tabIndicator {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background: rgb(var(--accent));
border-radius: 2px;
transition: transform 0.28s cubic-bezier(0.4, 0.0, 0.2, 1),
width 0.28s cubic-bezier(0.4, 0.0, 0.2, 1);
pointer-events: none;
box-shadow: 0 0 6px rgba(var(--accent), 0.4);
}
.tabCount {
background: rgba(var(--border), 0.12);
color: rgba(var(--fg-color), 0.9);
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
min-width: 18px;
text-align: center;
line-height: 14px;
transition: background 0.18s ease, color 0.18s ease;
}
.tabActive .tabCount {
background: rgba(var(--accent), 0.3);
color: rgb(var(--accent));
}
/* 未激活的「已完成」用黄色脉冲气泡提醒待清理;激活后回归 accent 配色,
避免颜色被静态徽章吃掉,导致切换"看不出动效" */
.tabCountAlert {
background: rgba(var(--warning), 0.25);
color: rgb(var(--warning));
animation: bubble 1.6s ease-in-out infinite;
}
.tabActive .tabCountAlert {
background: rgba(var(--accent), 0.3);
color: rgb(var(--accent));
animation: none;
}
@keyframes bubble {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.clearBtn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: var(--radius-sm);
background: rgba(var(--danger), 0.12);
color: rgb(var(--danger));
font-size: 12px;
transition: all 0.12s;
}
.clearBtn:hover {
background: rgba(var(--danger), 0.22);
}
/* ============ Input ============ */
.inputRow {
display: flex;
gap: 6px;
align-items: stretch;
}
.inputRow input {
flex: 1;
height: 36px;
min-width: 0;
padding: 6px 14px;
font-size: 14px;
}
.addBtn {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: rgba(var(--accent), 0.18);
color: rgb(var(--accent));
flex-shrink: 0;
}
.addBtn:hover {
background: rgba(var(--accent), 0.28);
}
/* ============ List ============ */
.list {
flex: 1;
min-height: 0; /* flex 子项允许收缩,配合 overflow-y 滚动 */
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 3px;
padding-right: 2px;
}
.empty {
text-align: center;
color: rgba(var(--fg-dim), 0.7);
font-size: 13px;
padding: 24px 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px;
border-radius: var(--radius-sm);
transition: background 0.12s;
}
.row:hover {
background: rgba(var(--border), 0.04);
}
.rowDone .title2 {
color: rgba(var(--fg-dim), 0.55);
text-decoration: line-through;
}
.grip {
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(var(--fg-dim), 0.5);
opacity: 0;
cursor: grab;
width: 18px;
height: 22px;
}
.row:hover .grip {
opacity: 1;
}
.grip:active {
cursor: grabbing;
}
.check {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
}
.check input {
opacity: 0;
position: absolute;
inset: 0;
cursor: pointer;
}
.check span {
width: 16px;
height: 16px;
border: 1.5px solid rgba(var(--border), 0.3);
border-radius: 4px;
display: inline-block;
position: relative;
transition: all 0.15s;
}
.check input:checked + span {
background: rgb(var(--accent));
border-color: rgb(var(--accent));
}
.check input:checked + span::after {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.title2 {
flex: 1;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
cursor: text;
user-select: text;
color: rgb(var(--fg-color));
}
.editInput {
flex: 1;
height: 28px;
font-size: 14px;
padding: 2px 8px;
}
.actions {
display: inline-flex;
gap: 2px;
opacity: 0;
transition: opacity 0.12s;
}
.row:hover .actions {
opacity: 1;
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
color: rgba(var(--fg-dim), 0.9);
transition: all 0.12s;
}
.iconBtn:hover {
background: rgba(var(--border), 0.1);
color: rgb(var(--fg-color));
}
.danger:hover {
background: rgba(var(--danger), 0.18);
color: rgb(var(--danger));
}
+367
View File
@@ -0,0 +1,367 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import clsx from "clsx";
import {
CheckSquare,
Plus,
Trash2,
Pencil,
Trash,
GripHorizontal,
ListTodo,
CheckCircle2,
Pin,
} from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAppStore } from "../../store/useAppStore";
import type { Task } from "../../store/types";
import styles from "./TasksPanel.module.css";
type View = "active" | "done";
/**
* 工具栏上的"任务"按钮(含未完成徽标)
*/
export function TasksTrigger({
active,
onClick,
disabled = false,
}: {
active: boolean;
onClick: () => void;
disabled?: boolean;
}) {
const pending = useAppStore((s) => s.tasks.filter((t) => !t.done).length);
const taskPinMode = useAppStore((s) => s.taskPinMode);
const toggleTaskPinMode = useAppStore((s) => s.toggleTaskPinMode);
return (
<div className={styles.triggerGroup}>
<button
className={clsx(styles.trigger, active && styles.triggerActive)}
title={disabled ? "已锁定" : "任务列表"}
onClick={onClick}
disabled={disabled}
>
<CheckSquare size={16} />
<span></span>
{pending > 0 && <span className={styles.badge}>{pending}</span>}
</button>
<button
type="button"
className={clsx(styles.pinTrigger, taskPinMode && styles.pinTriggerOn)}
title="钉子模式:透明悬浮,仅显示未完成任务,点击穿透下层"
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
if (!disabled) toggleTaskPinMode();
}}
>
<Pin size={14} />
</button>
</div>
);
}
/**
* 展开的任务面板:active(未完成) / done(已完成) 两个视图切换
* - active:输入框 + 未完成列表(默认)
* - done:已完成列表 + 一键清除
*/
export function TasksPanel() {
const tasks = useAppStore((s) => s.tasks);
const addTask = useAppStore((s) => s.addTask);
const reorderTasks = useAppStore((s) => s.reorderTasks);
const clearDoneTasks = useAppStore((s) => s.clearDoneTasks);
const [view, setViewRaw] = useState<View>("active");
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState("");
// 底部滑块动画:测量当前激活 tab 的位置/宽度,给滑块设 transform + width
const tabsRef = useRef<HTMLDivElement>(null);
const [indicator, setIndicator] = useState({ left: 0, width: 0 });
useLayoutEffect(() => {
if (!tabsRef.current) return;
const el = tabsRef.current.querySelector(
`[data-tab="${view}"]`
) as HTMLElement | null;
if (!el) return;
setIndicator({ left: el.offsetLeft, width: el.offsetWidth });
}, [view, tasks.length]); // tasks 变化会改变徽标宽度,需要重测
/** 幂等切换:当前已是目标 view 时直接 return,避免重复点击触发任何 re-render/动画 */
const setView = (next: View) => {
if (next === view) return;
setViewRaw(next);
};
// 响应全局快捷键的聚焦事件,并自动切回 active 视图
useEffect(() => {
const handler = () => {
setViewRaw("active");
setTimeout(() => inputRef.current?.focus(), 0);
};
window.addEventListener("deskfloat:focus-new-task", handler);
if (view === "active") inputRef.current?.focus();
return () =>
window.removeEventListener("deskfloat:focus-new-task", handler);
}, [view]);
const activeTasks = useMemo(
() => tasks.filter((t) => !t.done).sort((a, b) => a.order - b.order),
[tasks]
);
const doneTasks = useMemo(
() => tasks.filter((t) => t.done).sort((a, b) => b.createdAt - a.createdAt),
[tasks]
);
const visibleTasks = view === "active" ? activeTasks : doneTasks;
const ids = useMemo(() => visibleTasks.map((t) => t.id), [visibleTasks]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
/** 仅 active 视图允许拖拽排序 */
function onDragEnd(e: DragEndEvent) {
if (view !== "active") return;
const { active, over } = e;
if (!over || active.id === over.id) return;
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex < 0 || newIndex < 0) return;
// 仅对未完成任务重排序,已完成位置保持不变
const newActiveIds = arrayMove(ids, oldIndex, newIndex);
const doneIds = doneTasks.map((t) => t.id);
reorderTasks([...newActiveIds, ...doneIds]);
}
function onSubmit() {
addTask(draft);
setDraft("");
inputRef.current?.focus();
}
function onKey(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") onSubmit();
}
function handleClearDone() {
if (doneTasks.length === 0) return;
if (window.confirm(`确认清除 ${doneTasks.length} 条已完成任务?`)) {
clearDoneTasks();
// 清完跳回 active
setView("active");
}
}
return (
<div className={styles.panel}>
<div className={styles.tabs} ref={tabsRef}>
<button
data-tab="active"
className={clsx(styles.tab, view === "active" && styles.tabActive)}
onClick={() => setView("active")}
>
<ListTodo size={14} />
<span></span>
<span className={styles.tabCount}>{activeTasks.length}</span>
</button>
<button
data-tab="done"
className={clsx(styles.tab, view === "done" && styles.tabActive)}
onClick={() => setView("done")}
>
<CheckCircle2 size={14} />
<span></span>
{doneTasks.length > 0 && (
<span
className={clsx(styles.tabCount, styles.tabCountAlert)}
title="待清理"
>
{doneTasks.length}
</span>
)}
</button>
{/* 底部滑块指示器:transform 平滑滑动 */}
<div
className={styles.tabIndicator}
style={{
transform: `translateX(${indicator.left}px)`,
width: indicator.width,
}}
/>
{view === "done" && doneTasks.length > 0 && (
<button
className={styles.clearBtn}
onClick={handleClearDone}
title="一键清除所有已完成任务"
>
<Trash size={12} />
<span></span>
</button>
)}
</div>
{view === "active" && (
<div className={styles.inputRow}>
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKey}
placeholder="新增任务(回车保存)"
/>
<button className={styles.addBtn} onClick={onSubmit} title="新增">
<Plus size={16} />
</button>
</div>
)}
<div className={styles.list}>
{visibleTasks.length === 0 ? (
<div className={styles.empty}>
{view === "active"
? "暂无进行中的任务,回车快速新增 ↑"
: "还没有完成的任务,加油 💪"}
</div>
) : view === "active" ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{visibleTasks.map((t) => (
<TaskRow key={t.id} task={t} sortable />
))}
</SortableContext>
</DndContext>
) : (
visibleTasks.map((t) => <TaskRow key={t.id} task={t} sortable={false} />)
)}
</div>
</div>
);
}
/** 单行任务渲染。sortable=false 时去掉拖拽柄 */
function TaskRow({ task, sortable }: { task: Task; sortable: boolean }) {
const toggleTask = useAppStore((s) => s.toggleTask);
const removeTask = useAppStore((s) => s.removeTask);
const updateTask = useAppStore((s) => s.updateTask);
const [editing, setEditing] = useState(false);
const [text, setText] = useState(task.title);
const sortableHook = useSortable({ id: task.id, disabled: !sortable });
const style = sortable
? {
transform: CSS.Transform.toString(sortableHook.transform),
transition: sortableHook.transition,
opacity: sortableHook.isDragging ? 0.6 : 1,
}
: {};
function commit() {
if (text.trim() && text !== task.title) updateTask(task.id, text);
setEditing(false);
}
return (
<div
ref={sortable ? sortableHook.setNodeRef : undefined}
style={style}
className={clsx(styles.row, task.done && styles.rowDone)}
>
{sortable && (
<button
className={styles.grip}
{...sortableHook.attributes}
{...sortableHook.listeners}
title="拖动排序"
>
<GripHorizontal size={12} />
</button>
)}
<label className={styles.check}>
<input
type="checkbox"
checked={task.done}
onChange={() => toggleTask(task.id)}
/>
<span />
</label>
{editing ? (
<input
autoFocus
className={styles.editInput}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setText(task.title);
setEditing(false);
}
}}
/>
) : (
<div
className={styles.title2}
onDoubleClick={() => setEditing(true)}
title="双击编辑"
>
{task.title}
</div>
)}
<div className={styles.actions}>
{!task.done && (
<button
className={styles.iconBtn}
onClick={() => setEditing((v) => !v)}
title="编辑"
>
<Pencil size={12} />
</button>
)}
<button
className={clsx(styles.iconBtn, styles.danger)}
onClick={() => removeTask(task.id)}
title="删除"
>
<Trash2 size={12} />
</button>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/** 任务条目 */
export interface Task {
id: string;
title: string;
done: boolean;
createdAt: number;
order: number;
}
/** 快捷方式四种执行类型 */
export type ShortcutType = "url" | "app" | "cmd" | "powershell";
/** 快捷按钮 */
export interface Shortcut {
id: string;
label: string;
icon: string; // lucide-react 图标名(kebab-case 或 PascalCase 均可,我们统一存 kebab
type: ShortcutType;
target: string;
args?: string[];
cwd?: string;
/** 静默执行(仅 cmd/powershell 生效):true 隐藏控制台窗口,false 显示新控制台便于查看输出 */
silent?: boolean;
}
/** 番茄钟配置 */
export interface PomodoroConfig {
workMin: number;
breakMin: number;
soundOn: boolean;
}
/** 全局快捷键映射 */
export interface GlobalHotkeys {
newTask: string;
togglePomodoro: string;
showHide: string;
}
/** 通用设置 */
export interface Settings {
alwaysOnTop: boolean;
opacity: number;
globalHotkeys: GlobalHotkeys;
autoStart: boolean;
/** 仅悬浮锁定模式:true 时屏蔽所有交互按钮,避免误触;只能从锁按钮本身解锁 */
locked: boolean;
}
/** 整个 data.json 的根结构 */
export interface AppState {
tasks: Task[];
shortcuts: Shortcut[];
pomodoro: PomodoroConfig;
settings: Settings;
}
export const DEFAULT_STATE: AppState = {
tasks: [],
shortcuts: [],
pomodoro: { workMin: 25, breakMin: 5, soundOn: true },
settings: {
alwaysOnTop: true,
opacity: 0.85,
globalHotkeys: {
newTask: "Ctrl+Alt+T",
togglePomodoro: "Ctrl+Alt+P",
showHide: "Ctrl+Alt+`",
},
autoStart: false,
locked: false,
},
};
+229
View File
@@ -0,0 +1,229 @@
import { create } from "zustand";
import { loadState, saveState } from "../api/tauri";
import {
AppState,
DEFAULT_STATE,
PomodoroConfig,
Settings,
Shortcut,
Task,
} from "./types";
/**
* 全局应用状态:所有持久化字段集中在这里。
* 任何写入操作都会触发一次 debounce 的整体保存到 JSON 文件。
*/
interface Store extends AppState {
ready: boolean;
activePanel: "tasks" | "shortcuts" | "pomodoro" | "settings" | null;
collapsed: boolean;
/** 任务钉子模式:透明穿透 overlay,仅展示未完成任务 */
taskPinMode: boolean;
// lifecycle
hydrate: () => Promise<void>;
// ui
setActivePanel: (p: Store["activePanel"]) => void;
toggleCollapsed: () => void;
toggleTaskPinMode: () => void;
// tasks
addTask: (title: string) => void;
toggleTask: (id: string) => void;
updateTask: (id: string, title: string) => void;
removeTask: (id: string) => void;
reorderTasks: (orderedIds: string[]) => void;
clearDoneTasks: () => void;
// shortcuts
addShortcut: (s: Omit<Shortcut, "id">) => void;
updateShortcut: (id: string, patch: Partial<Shortcut>) => void;
removeShortcut: (id: string) => void;
reorderShortcuts: (orderedIds: string[]) => void;
// pomodoro config
updatePomodoro: (patch: Partial<PomodoroConfig>) => void;
// settings
updateSettings: (patch: Partial<Settings>) => void;
}
const newId = () =>
globalThis.crypto?.randomUUID?.() ??
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
let saveTimer: number | null = null;
/** 把当前 state 落盘(防抖 300ms */
function scheduleSave(getSnapshot: () => AppState) {
if (saveTimer) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(async () => {
const snap = getSnapshot();
try {
await saveState(JSON.stringify(snap, null, 2));
} catch (e) {
console.error("[DeskFloat] save failed", e);
}
}, 300);
}
export const useAppStore = create<Store>((set, get) => {
/** 取出仅持久化字段 */
const snapshot = (): AppState => {
const s = get();
return {
tasks: s.tasks,
shortcuts: s.shortcuts,
pomodoro: s.pomodoro,
settings: s.settings,
};
};
const persist = () => scheduleSave(snapshot);
return {
...DEFAULT_STATE,
ready: false,
activePanel: null,
collapsed: false,
taskPinMode: false,
/** 启动时从 Rust 后端读取 JSON */
async hydrate() {
try {
const json = await loadState();
const parsed = JSON.parse(json) as Partial<AppState>;
set({
tasks: parsed.tasks ?? [],
shortcuts: parsed.shortcuts ?? [],
pomodoro: { ...DEFAULT_STATE.pomodoro, ...(parsed.pomodoro ?? {}) },
settings: {
...DEFAULT_STATE.settings,
...(parsed.settings ?? {}),
globalHotkeys: {
...DEFAULT_STATE.settings.globalHotkeys,
...((parsed.settings as Settings | undefined)?.globalHotkeys ?? {}),
},
},
ready: true,
});
} catch (e) {
console.error("[DeskFloat] hydrate failed", e);
set({ ready: true });
}
},
setActivePanel(p) {
set({ activePanel: get().activePanel === p ? null : p });
},
toggleCollapsed() {
set({ collapsed: !get().collapsed, activePanel: null });
},
/** 切换任务钉子模式(进入时关闭抽屉) */
toggleTaskPinMode() {
const next = !get().taskPinMode;
set({
taskPinMode: next,
activePanel: next ? null : get().activePanel,
collapsed: false,
});
},
// ============= tasks =============
addTask(title) {
const t = title.trim();
if (!t) return;
const max = get().tasks.reduce((m, x) => Math.max(m, x.order), -1);
const task: Task = {
id: newId(),
title: t,
done: false,
createdAt: Date.now(),
order: max + 1,
};
set({ tasks: [...get().tasks, task] });
persist();
},
toggleTask(id) {
set({
tasks: get().tasks.map((x) => (x.id === id ? { ...x, done: !x.done } : x)),
});
persist();
},
updateTask(id, title) {
const t = title.trim();
if (!t) return;
set({
tasks: get().tasks.map((x) => (x.id === id ? { ...x, title: t } : x)),
});
persist();
},
removeTask(id) {
set({ tasks: get().tasks.filter((x) => x.id !== id) });
persist();
},
reorderTasks(orderedIds) {
const map = new Map(get().tasks.map((t) => [t.id, t]));
const next: Task[] = [];
orderedIds.forEach((id, idx) => {
const t = map.get(id);
if (t) next.push({ ...t, order: idx });
});
set({ tasks: next });
persist();
},
clearDoneTasks() {
set({ tasks: get().tasks.filter((x) => !x.done) });
persist();
},
// ============= shortcuts =============
addShortcut(s) {
set({ shortcuts: [...get().shortcuts, { ...s, id: newId() }] });
persist();
},
updateShortcut(id, patch) {
set({
shortcuts: get().shortcuts.map((x) => (x.id === id ? { ...x, ...patch } : x)),
});
persist();
},
removeShortcut(id) {
set({ shortcuts: get().shortcuts.filter((x) => x.id !== id) });
persist();
},
reorderShortcuts(orderedIds) {
const map = new Map(get().shortcuts.map((s) => [s.id, s]));
const next: Shortcut[] = [];
orderedIds.forEach((id) => {
const s = map.get(id);
if (s) next.push(s);
});
set({ shortcuts: next });
persist();
},
// ============= pomodoro config =============
updatePomodoro(patch) {
set({ pomodoro: { ...get().pomodoro, ...patch } });
persist();
},
// ============= settings =============
updateSettings(patch) {
const cur = get().settings;
const next: Settings = {
...cur,
...patch,
globalHotkeys: {
...cur.globalHotkeys,
...(patch.globalHotkeys ?? {}),
},
};
set({ settings: next });
persist();
},
};
});
+91
View File
@@ -0,0 +1,91 @@
:root {
--bg-opacity: 0.85;
--bg-color: 22, 22, 28;
--fg-color: 235, 235, 240;
--fg-dim: 165, 165, 175;
--accent: 138, 180, 248;
--accent-strong: 99, 145, 224;
--danger: 240, 100, 100;
--success: 86, 192, 130;
--warning: 240, 180, 80;
--border: 255, 255, 255;
--radius-lg: 14px;
--radius-md: 10px;
--radius-sm: 6px;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
color-scheme: dark;
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
font-size: 13px;
color: rgb(var(--fg-color));
-webkit-user-select: none;
user-select: none;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent !important;
overflow: hidden;
}
body {
/* 整个窗口背景完全透明,由 Toolbar 自身画背景 */
}
/* 任务钉子模式:整体更透明,列表区穿透由 Rust ignore_cursor_events 处理 */
html.task-pin-mode {
--bg-opacity: 0.12;
}
html.task-pin-mode body,
html.task-pin-mode #root {
background: transparent !important;
}
button {
font-family: inherit;
font-size: inherit;
color: inherit;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
}
input,
textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(var(--border), 0.08);
border-radius: var(--radius-sm);
padding: 6px 10px;
outline: none;
user-select: text;
}
input:focus,
textarea:focus {
border-color: rgba(var(--accent), 0.6);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module "*.module.css" {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module "*.css";
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/tauri.ts","./src/components/toolbar/toolbar.tsx","./src/hooks/usehotkeys.ts","./src/modules/pomodoro/pomodoropanel.tsx","./src/modules/pomodoro/pomodorostore.ts","./src/modules/settings/settingspanel.tsx","./src/modules/shortcuts/shortcutspanel.tsx","./src/modules/shortcuts/iconmap.tsx","./src/modules/tasks/pinnedtasksoverlay.tsx","./src/modules/tasks/taskspanel.tsx","./src/store/types.ts","./src/store/useappstore.ts"],"version":"5.9.3"}

Some files were not shown because too many files have changed in this diff Show More