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"}
|
||||||