Initial commit: DeskFloat 桌面透明浮动工具栏
Tauri 2 + React:任务列表、快捷方式、番茄钟、系统托盘、钉子穿透模式等。 Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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
|
||||
@@ -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*
|
||||
@@ -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 CSS(gzip 后 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`)
|
||||
- 番茄钟"专注次数"统计(用户在初版选择不要)
|
||||
- 快捷方式拖拽排序
|
||||
- 多主题切换(浅色 / 高对比)
|
||||
- 多窗口/多个独立工具栏布局
|
||||
@@ -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.x(Rust 主进程 + 系统 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**,需安装 WebView2(Win10 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`,修改后会重新注册
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 411 KiB |
@@ -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/powershell;url 由前端 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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 防止 Windows release 构建时弹出 cmd 窗口
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
desk_top_float_lib::run()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "包含任务列表、自定义快捷按钮、番茄钟的桌面浮动工具栏"
|
||||
}
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.module.css" {
|
||||
const classes: { readonly [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module "*.css";
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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"}
|
||||