init: Sprout desktop app launcher
Co-authored-by: Cursor <cursoragent@cursor.com>
13
.cursor/mcp.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"CursorMCP对话插件": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"c:\\Users\\smy\\.cursor\\extensions\\local.xw-cursor-message-8.8.8\\dist\\mcp-server.mjs"
|
||||
],
|
||||
"env": {
|
||||
"MESSENGER_DATA_DIR": "C:\\Users\\smy\\.moyu-message\\9e41e012cfd2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
.cursor/rules/mcp-messenger.mdc
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: "自动调用 Messenger MCP 检查待发送消息"
|
||||
globs: "**/*"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# MCP 工具调用规则
|
||||
|
||||
1. 每轮对话回复**完整输出后**,最后一步必须调用 `check_messages`,适用于 Agent/Plan/Debug/Ask 所有模式,无一例外
|
||||
2. `check_messages` 会持续等待直到用户发来新消息;收到后处理并回复,然后再次调用 `check_messages`,形成永续循环:回复 → check_messages → 收到消息 → 回复 → check_messages
|
||||
3. 严禁在回合开始时或回复未完成时调用 `check_messages`;唯一豁免:用户明确要求结束对话
|
||||
4. Plan 模式写完方案后、Debug 模式分析完问题后、Ask 模式回答完问题后,均不得直接结束对话,必须调用 `check_messages` 等待用户反馈
|
||||
5. 需要用户做选择或确认时(选方案、选框架、确认操作等),必须调用 `ask_question`;严禁使用 Cursor 内置的 ask_user_question
|
||||
6. `ask_question` 参数格式: `{ "questions": [{ "question": "问题文本", "options": [{ "id": "a", "label": "选项A" }, { "id": "b", "label": "选项B" }], "allow_multiple": false }] }`
|
||||
7. 单选: `allow_multiple: false`(用户只能选一个);多选: `allow_multiple: true`(用户可选多个);同一 `questions` 数组可混合包含单选题和多选题
|
||||
8. 用户在每道题中均可额外输入自定义补充文本(Other 输入框),无需为此添加额外选项
|
||||
9. `ask_question` 收到用户回答后,处理完毕仍需调用 `check_messages` 继续监听
|
||||
10. 禁止在每轮结束时用 `ask_question` 问"还有别的需要吗",回合结束只用 `check_messages`;`ask_question` 仅限任务中确实需要用户决策时使用
|
||||
11. `check_messages` 支持可选参数 `reply`(string,Markdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "reply": "## 结论\n修改完成..." }`
|
||||
12. 当会话结束前,建议在调用 `check_messages` 时附带 `reply` 参数,将关键结论推送到插件界面;如果本轮无需特别总结,`reply` 可省略
|
||||
13. 禁止调用subagent并发任务
|
||||
14. 如果 `check_messages` 或 `ask_question` 因客户端取消、连接抖动或内部异常而意外返回/报错,且用户未明确要求结束对话,严禁把这类内部提示直接输出给用户,必须立刻再次调用对应工具继续等待
|
||||
15. 执行多步任务时,每完成一个关键步骤后,必须调用 `send_progress` 推送进度摘要到远程控制台。格式: `{ "progress": "## 步骤1完成\n已修改xxx文件..." }`。`send_progress` 立即返回,不会等待消息,调用后继续执行下一步
|
||||
16. `send_progress` 的 `progress` 参数应包含:已完成的操作、当前方案、下一步计划。使用简洁的 Markdown 格式
|
||||
|
||||
---
|
||||
**回合结束(强制)**: 仅当回复**完整输出完毕后**,最后一步必须调用 CursorMCP对话插件 MCP 的 `check_messages` 工具(使用 CallMcpTool,server 为 CursorMCP对话插件 对应的 identifier,toolName 为 "check_messages",arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。
|
||||
18
.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Tauri / Rust
|
||||
src-tauri/target/
|
||||
src-tauri/gen/schemas/
|
||||
|
||||
# Local / editor
|
||||
.claude/
|
||||
41
AGENTS.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- `src/`: React renderer (UI). Main entry is `src/main.tsx`, app shell in `src/App.tsx`, shared types in `src/types.ts`, and UI pieces in `src/components/`.
|
||||
- `electron/`: Electron main + preload (`electron/main.ts`, `electron/preload.ts`).
|
||||
- `public/`: Static assets bundled with the renderer (for example `public/favicon.ico`, `public/logo.png`).
|
||||
- `dist/`, `dist-electron/`: Build artifacts (do not hand-edit).
|
||||
- `release/`: Packaged installers output by `electron-builder` (versioned subfolders).
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `npm install`: Install dependencies.
|
||||
- `npm run dev`: Run the Vite dev server (with `--host`) and Electron via `vite-plugin-electron`.
|
||||
- `npm run lint`: Run ESLint with `--max-warnings 0` (lint must be clean).
|
||||
- `npm run build`: `tsc && vite build && electron-builder` (creates `dist*` and writes installers to `release/<version>/`).
|
||||
- `npm run preview`: Preview the built renderer (useful for renderer-only checks).
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Language: TypeScript + React, ESM (`"type": "module"`). TypeScript is `strict` and enables unused code checks; keep changes type-safe.
|
||||
- Indentation: 2 spaces (match existing files).
|
||||
- Components: `PascalCase.tsx` in `src/components/` (for example `AppCard.tsx`). Types/interfaces stay in `src/types.ts` unless clearly feature-specific.
|
||||
- Linting: ESLint (`eslint:recommended`, `@typescript-eslint/recommended`, `react-hooks/recommended`, plus `react-refresh` rule).
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- No automated test framework is currently configured (no `npm test`, no coverage gate). Changes should include manual smoke testing notes in PRs.
|
||||
- Minimum manual checks: add/edit/delete apps, category CRUD, config persistence across restart, icon extraction, window controls (min/max/close).
|
||||
- If you introduce tests, prefer `*.spec.ts(x)` colocated under `src/` (or `src/**/__tests__/`) and add a `test` script.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Git history is not available in this workspace, so no repository-specific commit convention can be derived. Use Conventional Commits (for example `feat: add search`, `fix: handle missing exe`, `chore: bump deps`).
|
||||
- PRs: include a short summary, testing steps, and screenshots/GIFs for UI changes. Link issues when applicable.
|
||||
- Keep build artifacts and packaged outputs out of PRs unless intentionally updating release assets.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- User configuration lives at `%APPDATA%/sprout-launcher/launcher-config.json`; never commit local config data.
|
||||
- If you change the config schema, add a small migration path and document it in the PR.
|
||||
92
CLAUDE.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Sprout Launcher (萌芽桌面启动器)** is a Windows desktop application launcher built with **Tauri 2 + React 18 + TypeScript**. Users add apps to a categorized grid, launch them from a single window, and manage shortcuts/icons.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # Install JS dependencies
|
||||
npm run tauri dev # Start Vite dev server + Tauri (hot reload)
|
||||
npm run lint # ESLint — must pass with zero warnings
|
||||
npm run tauri build # tsc && vite build && Tauri bundler → src-tauri/target/release/bundle/
|
||||
```
|
||||
|
||||
No automated test framework is configured. Manual smoke testing: add/edit/delete apps, category CRUD, config persistence across restart, icon extraction, window controls.
|
||||
|
||||
## Architecture
|
||||
|
||||
The app uses Tauri 2's two-process model:
|
||||
|
||||
```
|
||||
Renderer (React UI in src/)
|
||||
↕ invoke() / listen() via @tauri-apps/api
|
||||
Rust backend (src-tauri/src/lib.rs)
|
||||
↕ Native APIs
|
||||
Windows OS (filesystem, registry, shell, PowerShell)
|
||||
```
|
||||
|
||||
**Key source files:**
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/App.tsx` | Master state container — config, drag-drop, dialog orchestration |
|
||||
| `src/types.ts` | Shared TypeScript interfaces (`App`, `Config`, `Category`) |
|
||||
| `src/components/AppCard.tsx` | Memoized app tile |
|
||||
| `src/components/AddAppDialog.tsx` | Add/edit app modal — calls `invoke()` for file pickers |
|
||||
| `src/components/GridWithFill.tsx` | Responsive grid using ResizeObserver |
|
||||
| `src-tauri/src/lib.rs` | All Tauri commands + tray + protocol + setup |
|
||||
| `src-tauri/tauri.conf.json` | Window config, bundle targets, CSP |
|
||||
| `src-tauri/capabilities/default.json` | Plugin permission grants |
|
||||
|
||||
## IPC Communication
|
||||
|
||||
The frontend calls Rust via `invoke(command_name, args)` from `@tauri-apps/api/core`. Events from Rust to frontend use `listen()` from `@tauri-apps/api/event`.
|
||||
|
||||
Key commands in `src-tauri/src/lib.rs`:
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `launch_app` | Execute app via `cmd /C start` |
|
||||
| `select_target` / `select_folder` / `select_single_executable` | Native file/folder dialogs |
|
||||
| `select_executables` | Multi-select executables |
|
||||
| `create_shortcut` | Create Windows `.lnk` via PowerShell COM |
|
||||
| `ensure_icon` | Extract icon from EXE/folder via PowerShell + .NET, cache as PNG |
|
||||
| `delete_app_artifacts` | Clean up shortcut + cached icon |
|
||||
| `show_app_context_menu` | Show native right-click menu; emits `app-context-action` event |
|
||||
| `load_config` / `save_config` | Read/write `%APPDATA%/com.smyhub.sprout-launcher/launcher-config.json` |
|
||||
| `export_config` / `import_config` | File dialog + JSON backup/restore |
|
||||
| `get_auto_launch` / `set_auto_launch` | Windows boot startup via `tauri-plugin-autostart` |
|
||||
| `minimize_window` / `maximize_window` / `close_window` | Window controls (close → hide to tray) |
|
||||
| `refresh_icons` | Re-extract all icons to disk; returns `sprout-icon://` URLs |
|
||||
|
||||
## Data & File Locations
|
||||
|
||||
- **Config:** `%APPDATA%/com.smyhub.sprout-launcher/launcher-config.json`
|
||||
- **Cached icons:** `%APPDATA%/com.smyhub.sprout-launcher/data/icons/` (PNG files)
|
||||
- **Shortcuts:** `%APPDATA%/com.smyhub.sprout-launcher/data/shortcuts/` (`.lnk` files)
|
||||
- Icons are served via a custom `sprout-icon://` URI scheme registered in `lib.rs`
|
||||
|
||||
If you change the config schema, add a migration path in `src/App.tsx` inside `migrateAndMaybeSaveConfig()` (existing `exePath → targetPath` migration is the pattern to follow).
|
||||
|
||||
## Tauri Plugins Used
|
||||
|
||||
| Plugin | Purpose |
|
||||
|--------|---------|
|
||||
| `tauri-plugin-dialog` | File open/save dialogs (Rust side) |
|
||||
| `tauri-plugin-autostart` | Windows login-item / startup registry |
|
||||
| `tauri-plugin-single-instance` | Prevent duplicate instances; focus existing window |
|
||||
| `tauri-plugin-log` | Structured logging |
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- TypeScript strict mode; all changes must be type-safe
|
||||
- ESM (`"type": "module"`) throughout
|
||||
- 2-space indentation
|
||||
- React components: `PascalCase.tsx` in `src/components/`; shared types in `src/types.ts`
|
||||
- Rust: standard `snake_case`, all commands registered in `invoke_handler!` at bottom of `lib.rs`
|
||||
- Commits: Conventional Commits (`feat:`, `fix:`, `chore:`, etc.)
|
||||
- `dist/`, `src-tauri/target/` are build artifacts — do not edit
|
||||
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Sprout Launcher - Windows桌面启动器
|
||||
|
||||
一个基于 Electron + React 开发的清新风格 Windows 应用启动器。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 🚀 **快速启动** - 点击图标即可启动相应的Windows应用程序
|
||||
- 🎨 **清新界面** - 淡绿色到淡黄绿色渐变,带有毛玻璃和半透明效果
|
||||
- 🖼️ **自动图标** - 自动获取exe文件的图标
|
||||
- 📁 **分类管理** - 支持自定义分类,轻松管理应用
|
||||
- 💾 **配置保存** - 所有配置使用JSON文件保存
|
||||
- 🎯 **灵活布局** - 每行最多显示15个应用,自适应网格布局
|
||||
|
||||
## 📋 应用信息
|
||||
|
||||
每个添加的应用包含以下信息:
|
||||
|
||||
- **软件图标** - 自动从exe文件提取
|
||||
- **软件名称** - 可自定义,不填则使用exe文件名
|
||||
- **软件分类** - 可选择分类,默认为"默认分类"
|
||||
- **软件描述** - 可选的简单描述
|
||||
|
||||
## 🎯 使用方法
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建应用
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 添加应用
|
||||
|
||||
1. 点击左侧边栏的"➕ 添加应用"按钮
|
||||
2. 点击"浏览"选择exe文件
|
||||
3. 系统会自动获取图标和文件名
|
||||
4. 可选:自定义应用名称、选择分类、添加描述
|
||||
5. 点击"添加"完成
|
||||
|
||||
### 管理分类
|
||||
|
||||
1. 点击左侧边栏的"📁 管理分类"按钮
|
||||
2. 点击"+ 添加分类"输入新分类名称
|
||||
3. 点击"确定"完成添加
|
||||
4. 默认分类无法删除
|
||||
|
||||
### 编辑/删除应用
|
||||
|
||||
- 鼠标悬停在应用卡片上会显示编辑(✏️)和删除(🗑️)按钮
|
||||
- 点击编辑按钮可修改应用信息
|
||||
- 点击删除按钮可删除应用(需确认)
|
||||
|
||||
## 🎨 界面特性
|
||||
|
||||
- **自定义标题栏** - 支持最小化、最大化、关闭操作
|
||||
- **毛玻璃效果** - 现代化的半透明背景
|
||||
- **渐变背景** - 淡绿色到淡黄绿色的清新渐变
|
||||
- **响应式布局** - 自适应不同屏幕尺寸
|
||||
|
||||
## 📦 技术栈
|
||||
|
||||
- **Electron** - 跨平台桌面应用框架
|
||||
- **React** - UI框架
|
||||
- **TypeScript** - 类型安全
|
||||
- **Vite** - 快速构建工具
|
||||
|
||||
## 📝 配置文件位置
|
||||
|
||||
配置文件保存在:`%APPDATA%/sprout-launcher/launcher-config.json`
|
||||
|
||||
## 🔧 开发
|
||||
|
||||
项目结构:
|
||||
|
||||
```
|
||||
sprout-launcher/
|
||||
├── electron/ # Electron主进程和预加载脚本
|
||||
│ ├── main.ts # 主进程
|
||||
│ └── preload.ts # 预加载脚本
|
||||
├── src/
|
||||
│ ├── components/ # React组件
|
||||
│ │ ├── AppCard.tsx # 应用卡片
|
||||
│ │ ├── AddAppDialog.tsx # 添加应用对话框
|
||||
│ │ └── CategoryManager.tsx # 分类管理
|
||||
│ ├── types.ts # TypeScript类型定义
|
||||
│ ├── App.tsx # 主应用组件
|
||||
│ └── App.css # 主样式
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>萌芽桌面启动器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3707
package-lock.json
generated
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "sprout-launcher",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "萌芽桌面启动器 - Windows桌面应用启动器",
|
||||
"author": {
|
||||
"name": "smyhub",
|
||||
"email": "contact@smyhub.com"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
5646
src-tauri/Cargo.lock
generated
Normal file
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "sprout-launcher"
|
||||
version = "1.0.0"
|
||||
description = "萌芽桌面启动器"
|
||||
authors = ["smyhub"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[[bin]]
|
||||
name = "SproutLauncher"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
urlencoding = "2"
|
||||
base64 = "0.22"
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
890
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,890 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
// Suppress the console window that Windows shows when spawning child processes.
|
||||
#[cfg(target_os = "windows")]
|
||||
trait NoWindow {
|
||||
fn no_window(&mut self) -> &mut Self;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
impl NoWindow for Command {
|
||||
fn no_window(&mut self) -> &mut Self {
|
||||
use std::os::windows::process::CommandExt;
|
||||
self.creation_flags(0x08000000) // CREATE_NO_WINDOW
|
||||
}
|
||||
}
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Emitter, Manager, State, WebviewWindow,
|
||||
};
|
||||
|
||||
// ─── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AppItem {
|
||||
id: String,
|
||||
name: String,
|
||||
target_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
shortcut_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon_key: Option<String>,
|
||||
category: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct Category {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct Config {
|
||||
apps: Vec<AppItem>,
|
||||
categories: Vec<Category>,
|
||||
}
|
||||
|
||||
// Snapshot stored when a context menu is shown so the app-level event handler
|
||||
// can act on it without any extra state passing.
|
||||
#[derive(Clone)]
|
||||
struct ContextMenuSnapshot {
|
||||
app_id: String,
|
||||
open_path: String, // shortcut if exists, else target
|
||||
shortcut_path: Option<String>,
|
||||
target_path: String,
|
||||
}
|
||||
|
||||
// ─── App state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct AppState {
|
||||
config_path: PathBuf,
|
||||
shortcuts_dir: PathBuf,
|
||||
icons_dir: PathBuf,
|
||||
context_menu: Mutex<Option<ContextMenuSnapshot>>,
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn sanitize_filename(input: &str) -> String {
|
||||
let bad = "<>:\"/\\|?*";
|
||||
let mut out = String::new();
|
||||
for ch in input.chars() {
|
||||
if (ch as u32) < 32 || bad.contains(ch) {
|
||||
out.push('_');
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
let s: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
"item".to_string()
|
||||
} else {
|
||||
trimmed.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_dir(p: &Path) {
|
||||
let _ = fs::create_dir_all(p);
|
||||
}
|
||||
|
||||
/// Read a PNG file from the icon cache and return it as a base64 data URL.
|
||||
fn png_to_data_url(path: &Path) -> Option<String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
fs::read(path)
|
||||
.ok()
|
||||
.map(|data| format!("data:image/png;base64,{}", STANDARD.encode(&data)))
|
||||
}
|
||||
|
||||
/// Resolve a .lnk shortcut to its target path via PowerShell.
|
||||
fn resolve_lnk_target(lnk: &str) -> Option<String> {
|
||||
if !lnk.to_lowercase().ends_with(".lnk") {
|
||||
return None;
|
||||
}
|
||||
let escaped = lnk.replace('\'', "''");
|
||||
let ps = format!(
|
||||
"(New-Object -ComObject WScript.Shell).CreateShortcut('{}').TargetPath",
|
||||
escaped
|
||||
);
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &format!("& {{ {} }}", ps)])
|
||||
.no_window()
|
||||
.output()
|
||||
.ok()?;
|
||||
let target = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !target.is_empty() && Path::new(&target).exists() {
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the file's associated icon to a PNG using .NET System.Drawing.
|
||||
fn extract_icon_with_powershell(src: &str, dest: &str) -> bool {
|
||||
let src_esc = src.replace('\'', "''");
|
||||
let dst_esc = dest.replace('\'', "''");
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Drawing\n\
|
||||
$inPath = '{}'\n\
|
||||
$outPath = '{}'\n\
|
||||
try {{\n\
|
||||
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($inPath)\n\
|
||||
if ($icon) {{\n\
|
||||
$bmp = $icon.ToBitmap()\n\
|
||||
$bmp.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)\n\
|
||||
$bmp.Dispose()\n\
|
||||
$icon.Dispose()\n\
|
||||
}}\n\
|
||||
}} catch {{ exit 1 }}\n",
|
||||
src_esc, dst_esc
|
||||
);
|
||||
let script_path = std::env::temp_dir().join("sprout-extract-icon.ps1");
|
||||
if fs::write(&script_path, &script).is_err() {
|
||||
return false;
|
||||
}
|
||||
let ok = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
script_path.to_str().unwrap_or(""),
|
||||
])
|
||||
.no_window()
|
||||
.output()
|
||||
.map(|o| o.status.success() && Path::new(dest).exists())
|
||||
.unwrap_or(false);
|
||||
let _ = fs::remove_file(&script_path);
|
||||
ok
|
||||
}
|
||||
|
||||
/// Ensure an icon PNG exists in the cache.
|
||||
/// Returns a base64 data URL (`data:image/png;base64,...`) on success.
|
||||
fn ensure_icon_file(icon_key: &str, from_path: &str, icons_dir: &Path) -> Option<String> {
|
||||
ensure_dir(icons_dir);
|
||||
let out_path = icons_dir.join(format!("{}.png", sanitize_filename(icon_key)));
|
||||
|
||||
if out_path.exists() {
|
||||
// Already cached — read and return data URL.
|
||||
return png_to_data_url(&out_path);
|
||||
}
|
||||
|
||||
if !Path::new(from_path).exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icon_src = if from_path.to_lowercase().ends_with(".lnk") {
|
||||
resolve_lnk_target(from_path).unwrap_or_else(|| from_path.to_string())
|
||||
} else {
|
||||
from_path.to_string()
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if extract_icon_with_powershell(&icon_src, out_path.to_str().unwrap_or("")) {
|
||||
return png_to_data_url(&out_path);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ─── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn launch_app(path: String) -> Result<(), String> {
|
||||
if !Path::new(&path).exists() {
|
||||
return Err("应用程序不存在".into());
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", &path])
|
||||
.no_window()
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("启动失败: {}", e))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("启动失败: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_target(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter("可执行与脚本", &["exe", "bat", "cmd", "ps1", "vbs"])
|
||||
.add_filter("所有文件", &["*"])
|
||||
.blocking_pick_file()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_folder(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.blocking_pick_folder()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_single_executable(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter("可执行与脚本", &["exe", "bat", "cmd", "ps1", "vbs"])
|
||||
.add_filter("所有文件", &["*"])
|
||||
.blocking_pick_file()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_exe_file(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter("可执行文件", &["exe"])
|
||||
.blocking_pick_file()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExecutableEntry {
|
||||
path: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_executables(app: AppHandle) -> Vec<ExecutableEntry> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let paths = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("可执行与脚本", &["exe", "bat", "cmd", "ps1", "vbs"])
|
||||
.add_filter("所有文件", &["*"])
|
||||
.blocking_pick_files()
|
||||
.unwrap_or_default();
|
||||
|
||||
let exe_exts = [".exe", ".bat", ".cmd", ".ps1", ".vbs"];
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|fp| {
|
||||
let p = fp.to_string();
|
||||
let base = Path::new(&p)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = {
|
||||
let lower = base.to_lowercase();
|
||||
let stripped = exe_exts
|
||||
.iter()
|
||||
.find(|ext| lower.ends_with(*ext))
|
||||
.map(|ext| &base[..base.len() - ext.len()])
|
||||
.unwrap_or(&base);
|
||||
stripped.to_string()
|
||||
};
|
||||
ExecutableEntry { path: p, name }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ShortcutResult {
|
||||
success: bool,
|
||||
#[serde(rename = "shortcutPath")]
|
||||
shortcut_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_shortcut(
|
||||
id: String,
|
||||
target_path: String,
|
||||
name: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ShortcutResult, String> {
|
||||
if !Path::new(&target_path).exists() {
|
||||
return Err("目标不存在".into());
|
||||
}
|
||||
ensure_dir(&state.shortcuts_dir);
|
||||
|
||||
let lnk_path = state
|
||||
.shortcuts_dir
|
||||
.join(format!("{}.lnk", sanitize_filename(&id)));
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let is_dir = fs::metadata(&target_path)
|
||||
.map(|m| m.is_dir())
|
||||
.unwrap_or(false);
|
||||
let cwd = if is_dir {
|
||||
String::new()
|
||||
} else {
|
||||
Path::new(&target_path)
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
let desc = name.unwrap_or_else(|| {
|
||||
Path::new(&target_path)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let lnk_esc = lnk_path.to_str().unwrap_or("").replace('\'', "''");
|
||||
let target_esc = target_path.replace('\'', "''");
|
||||
let cwd_esc = cwd.replace('\'', "''");
|
||||
let desc_esc = desc.replace('\'', "''");
|
||||
|
||||
let ps = format!(
|
||||
"$ws = New-Object -ComObject WScript.Shell; \
|
||||
$s = $ws.CreateShortcut('{}'); \
|
||||
$s.TargetPath = '{}'; \
|
||||
$s.WorkingDirectory = '{}'; \
|
||||
$s.Description = '{}'; \
|
||||
$s.Save()",
|
||||
lnk_esc, target_esc, cwd_esc, desc_esc
|
||||
);
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &ps])
|
||||
.no_window()
|
||||
.output()
|
||||
.map_err(|e| format!("创建快捷方式失败: {}", e))?;
|
||||
|
||||
if !out.status.success() || !lnk_path.exists() {
|
||||
return Err("创建快捷方式失败".into());
|
||||
}
|
||||
return Ok(ShortcutResult {
|
||||
success: true,
|
||||
shortcut_path: lnk_path.to_string_lossy().into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
// Non-Windows fallback: return the target path as-is.
|
||||
#[allow(unreachable_code)]
|
||||
Ok(ShortcutResult { success: true, shortcut_path: target_path })
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IconResult {
|
||||
success: bool,
|
||||
#[serde(rename = "iconUrl")]
|
||||
icon_url: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_icon(
|
||||
icon_key: String,
|
||||
from_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> IconResult {
|
||||
if icon_key.is_empty() || from_path.is_empty() {
|
||||
return IconResult { success: false, icon_url: None };
|
||||
}
|
||||
match ensure_icon_file(&icon_key, &from_path, &state.icons_dir) {
|
||||
Some(url) => IconResult { success: true, icon_url: Some(url) },
|
||||
None => IconResult { success: false, icon_url: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// Read all cached icon files at once and return them as base64 data URLs.
|
||||
/// Keys that don't have a cached PNG are simply omitted from the result.
|
||||
#[tauri::command]
|
||||
fn batch_read_icons(
|
||||
icon_keys: Vec<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> HashMap<String, String> {
|
||||
icon_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let path = state.icons_dir.join(format!("{}.png", sanitize_filename(&key)));
|
||||
png_to_data_url(&path).map(|url| (key, url))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_app_artifacts(
|
||||
shortcut_path: Option<String>,
|
||||
icon_key: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> serde_json::Value {
|
||||
if let Some(p) = shortcut_path {
|
||||
let pb = PathBuf::from(&p);
|
||||
if pb.extension().and_then(|e| e.to_str()) == Some("lnk")
|
||||
&& pb.starts_with(&state.shortcuts_dir)
|
||||
{
|
||||
let _ = fs::remove_file(&pb);
|
||||
}
|
||||
}
|
||||
if let Some(key) = icon_key {
|
||||
let f = state.icons_dir.join(format!("{}.png", sanitize_filename(&key)));
|
||||
if f.starts_with(&state.icons_dir) {
|
||||
let _ = fs::remove_file(&f);
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "success": true })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ContextMenuPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
shortcut_path: Option<String>,
|
||||
target_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn show_app_context_menu(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
payload: ContextMenuPayload,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let open_path = payload
|
||||
.shortcut_path
|
||||
.as_deref()
|
||||
.filter(|p| Path::new(p).exists())
|
||||
.unwrap_or(&payload.target_path)
|
||||
.to_string();
|
||||
|
||||
let has_shortcut = payload
|
||||
.shortcut_path
|
||||
.as_deref()
|
||||
.map(|p| Path::new(p).exists())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Store context so the app-level menu event handler can act on it.
|
||||
*state.context_menu.lock().unwrap() = Some(ContextMenuSnapshot {
|
||||
app_id: payload.id.clone(),
|
||||
open_path: open_path.clone(),
|
||||
shortcut_path: payload.shortcut_path.clone(),
|
||||
target_path: payload.target_path.clone(),
|
||||
});
|
||||
|
||||
let open_item =
|
||||
MenuItemBuilder::with_id("ctx_open", format!("打开: {}", payload.name))
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let runas_item =
|
||||
MenuItemBuilder::with_id("ctx_runas", "以管理员身份运行")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let show_shortcut =
|
||||
MenuItemBuilder::with_id("ctx_show_shortcut", "在资源管理器中显示快捷方式")
|
||||
.enabled(has_shortcut)
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let show_target =
|
||||
MenuItemBuilder::with_id("ctx_show_target", "在资源管理器中显示目标")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let sep = PredefinedMenuItem::separator(&app).map_err(|e| e.to_string())?;
|
||||
let edit_item =
|
||||
MenuItemBuilder::with_id("ctx_edit", "编辑")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let remove_item =
|
||||
MenuItemBuilder::with_id("ctx_remove", "从启动器移除")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let menu = MenuBuilder::new(&app)
|
||||
.item(&open_item)
|
||||
.item(&runas_item)
|
||||
.item(&show_shortcut)
|
||||
.item(&show_target)
|
||||
.item(&sep)
|
||||
.item(&edit_item)
|
||||
.item(&remove_item)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
window.popup_menu(&menu).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_exe_icon(exe_path: String, state: State<'_, AppState>) -> Option<String> {
|
||||
if !Path::new(&exe_path).exists() {
|
||||
return None;
|
||||
}
|
||||
let key = format!("exe_{}", sanitize_filename(&exe_path));
|
||||
ensure_icon_file(&key, &exe_path, &state.icons_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_config(state: State<'_, AppState>) -> Result<Config, String> {
|
||||
if !state.config_path.exists() {
|
||||
let default = Config {
|
||||
apps: vec![],
|
||||
categories: vec![
|
||||
Category { id: "default".into(), name: "默认分类".into() },
|
||||
Category { id: "productivity".into(), name: "办公软件".into() },
|
||||
Category { id: "entertainment".into(), name: "娱乐软件".into() },
|
||||
Category { id: "development".into(), name: "开发工具".into() },
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&default).map_err(|e| e.to_string())?;
|
||||
fs::write(&state.config_path, &json).map_err(|e| e.to_string())?;
|
||||
return Ok(default);
|
||||
}
|
||||
let data = fs::read_to_string(&state.config_path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config(config: Config, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
|
||||
fs::write(&state.config_path, &json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_config(app: AppHandle, state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let dest = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_file_name("sprout-launcher-config.json")
|
||||
.add_filter("JSON文件", &["json"])
|
||||
.blocking_save_file();
|
||||
|
||||
let Some(dest_path) = dest else {
|
||||
return Ok(serde_json::json!({ "success": false }));
|
||||
};
|
||||
if !state.config_path.exists() {
|
||||
return Ok(serde_json::json!({ "success": false }));
|
||||
}
|
||||
let data = fs::read_to_string(&state.config_path).map_err(|e| e.to_string())?;
|
||||
fs::write(dest_path.to_string(), &data).map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_config(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let src = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("JSON文件", &["json"])
|
||||
.blocking_pick_file();
|
||||
|
||||
let Some(src_path) = src else {
|
||||
return Ok(serde_json::json!({ "success": false }));
|
||||
};
|
||||
let data = fs::read_to_string(src_path.to_string()).map_err(|e| e.to_string())?;
|
||||
let config: Config =
|
||||
serde_json::from_str(&data).map_err(|_| "无效的配置文件格式".to_string())?;
|
||||
fs::write(&state.config_path, &data).map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({ "success": true, "config": config }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_auto_launch(app: AppHandle) -> bool {
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
app.autolaunch().is_enabled().unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_auto_launch(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
if enabled {
|
||||
app.autolaunch().enable().map_err(|e| e.to_string())
|
||||
} else {
|
||||
app.autolaunch().disable().map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn minimize_window(window: WebviewWindow) {
|
||||
let _ = window.minimize();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn maximize_window(window: WebviewWindow) {
|
||||
if window.is_maximized().unwrap_or(false) {
|
||||
let _ = window.unmaximize();
|
||||
} else {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn close_window(window: WebviewWindow) {
|
||||
let _ = window.hide();
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RefreshItem {
|
||||
id: String,
|
||||
#[serde(rename = "targetPath", default)]
|
||||
target_path: Option<String>,
|
||||
#[serde(rename = "shortcutPath", default)]
|
||||
shortcut_path: Option<String>,
|
||||
#[serde(rename = "iconKey", default)]
|
||||
icon_key: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn refresh_icons(
|
||||
apps: Vec<RefreshItem>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
apps.into_iter()
|
||||
.map(|item| {
|
||||
let id = item.id.clone();
|
||||
let from = item
|
||||
.shortcut_path
|
||||
.filter(|p| Path::new(p).exists())
|
||||
.or_else(|| item.target_path.filter(|p| Path::new(p).exists()));
|
||||
|
||||
let Some(from_path) = from else {
|
||||
return serde_json::json!({ "id": id, "success": false, "reason": "文件不存在" });
|
||||
};
|
||||
|
||||
let icon_key = item.icon_key.unwrap_or_else(|| id.clone());
|
||||
// Delete cached file to force re-extraction.
|
||||
let cached = state
|
||||
.icons_dir
|
||||
.join(format!("{}.png", sanitize_filename(&icon_key)));
|
||||
let _ = fs::remove_file(&cached);
|
||||
|
||||
match ensure_icon_file(&icon_key, &from_path, &state.icons_dir) {
|
||||
Some(url) => {
|
||||
serde_json::json!({ "id": id, "success": true, "iconUrl": url })
|
||||
}
|
||||
None => {
|
||||
serde_json::json!({ "id": id, "success": false, "reason": "无法获取图标" })
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
// ── Plugins ──────────────────────────────────────────────────────────
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
.plugin(if cfg!(debug_assertions) {
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build()
|
||||
} else {
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Warn)
|
||||
.build()
|
||||
})
|
||||
// ── Setup ─────────────────────────────────────────────────────────────
|
||||
.setup(|app| {
|
||||
let app_data = app.path().app_data_dir()?;
|
||||
let config_path = app_data.join("launcher-config.json");
|
||||
let data_dir = app_data.join("data");
|
||||
let shortcuts_dir = data_dir.join("shortcuts");
|
||||
let icons_dir = data_dir.join("icons");
|
||||
|
||||
ensure_dir(&shortcuts_dir);
|
||||
ensure_dir(&icons_dir);
|
||||
ensure_dir(&app_data);
|
||||
|
||||
app.manage(AppState {
|
||||
config_path,
|
||||
shortcuts_dir,
|
||||
icons_dir,
|
||||
context_menu: Mutex::new(None),
|
||||
});
|
||||
|
||||
// System tray — use public/logo.png embedded at compile time.
|
||||
let logo_bytes = include_bytes!("../../public/logo.png");
|
||||
let logo_icon = tauri::image::Image::from_bytes(logo_bytes)?;
|
||||
|
||||
let tray_menu = MenuBuilder::new(app)
|
||||
.text("tray_show", "显示主窗口")
|
||||
.separator()
|
||||
.text("tray_quit", "退出 SproutLauncher")
|
||||
.build()?;
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(logo_icon)
|
||||
.tooltip("SproutLauncher")
|
||||
.menu(&tray_menu)
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Only show the window on left-click.
|
||||
// Right-click shows the context menu automatically (OS handles it).
|
||||
if let TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
button_state: tauri::tray::MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Single app-level handler for ALL menu events (tray + context menu).
|
||||
let app_handle = app.handle().clone();
|
||||
app.on_menu_event(move |app, event| {
|
||||
match event.id().as_ref() {
|
||||
// ── Tray menu ─────────────────────────────────────────────
|
||||
"tray_show" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
"tray_quit" => {
|
||||
// Use process::exit to bypass CloseRequested handler.
|
||||
std::process::exit(0);
|
||||
}
|
||||
// ── App context menu ──────────────────────────────────────
|
||||
id => {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let snapshot = state.context_menu.lock().unwrap().clone();
|
||||
let Some(snap) = snapshot else { return };
|
||||
match id {
|
||||
"ctx_open" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("cmd")
|
||||
.args(["/C", "start", "", &snap.open_path])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
"ctx_runas" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let esc = snap.open_path.replace('\'', "''");
|
||||
let _ = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!(
|
||||
"Start-Process -FilePath '{}' -Verb RunAs",
|
||||
esc
|
||||
),
|
||||
])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
"ctx_show_shortcut" => {
|
||||
if let Some(ref sp) = snap.shortcut_path {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("explorer")
|
||||
.args(["/select,", sp])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
"ctx_show_target" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("explorer")
|
||||
.args(["/select,", &snap.target_path])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
"ctx_edit" => {
|
||||
if let Some(w) = app_handle.get_webview_window("main") {
|
||||
let _ = w.emit(
|
||||
"app-context-action",
|
||||
serde_json::json!({
|
||||
"action": "edit",
|
||||
"id": snap.app_id
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
"ctx_remove" => {
|
||||
if let Some(w) = app_handle.get_webview_window("main") {
|
||||
let _ = w.emit(
|
||||
"app-context-action",
|
||||
serde_json::json!({
|
||||
"action": "remove",
|
||||
"id": snap.app_id
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
// ── Close → hide to tray ──────────────────────────────────────────────
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
// ── Commands ──────────────────────────────────────────────────────────
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
launch_app,
|
||||
select_target,
|
||||
select_folder,
|
||||
select_single_executable,
|
||||
select_exe_file,
|
||||
select_executables,
|
||||
create_shortcut,
|
||||
ensure_icon,
|
||||
batch_read_icons,
|
||||
delete_app_artifacts,
|
||||
show_app_context_menu,
|
||||
get_exe_icon,
|
||||
load_config,
|
||||
save_config,
|
||||
export_config,
|
||||
import_config,
|
||||
get_auto_launch,
|
||||
set_auto_launch,
|
||||
minimize_window,
|
||||
maximize_window,
|
||||
close_window,
|
||||
refresh_icons,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
49
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SproutLauncher",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.smyhub.sprout-launcher",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "SproutLauncher",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"visible": false,
|
||||
"skipTaskbar": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"languages": ["SimpChinese"],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
566
src/App.css
Normal file
@@ -0,0 +1,566 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: transparent;
|
||||
color: #2d6a4f;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius-sm: 6px;
|
||||
--radius: 8px;
|
||||
--radius-lg: 10px;
|
||||
--glass-bg: rgba(255, 255, 255, 0.22);
|
||||
--glass-border: rgba(255, 255, 255, 0.3);
|
||||
--green-dark: #2d6a4f;
|
||||
--green: #7cb342;
|
||||
--green-light: #a8e6cf;
|
||||
--green-soft: #dcedc1;
|
||||
--yellow-soft: #fff2b3;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #a8e6cf 0%, #dcedc1 55%, #fff2b3 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(45, 106, 79, 0.18);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 45%, rgba(124, 179, 66, 0.22), transparent 55%),
|
||||
radial-gradient(circle at 80% 80%, rgba(168, 230, 207, 0.25), transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 自定义标题栏 */
|
||||
.titlebar {
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 24px rgba(45, 106, 79, 0.12);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 160px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.titlebar-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #2d6a4f 0%, #7cb342 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-shadow: 0 2px 10px rgba(124, 179, 66, 0.25);
|
||||
filter: drop-shadow(0 2px 4px rgba(45, 106, 79, 0.1));
|
||||
}
|
||||
|
||||
.titlebar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
-webkit-app-region: no-drag;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 15px rgba(45, 106, 79, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(45, 106, 79, 0.2);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: #2d6a4f;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-app-region: no-drag;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: #2d6a4f;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.2);
|
||||
}
|
||||
|
||||
.toolbar-btn:active {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
transform: translateY(0) scale(0.95);
|
||||
}
|
||||
|
||||
.toolbar-btn.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.toolbar-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 窗口控制按钮 */
|
||||
.titlebar-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
-webkit-app-region: no-drag;
|
||||
min-width: 120px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: #2d6a4f;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.control-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.control-btn.minimize:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.control-btn.maximize:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.control-btn.close:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.35);
|
||||
box-shadow: 4px 0 20px rgba(45, 106, 79, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.sidebar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(168, 230, 207, 0.2) 0%,
|
||||
rgba(220, 237, 193, 0.1) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #2d6a4f;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.category-tab:first-of-type {
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.category-tab:last-of-type {
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.category-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.2);
|
||||
}
|
||||
|
||||
.category-tab.active {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: #2d6a4f;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 15px rgba(45, 106, 79, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
border-left: 3px solid rgba(45, 106, 79, 0.7);
|
||||
}
|
||||
|
||||
.category-tab.dragging {
|
||||
opacity: 0.5;
|
||||
background: rgba(168, 230, 207, 0.5);
|
||||
}
|
||||
|
||||
.category-tab.drag-over {
|
||||
background: rgba(168, 230, 207, 0.6);
|
||||
box-shadow: 0 0 0 2px #2d6a4f inset;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
margin: 12px;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
/* 分类分组容器 */
|
||||
.categorized-apps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 1px 6px rgba(45, 106, 79, 0.08);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.category-section:first-child {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.category-section:last-child {
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0.5) 0%,
|
||||
rgba(255, 255, 255, 0.2) 100%);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #2d6a4f;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 2px 4px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
/* 应用网格 */
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
/* 每行最多15个 */
|
||||
@media (min-width: 1650px) {
|
||||
.apps-grid {
|
||||
grid-template-columns: repeat(15, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 18px;
|
||||
color: #2d6a4f;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(45, 106, 79, 0.12);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
padding: 14px 28px;
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #2d6a4f;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 15px rgba(45, 106, 79, 0.18);
|
||||
}
|
||||
|
||||
.empty-state button:hover {
|
||||
transform: translateY(-3px) scale(1.05);
|
||||
box-shadow: 0 8px 25px rgba(45, 106, 79, 0.24);
|
||||
background: linear-gradient(135deg, #8bd7b8, #cfe7a8);
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 为Firefox隐藏滚动条 */
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 设置面板 */
|
||||
.settings-panel {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
box-shadow: 0 8px 32px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.settings-panel h3 {
|
||||
font-size: 16px;
|
||||
color: #2d6a4f;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 2px 4px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 14px;
|
||||
color: #2d6a4f;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(45, 106, 79, 0.8);
|
||||
line-height: 1.6;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid rgba(45, 106, 79, 0.6);
|
||||
text-shadow: 0 1px 2px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
/* 开关切换 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: inset 0 2px 4px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
border-color: rgba(45, 106, 79, 0.4);
|
||||
box-shadow: 0 0 10px rgba(45, 106, 79, 0.35),
|
||||
inset 0 1px 3px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* ── 外部文件拖放覆盖层 ───────────────────────────────────────── */
|
||||
.external-drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(45, 106, 79, 0.18);
|
||||
backdrop-filter: blur(3px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.external-drop-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 40px 56px;
|
||||
border: 2.5px dashed rgba(45, 106, 79, 0.6);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 8px 32px rgba(45, 106, 79, 0.15);
|
||||
color: #2d6a4f;
|
||||
}
|
||||
|
||||
.external-drop-box svg {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.external-drop-box span {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
778
src/App.tsx
Normal file
@@ -0,0 +1,778 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import './App.css'
|
||||
import { App as AppType, Config, Category } from './types'
|
||||
import AppCard from './components/AppCard'
|
||||
import GridWithFill from './components/GridWithFill'
|
||||
import AddAppDialog from './components/AddAppDialog'
|
||||
import CategoryManager from './components/CategoryManager'
|
||||
|
||||
function App() {
|
||||
const [config, setConfig] = useState<Config>({ apps: [], categories: [] })
|
||||
const configRef = useRef(config)
|
||||
// iconKey → base64 data URL (populated by batch_read_icons + ensure_icon)
|
||||
const [iconUrls, setIconUrls] = useState<Record<string, string>>({})
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [addDialogMode, setAddDialogMode] = useState<'folder' | 'program'>('folder')
|
||||
const [editingApp, setEditingApp] = useState<AppType | null>(null)
|
||||
const [showCategoryManager, setShowCategoryManager] = useState(false)
|
||||
const [draggedApp, setDraggedApp] = useState<AppType | null>(null)
|
||||
const [dragOverApp, setDragOverApp] = useState<string | null>(null)
|
||||
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
|
||||
const [dragOverCategory, setDragOverCategory] = useState<string | null>(null)
|
||||
const [autoLaunch, setAutoLaunch] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [isExternalDragOver, setIsExternalDragOver] = useState(false)
|
||||
const dragStartPos = useRef<{ x: number; y: number } | null>(null)
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isDragging = useRef(false)
|
||||
const dragType = useRef<'app' | 'category' | null>(null)
|
||||
// Stable refs for use inside event listeners
|
||||
const selectedCategoryRef = useRef(selectedCategory)
|
||||
|
||||
useEffect(() => { configRef.current = config }, [config])
|
||||
useEffect(() => { selectedCategoryRef.current = selectedCategory }, [selectedCategory])
|
||||
|
||||
// 加载配置
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
loadAutoLaunchStatus()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 监听主进程发来的右键菜单动作
|
||||
useEffect(() => {
|
||||
const unlisten = listen<{ action: 'edit' | 'remove'; id: string }>(
|
||||
'app-context-action',
|
||||
({ payload }) => {
|
||||
const current = configRef.current
|
||||
const app = current.apps.find(a => a.id === payload.id)
|
||||
if (!app) return
|
||||
if (payload.action === 'edit') {
|
||||
openEditDialog(app)
|
||||
} else if (payload.action === 'remove') {
|
||||
void removeAppById(payload.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
return () => {
|
||||
unlisten.then(fn => fn())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 拖放添加:监听从外部(资源管理器)拖入的文件/文件夹
|
||||
useEffect(() => {
|
||||
const unEnter = listen<{ paths: string[] }>('tauri://drag-enter', () => {
|
||||
setIsExternalDragOver(true)
|
||||
})
|
||||
const unLeave = listen('tauri://drag-leave', () => {
|
||||
setIsExternalDragOver(false)
|
||||
})
|
||||
const unDrop = listen<{ paths: string[] }>('tauri://drag-drop', async (event) => {
|
||||
setIsExternalDragOver(false)
|
||||
const paths = event.payload.paths
|
||||
if (!paths || paths.length === 0) return
|
||||
|
||||
const current = configRef.current
|
||||
const targetCategory =
|
||||
selectedCategoryRef.current === 'all' ? 'default' : selectedCategoryRef.current
|
||||
|
||||
const newApps: AppType[] = []
|
||||
let counter = Date.now()
|
||||
|
||||
for (const filePath of paths) {
|
||||
const id = String(counter++)
|
||||
const iconKey = id
|
||||
const base = filePath.split(/[/\\]/).pop() || filePath
|
||||
const name = base.replace(/\.(exe|bat|cmd|ps1|vbs|lnk)$/i, '') || base
|
||||
|
||||
let shortcutPath: string | undefined
|
||||
try {
|
||||
const res = await invoke<{ success: boolean; shortcutPath: string }>(
|
||||
'create_shortcut', { id, targetPath: filePath, name }
|
||||
)
|
||||
shortcutPath = res.shortcutPath
|
||||
} catch { shortcutPath = undefined }
|
||||
|
||||
// 后台提取图标,完成后立即更新显示
|
||||
invoke<{ success: boolean; iconUrl: string | null }>(
|
||||
'ensure_icon', { iconKey, fromPath: shortcutPath || filePath }
|
||||
).then(res => {
|
||||
if (res.success && res.iconUrl) {
|
||||
setIconUrls(prev => ({ ...prev, [iconKey]: res.iconUrl! }))
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
newApps.push({ id, name, targetPath: filePath, shortcutPath, iconKey, category: targetCategory })
|
||||
}
|
||||
|
||||
const newConfig = { ...current, apps: [...current.apps, ...newApps] }
|
||||
await invoke('save_config', { config: newConfig })
|
||||
setConfig(newConfig)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unEnter.then(fn => fn())
|
||||
unLeave.then(fn => fn())
|
||||
unDrop.then(fn => fn())
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeAppById = async (id: string) => {
|
||||
const current = configRef.current
|
||||
const app = current.apps.find(a => a.id === id)
|
||||
if (!app) return
|
||||
try {
|
||||
await invoke('delete_app_artifacts', {
|
||||
shortcutPath: app.shortcutPath,
|
||||
iconKey: app.iconKey || app.id,
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
const newConfig = { ...current, apps: current.apps.filter(a => a.id !== id) }
|
||||
await saveConfig(newConfig)
|
||||
}
|
||||
|
||||
const loadAutoLaunchStatus = async () => {
|
||||
try {
|
||||
const enabled = await invoke<boolean>('get_auto_launch')
|
||||
setAutoLaunch(enabled)
|
||||
} catch (error) {
|
||||
console.error('获取开机自启状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleAutoLaunch = async () => {
|
||||
try {
|
||||
const newValue = !autoLaunch
|
||||
await invoke('set_auto_launch', { enabled: newValue })
|
||||
setAutoLaunch(newValue)
|
||||
} catch (error) {
|
||||
console.error('设置开机自启失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台逐个提取还没有缓存的图标,提取完成后立即更新 iconUrls */
|
||||
const extractMissingIcons = (apps: AppType[], existingKeys: Set<string>) => {
|
||||
for (const app of apps) {
|
||||
const key = app.iconKey || app.id
|
||||
if (existingKeys.has(key)) continue
|
||||
const fromPath = app.shortcutPath || app.targetPath
|
||||
if (!fromPath) continue
|
||||
invoke<{ success: boolean; iconUrl: string | null }>(
|
||||
'ensure_icon', { iconKey: key, fromPath }
|
||||
).then(res => {
|
||||
if (res.success && res.iconUrl) {
|
||||
setIconUrls(prev => ({ ...prev, [key]: res.iconUrl! }))
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshIcons = async () => {
|
||||
if (config.apps.length === 0) { alert('当前没有应用'); return }
|
||||
if (!confirm(`确定要刷新所有应用的图标吗?这可能需要一些时间。`)) return
|
||||
|
||||
try {
|
||||
let successCount = 0
|
||||
for (const app of config.apps) {
|
||||
const fromPath = app.shortcutPath || app.targetPath
|
||||
const res = await invoke<{ success: boolean; iconUrl: string | null }>(
|
||||
'ensure_icon', { iconKey: app.iconKey || app.id, fromPath }
|
||||
)
|
||||
if (res.success && res.iconUrl) {
|
||||
successCount++
|
||||
setIconUrls(prev => ({ ...prev, [app.iconKey || app.id]: res.iconUrl! }))
|
||||
}
|
||||
}
|
||||
alert(`图标刷新完成!成功: ${successCount}/${config.apps.length}`)
|
||||
} catch (error) {
|
||||
alert('刷新图标失败: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
const migrateAndMaybeSaveConfig = async (rawConfig: unknown) => {
|
||||
const root = (typeof rawConfig === 'object' && rawConfig !== null)
|
||||
? (rawConfig as Record<string, unknown>) : {}
|
||||
|
||||
const appsRaw: unknown[] = Array.isArray(root.apps) ? root.apps : []
|
||||
const categoriesRaw: unknown[] = Array.isArray(root.categories) ? root.categories : []
|
||||
|
||||
let changed = false
|
||||
const migratedApps: AppType[] = []
|
||||
|
||||
// Parallelize shortcut creation for faster first-run startup
|
||||
const settled = await Promise.allSettled(appsRaw.map(async (a) => {
|
||||
const item = (typeof a === 'object' && a !== null) ? (a as Record<string, unknown>) : {}
|
||||
const id = String(item.id ?? Date.now())
|
||||
const name = String(item.name ?? '未命名')
|
||||
const category = String(item.category ?? 'default')
|
||||
const description = typeof item.description === 'string' ? item.description : undefined
|
||||
const targetPath = String(item.targetPath ?? item.exePath ?? '')
|
||||
const iconKey = String(item.iconKey ?? id)
|
||||
let shortcutPath: string | undefined =
|
||||
typeof item.shortcutPath === 'string' ? item.shortcutPath : undefined
|
||||
let needsSave = false
|
||||
|
||||
if (item.targetPath === undefined && typeof item.exePath === 'string') needsSave = true
|
||||
if (item.iconKey === undefined) needsSave = true
|
||||
|
||||
if (!shortcutPath && targetPath) {
|
||||
try {
|
||||
const res = await invoke<{ success: boolean; shortcutPath: string }>(
|
||||
'create_shortcut', { id, targetPath, name }
|
||||
)
|
||||
shortcutPath = res.shortcutPath
|
||||
needsSave = true
|
||||
} catch { shortcutPath = undefined }
|
||||
}
|
||||
return { app: { id, name, targetPath, shortcutPath, iconKey, category, description }, needsSave }
|
||||
}))
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status === 'fulfilled') {
|
||||
migratedApps.push(r.value.app)
|
||||
if (r.value.needsSave) changed = true
|
||||
}
|
||||
}
|
||||
|
||||
const migrated: Config = { apps: migratedApps, categories: categoriesRaw as Category[] }
|
||||
if (changed) await invoke('save_config', { config: migrated })
|
||||
return migrated
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const loadedConfig = await invoke<Config>('load_config')
|
||||
const migrated = await migrateAndMaybeSaveConfig(loadedConfig)
|
||||
setConfig(migrated)
|
||||
// Step 1: Load already-cached icons immediately (fast disk read, no PowerShell)
|
||||
const iconKeys = migrated.apps.map(a => a.iconKey || a.id)
|
||||
const existing = await invoke<Record<string, string>>(
|
||||
'batch_read_icons', { iconKeys }
|
||||
).catch(() => ({} as Record<string, string>))
|
||||
if (Object.keys(existing).length > 0) {
|
||||
setIconUrls(prev => ({ ...prev, ...existing }))
|
||||
}
|
||||
// Step 2: Extract any missing icons in the background (may run PowerShell)
|
||||
const existingSet = new Set(Object.keys(existing))
|
||||
extractMissingIcons(migrated.apps, existingSet)
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async (newConfig: Config) => {
|
||||
try {
|
||||
await invoke('save_config', { config: newConfig })
|
||||
setConfig(newConfig)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLaunchApp = async (app: AppType) => {
|
||||
try {
|
||||
const openPath = app.shortcutPath || app.targetPath
|
||||
await invoke('launch_app', { path: openPath })
|
||||
} catch (error) {
|
||||
console.error('启动应用失败:', error)
|
||||
alert('启动应用失败: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddApp = async (appData: Omit<AppType, 'id' | 'shortcutPath' | 'iconKey'>) => {
|
||||
const id = Date.now().toString()
|
||||
const iconKey = id
|
||||
|
||||
const shortcutRes = await invoke<{ success: boolean; shortcutPath: string }>(
|
||||
'create_shortcut',
|
||||
{ id, targetPath: appData.targetPath, name: appData.name }
|
||||
)
|
||||
const shortcutPath = shortcutRes.shortcutPath
|
||||
const iconRes = await invoke<{ success: boolean; iconUrl: string | null }>(
|
||||
'ensure_icon', { iconKey, fromPath: shortcutPath || appData.targetPath }
|
||||
)
|
||||
if (iconRes.success && iconRes.iconUrl) {
|
||||
setIconUrls(prev => ({ ...prev, [iconKey]: iconRes.iconUrl! }))
|
||||
}
|
||||
|
||||
const newApp: AppType = { ...appData, id, shortcutPath, iconKey }
|
||||
const newConfig = { ...config, apps: [...config.apps, newApp] }
|
||||
await saveConfig(newConfig)
|
||||
}
|
||||
|
||||
const handleEditApp = async (appData: Omit<AppType, 'id' | 'shortcutPath' | 'iconKey'>) => {
|
||||
if (!editingApp) return
|
||||
const id = editingApp.id
|
||||
const iconKey = editingApp.iconKey || id
|
||||
let shortcutPath = editingApp.shortcutPath
|
||||
const targetChanged = editingApp.targetPath !== appData.targetPath
|
||||
|
||||
if (targetChanged) {
|
||||
try {
|
||||
const res = await invoke<{ success: boolean; shortcutPath: string }>(
|
||||
'create_shortcut', { id, targetPath: appData.targetPath, name: appData.name }
|
||||
)
|
||||
shortcutPath = res.shortcutPath
|
||||
} catch {
|
||||
shortcutPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const iconRes = await invoke<{ success: boolean; iconUrl: string | null }>(
|
||||
'ensure_icon', { iconKey, fromPath: shortcutPath || appData.targetPath }
|
||||
)
|
||||
if (iconRes.success && iconRes.iconUrl) {
|
||||
setIconUrls(prev => ({ ...prev, [iconKey]: iconRes.iconUrl! }))
|
||||
}
|
||||
|
||||
const updatedApps = config.apps.map(app =>
|
||||
app.id === id ? { ...appData, id, shortcutPath, iconKey } : app
|
||||
)
|
||||
await saveConfig({ ...config, apps: updatedApps })
|
||||
setEditingApp(null)
|
||||
}
|
||||
|
||||
const handleDeleteApp = async (app: AppType) => {
|
||||
try {
|
||||
await invoke('delete_app_artifacts', {
|
||||
shortcutPath: app.shortcutPath,
|
||||
iconKey: app.iconKey || app.id,
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
await saveConfig({ ...config, apps: config.apps.filter(a => a.id !== app.id) })
|
||||
}
|
||||
|
||||
const handleAddCategory = async (name: string) => {
|
||||
const newCategory: Category = { id: Date.now().toString(), name }
|
||||
await saveConfig({ ...config, categories: [...config.categories, newCategory] })
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (id: string) => {
|
||||
if (id === 'default') { alert('默认分类不能删除'); return }
|
||||
if (confirm('确定要删除这个分类吗?该分类下的应用将移至默认分类。')) {
|
||||
await saveConfig({
|
||||
...config,
|
||||
categories: config.categories.filter(c => c.id !== id),
|
||||
apps: config.apps.map(app => app.category === id ? { ...app, category: 'default' } : app),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppContextMenu = async (app: AppType) => {
|
||||
try {
|
||||
await invoke('show_app_context_menu', {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
shortcutPath: app.shortcutPath,
|
||||
targetPath: app.targetPath,
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleClearAllApps = async () => {
|
||||
if (config.apps.length === 0) { alert('当前没有应用可以清空'); return }
|
||||
if (confirm(`确定要清空所有应用吗?这将删除 ${config.apps.length} 个应用,此操作不可恢复!`)) {
|
||||
try {
|
||||
for (const app of config.apps) {
|
||||
await invoke('delete_app_artifacts', {
|
||||
shortcutPath: app.shortcutPath,
|
||||
iconKey: app.iconKey || app.id,
|
||||
})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
await saveConfig({ ...config, apps: [] })
|
||||
alert('已清空所有应用!')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportConfig = async () => {
|
||||
try {
|
||||
const result = await invoke<{ success: boolean }>('export_config')
|
||||
if (result.success) alert('配置导出成功!')
|
||||
} catch (error) {
|
||||
console.error('导出配置失败:', error)
|
||||
alert('导出配置失败: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportConfig = async () => {
|
||||
try {
|
||||
const result = await invoke<{ success: boolean; config?: Config }>('import_config')
|
||||
if (result.success && result.config) {
|
||||
await loadConfig()
|
||||
alert('配置导入成功!')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入配置失败:', error)
|
||||
alert('导入配置失败: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (app: AppType) => {
|
||||
setEditingApp(app)
|
||||
setIsAddDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsAddDialogOpen(false)
|
||||
setEditingApp(null)
|
||||
}
|
||||
|
||||
// 拖拽开始
|
||||
const handleDragStart = (app: AppType, e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
const pos = 'touches' in e
|
||||
? { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
: { x: e.clientX, y: e.clientY }
|
||||
dragStartPos.current = pos
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
isDragging.current = true
|
||||
dragType.current = 'app'
|
||||
setDraggedApp(app)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleCategoryDragStart = (categoryId: string, e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (categoryId === 'all') return
|
||||
e.preventDefault()
|
||||
const pos = 'touches' in e
|
||||
? { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
: { x: e.clientX, y: e.clientY }
|
||||
dragStartPos.current = pos
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
isDragging.current = true
|
||||
dragType.current = 'category'
|
||||
setDraggedCategory(categoryId)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleDragMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
const pos = 'touches' in e
|
||||
? { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
: { x: e.clientX, y: e.clientY }
|
||||
if (dragType.current === 'app' && draggedApp) {
|
||||
const element = document.elementFromPoint(pos.x, pos.y)
|
||||
const appCard = element?.closest('.app-card') as HTMLElement
|
||||
if (appCard) {
|
||||
const appId = appCard.dataset.appId
|
||||
if (appId && appId !== draggedApp.id) setDragOverApp(appId)
|
||||
} else {
|
||||
setDragOverApp(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCategoryDragMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDragging.current || dragType.current !== 'category' || !draggedCategory) return
|
||||
const pos = 'touches' in e
|
||||
? { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
: { x: e.clientX, y: e.clientY }
|
||||
const element = document.elementFromPoint(pos.x, pos.y)
|
||||
const tab = element?.closest('.category-tab') as HTMLElement
|
||||
if (tab) {
|
||||
const catId = tab.dataset.categoryId
|
||||
if (catId && catId !== draggedCategory && catId !== 'all') setDragOverCategory(catId)
|
||||
} else {
|
||||
setDragOverCategory(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null }
|
||||
if (isDragging.current && dragType.current === 'app' && draggedApp && dragOverApp) {
|
||||
const apps = [...config.apps]
|
||||
const di = apps.findIndex(a => a.id === draggedApp.id)
|
||||
const ti = apps.findIndex(a => a.id === dragOverApp)
|
||||
if (di !== -1 && ti !== -1) {
|
||||
const temp = apps[di]; apps[di] = apps[ti]; apps[ti] = temp
|
||||
await saveConfig({ ...config, apps })
|
||||
}
|
||||
}
|
||||
isDragging.current = false
|
||||
dragType.current = null
|
||||
setDraggedApp(null)
|
||||
setDragOverApp(null)
|
||||
dragStartPos.current = null
|
||||
}
|
||||
|
||||
const handleCategoryDragEnd = async () => {
|
||||
if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null }
|
||||
if (isDragging.current && dragType.current === 'category' && draggedCategory && dragOverCategory) {
|
||||
const categories = [...config.categories]
|
||||
const di = categories.findIndex(c => c.id === draggedCategory)
|
||||
const ti = categories.findIndex(c => c.id === dragOverCategory)
|
||||
if (di !== -1 && ti !== -1) {
|
||||
const temp = categories[di]; categories[di] = categories[ti]; categories[ti] = temp
|
||||
await saveConfig({ ...config, categories })
|
||||
}
|
||||
}
|
||||
isDragging.current = false
|
||||
dragType.current = null
|
||||
setDraggedCategory(null)
|
||||
setDragOverCategory(null)
|
||||
dragStartPos.current = null
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null }
|
||||
isDragging.current = false
|
||||
dragType.current = null
|
||||
setDraggedApp(null)
|
||||
setDragOverApp(null)
|
||||
setDraggedCategory(null)
|
||||
setDragOverCategory(null)
|
||||
}
|
||||
|
||||
const filteredApps = selectedCategory === 'all'
|
||||
? config.apps
|
||||
: config.apps.filter(app => app.category === selectedCategory)
|
||||
|
||||
return (
|
||||
<div className="app-container" onDragOver={(e) => e.preventDefault()} onDrop={(e) => e.preventDefault()}>
|
||||
{/* 自定义标题栏 */}
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<div className="titlebar-left">
|
||||
<span className="titlebar-title">SproutLauncher</span>
|
||||
</div>
|
||||
<div className="titlebar-center">
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn" onClick={() => setShowCategoryManager(!showCategoryManager)} title="管理分类">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={() => { setEditingApp(null); setAddDialogMode('folder'); setIsAddDialogOpen(true) }}
|
||||
title="添加文件夹"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={() => { setEditingApp(null); setAddDialogMode('program'); setIsAddDialogOpen(true) }}
|
||||
title="添加程序/脚本"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-divider"></div>
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn" onClick={handleExportConfig} title="导出配置">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={handleImportConfig} title="导入配置">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-divider"></div>
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn" onClick={handleRefreshIcons} title="刷新图标">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0118.8-4.3M22 12.5a10 10 0 01-18.8 4.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="toolbar-btn danger" onClick={handleClearAllApps} title="清空所有">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-divider"></div>
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn" onClick={() => setShowSettings(!showSettings)} title="设置">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="titlebar-controls">
|
||||
<button className="control-btn minimize" onClick={() => invoke('minimize_window')} title="最小化">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="control-btn maximize" onClick={() => invoke('maximize_window')} title="最大化">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="control-btn close" onClick={() => invoke('close_window')} title="关闭">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M6 18L18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="main-content">
|
||||
{/* 侧边栏 */}
|
||||
<div
|
||||
className="sidebar"
|
||||
onMouseMove={handleCategoryDragMove}
|
||||
onMouseUp={handleCategoryDragEnd}
|
||||
onMouseLeave={handleDragCancel}
|
||||
onTouchMove={handleCategoryDragMove}
|
||||
onTouchEnd={handleCategoryDragEnd}
|
||||
>
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className={`category-tab ${selectedCategory === 'all' ? 'active' : ''}`}
|
||||
data-category-id="all"
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
>
|
||||
全部应用 ({config.apps.length})
|
||||
</div>
|
||||
{config.categories.map(cat => (
|
||||
<div
|
||||
key={cat.id}
|
||||
data-category-id={cat.id}
|
||||
className={`category-tab ${selectedCategory === cat.id ? 'active' : ''} ${draggedCategory === cat.id ? 'dragging' : ''} ${dragOverCategory === cat.id ? 'drag-over' : ''}`}
|
||||
onClick={() => !isDragging.current && setSelectedCategory(cat.id)}
|
||||
onMouseDown={(e) => handleCategoryDragStart(cat.id, e)}
|
||||
onTouchStart={(e) => handleCategoryDragStart(cat.id, e)}
|
||||
>
|
||||
{cat.name} ({config.apps.filter(app => app.category === cat.id).length})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 应用网格 */}
|
||||
<div
|
||||
className="content-area"
|
||||
onMouseMove={handleDragMove}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragCancel}
|
||||
onTouchMove={handleDragMove}
|
||||
onTouchEnd={handleDragEnd}
|
||||
>
|
||||
{showCategoryManager && (
|
||||
<CategoryManager
|
||||
categories={config.categories}
|
||||
onAddCategory={handleAddCategory}
|
||||
onDeleteCategory={handleDeleteCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<div className="settings-panel">
|
||||
<h3>设置</h3>
|
||||
<div className="settings-item">
|
||||
<span className="settings-label">开机自动启动</span>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={autoLaunch} onChange={handleToggleAutoLaunch} />
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
提示:点击窗口关闭按钮会最小化到系统托盘,右键托盘图标选择"退出 SproutLauncher"可退出程序。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredApps.length === 0 ? (
|
||||
<div className="empty-state"><p>暂无应用</p></div>
|
||||
) : selectedCategory === 'all' ? (
|
||||
<div className="categorized-apps">
|
||||
{config.categories.map(cat => {
|
||||
const categoryApps = config.apps.filter(app => app.category === cat.id)
|
||||
if (categoryApps.length === 0) return null
|
||||
return (
|
||||
<div key={cat.id} className="category-section">
|
||||
<div className="category-title">{cat.name}</div>
|
||||
<GridWithFill
|
||||
keyPrefix={cat.id}
|
||||
items={categoryApps}
|
||||
renderItem={(app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
iconUrl={iconUrls[app.iconKey || app.id]}
|
||||
onLaunch={handleLaunchApp}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={handleDeleteApp}
|
||||
onContextMenu={(a) => void handleAppContextMenu(a)}
|
||||
isDragging={draggedApp?.id === app.id}
|
||||
isDragOver={dragOverApp === app.id}
|
||||
onDragStart={(e) => handleDragStart(app, e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<GridWithFill
|
||||
keyPrefix={`single-${selectedCategory}`}
|
||||
items={filteredApps}
|
||||
renderItem={(app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
iconUrl={iconUrls[app.iconKey || app.id]}
|
||||
onLaunch={handleLaunchApp}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={handleDeleteApp}
|
||||
onContextMenu={(a) => void handleAppContextMenu(a)}
|
||||
isDragging={draggedApp?.id === app.id}
|
||||
isDragOver={dragOverApp === app.id}
|
||||
onDragStart={(e) => handleDragStart(app, e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加/编辑对话框 */}
|
||||
<AddAppDialog
|
||||
isOpen={isAddDialogOpen}
|
||||
onClose={closeDialog}
|
||||
onSave={editingApp ? handleEditApp : handleAddApp}
|
||||
categories={config.categories}
|
||||
editApp={editingApp}
|
||||
mode={addDialogMode}
|
||||
/>
|
||||
|
||||
{/* 外部文件拖入覆盖层 */}
|
||||
{isExternalDragOver && (
|
||||
<div className="external-drop-overlay">
|
||||
<div className="external-drop-box">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 16V8M8 12l4-4 4 4" />
|
||||
<path d="M20 16.5A4.5 4.5 0 0015.5 12H14a6 6 0 10-11.8 1.5" />
|
||||
</svg>
|
||||
<span>释放以添加到「{
|
||||
selectedCategory === 'all'
|
||||
? '默认分类'
|
||||
: config.categories.find(c => c.id === selectedCategory)?.name ?? '默认分类'
|
||||
}」</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
238
src/components/AddAppDialog.css
Normal file
@@ -0,0 +1,238 @@
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: var(--radius);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 20px 60px rgba(45, 106, 79, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 28px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.05) 100%);
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #2d6a4f 0%, #7cb342 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
font-size: 20px;
|
||||
color: #2d6a4f;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.8);
|
||||
color: white;
|
||||
transform: rotate(90deg);
|
||||
border-color: rgba(239, 68, 68, 1);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #2d6a4f;
|
||||
text-shadow: 0 1px 2px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
color: #2d6a4f;
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 0 2px 4px rgba(45, 106, 79, 0.08);
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: rgba(45, 106, 79, 0.6);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: rgba(45, 106, 79, 0.6);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(45, 106, 79, 0.2),
|
||||
inset 0 2px 4px rgba(45, 106, 79, 0.08);
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-with-button button {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #2d6a4f;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.18);
|
||||
}
|
||||
|
||||
.input-with-button button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(45, 106, 79, 0.22);
|
||||
background: linear-gradient(135deg, #8bd7b8, #cfe7a8);
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.icon-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 20px 28px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: #2d6a4f;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(45, 106, 79, 0.18);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
color: #2d6a4f;
|
||||
border-color: rgba(45, 106, 79, 0.35);
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background: linear-gradient(135deg, #8bd7b8, #cfe7a8);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(45, 106, 79, 0.22);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
color: #2d6a4f;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(129, 199, 132, 0.3);
|
||||
}
|
||||
151
src/components/AddAppDialog.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { App, Category } from '../types';
|
||||
import './AddAppDialog.css';
|
||||
|
||||
interface AddAppDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (app: Omit<App, 'id' | 'shortcutPath' | 'iconKey'>) => void;
|
||||
categories: Category[];
|
||||
editApp?: App | null;
|
||||
mode?: 'folder' | 'program';
|
||||
}
|
||||
|
||||
const AddAppDialog: React.FC<AddAppDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
categories,
|
||||
editApp,
|
||||
mode = 'folder',
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [targetPath, setTargetPath] = useState('');
|
||||
const [category, setCategory] = useState('default');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (editApp) {
|
||||
setName(editApp.name);
|
||||
setTargetPath(editApp.targetPath);
|
||||
setCategory(editApp.category);
|
||||
setDescription(editApp.description || '');
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}, [editApp]);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setTargetPath('');
|
||||
setCategory('default');
|
||||
setDescription('');
|
||||
};
|
||||
|
||||
const handleSelectTarget = async () => {
|
||||
let selected: string | null = null;
|
||||
if (editApp) {
|
||||
selected = await invoke<string | null>('select_target');
|
||||
} else if (mode === 'folder') {
|
||||
selected = await invoke<string | null>('select_folder');
|
||||
} else {
|
||||
selected = await invoke<string | null>('select_single_executable');
|
||||
}
|
||||
if (!selected) return;
|
||||
setTargetPath(selected);
|
||||
if (!name) {
|
||||
const base = selected.split('\\').pop() || selected;
|
||||
setName(base.replace(/\.(exe|bat|cmd|ps1|vbs)$/i, ''));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!targetPath) {
|
||||
alert(mode === 'folder' ? '请选择文件夹' : '请选择程序或脚本文件');
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
name: name || (() => {
|
||||
const base = targetPath.split('\\').pop() || targetPath;
|
||||
return base.replace(/\.(exe|bat|cmd|ps1|vbs)$/i, '') || '未命名';
|
||||
})(),
|
||||
targetPath,
|
||||
category,
|
||||
description: description || undefined,
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" onClick={onClose}>
|
||||
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h2>{editApp ? '编辑应用' : (mode === 'folder' ? '添加文件夹' : '添加程序/脚本')}</h2>
|
||||
<button className="close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="dialog-body">
|
||||
<div className="form-group">
|
||||
<label>目标路径 *</label>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={targetPath}
|
||||
onChange={(e) => setTargetPath(e.target.value)}
|
||||
placeholder={
|
||||
editApp ? '选择 exe / 文件 / 文件夹'
|
||||
: mode === 'folder' ? '选择文件夹'
|
||||
: '选择 exe / bat / cmd / ps1 / vbs'
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
<button onClick={handleSelectTarget}>浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>应用名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="留空使用文件名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>分类</label>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="简单描述应用(可选)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dialog-footer">
|
||||
<button className="btn btn-cancel" onClick={onClose}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
{editApp ? '保存' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAppDialog;
|
||||
157
src/components/AppCard.css
Normal file
@@ -0,0 +1,157 @@
|
||||
.app-card {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 4px rgba(45, 106, 79, 0.1);
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 6px 16px rgba(45, 106, 79, 0.18),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.app-card.dragging {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
background: rgba(168, 230, 207, 0.45);
|
||||
box-shadow: 0 6px 14px rgba(45, 106, 79, 0.2);
|
||||
}
|
||||
|
||||
.app-card.drag-over {
|
||||
background: rgba(220, 237, 193, 0.6);
|
||||
border-color: rgba(45, 106, 79, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(45, 106, 79, 0.35),
|
||||
0 6px 16px rgba(45, 106, 79, 0.18);
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.app-card:hover .app-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.app-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-icon-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #2d6a4f;
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.2);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: #2d6a4f;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
text-shadow: 0 1px 3px rgba(45, 106, 79, 0.15);
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
color: rgba(45, 106, 79, 0.8);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
max-height: 26px;
|
||||
text-shadow: 0 1px 2px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-card:hover .app-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 2px 4px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 8px rgba(45, 106, 79, 0.2);
|
||||
}
|
||||
|
||||
/* 空白格子 */
|
||||
.app-card.empty-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card.empty-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
79
src/components/AppCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { App } from '../types';
|
||||
import './AppCard.css';
|
||||
|
||||
interface AppCardProps {
|
||||
app: App;
|
||||
iconUrl?: string; // base64 data URL from backend
|
||||
onLaunch: (app: App) => void;
|
||||
onEdit?: (app: App) => void;
|
||||
onDelete?: (app: App) => void;
|
||||
onContextMenu?: (app: App, e: React.MouseEvent) => void;
|
||||
isDragging?: boolean;
|
||||
isDragOver?: boolean;
|
||||
onDragStart?: (e: React.MouseEvent | React.TouchEvent) => void;
|
||||
}
|
||||
|
||||
const AppCard: React.FC<AppCardProps> = ({
|
||||
app,
|
||||
iconUrl,
|
||||
onLaunch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onContextMenu,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
onDragStart,
|
||||
}) => {
|
||||
const [imgFailed, setImgFailed] = useState(false)
|
||||
|
||||
// Reset error state when icon URL changes (e.g. after refresh).
|
||||
useEffect(() => {
|
||||
setImgFailed(false)
|
||||
}, [iconUrl])
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isDragging) onLaunch(app)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`app-card ${isDragging ? 'dragging' : ''} ${isDragOver ? 'drag-over' : ''}`}
|
||||
data-app-id={app.id}
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => { e.preventDefault(); onContextMenu?.(app, e) }}
|
||||
onMouseDown={onDragStart}
|
||||
onTouchStart={onDragStart}
|
||||
>
|
||||
<div className="app-icon">
|
||||
{iconUrl && !imgFailed ? (
|
||||
<img src={iconUrl} alt={app.name} onError={() => setImgFailed(true)} />
|
||||
) : (
|
||||
<div className="app-icon-placeholder">
|
||||
{app.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="app-name" title={app.name}>{app.name}</div>
|
||||
{app.description && (
|
||||
<div className="app-description" title={app.description}>{app.description}</div>
|
||||
)}
|
||||
<div className="app-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{onEdit && (
|
||||
<button className="action-btn" onClick={() => onEdit(app)} title="编辑">✏️</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button className="action-btn" onClick={() => onDelete(app)} title="删除">🗑️</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AppCard);
|
||||
|
||||
export const EmptyCard: React.FC = () => (
|
||||
<div className="app-card empty-card">
|
||||
<div className="empty-placeholder"></div>
|
||||
</div>
|
||||
);
|
||||
146
src/components/CategoryManager.css
Normal file
@@ -0,0 +1,146 @@
|
||||
.category-manager {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
box-shadow: 0 8px 32px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.category-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #2d6a4f;
|
||||
text-shadow: 0 2px 4px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
padding: 8px 18px;
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #2d6a4f;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.18);
|
||||
}
|
||||
|
||||
.add-category-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(45, 106, 79, 0.22);
|
||||
background: linear-gradient(135deg, #8bd7b8, #cfe7a8);
|
||||
}
|
||||
|
||||
.add-category-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.add-category-form input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: #2d6a4f;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-form input::placeholder {
|
||||
color: rgba(45, 106, 79, 0.6);
|
||||
}
|
||||
|
||||
.add-category-form input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(45, 106, 79, 0.6);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(45, 106, 79, 0.2);
|
||||
}
|
||||
|
||||
.add-category-form button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-form button:first-of-type {
|
||||
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
||||
color: #2d6a4f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 2px 8px rgba(45, 106, 79, 0.18);
|
||||
}
|
||||
|
||||
.add-category-form button:first-of-type:hover {
|
||||
background: linear-gradient(135deg, #8bd7b8, #cfe7a8);
|
||||
box-shadow: 0 4px 12px rgba(45, 106, 79, 0.22);
|
||||
}
|
||||
|
||||
.add-category-form button:last-of-type {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: #2d6a4f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.add-category-form button:last-of-type:hover {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: #2d6a4f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 2px 8px rgba(45, 106, 79, 0.12);
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
.category-item .delete-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category-item .delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.8);
|
||||
color: white;
|
||||
transform: scale(1.15) rotate(90deg);
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
79
src/components/CategoryManager.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category } from '../types';
|
||||
import './CategoryManager.css';
|
||||
|
||||
interface CategoryManagerProps {
|
||||
categories: Category[];
|
||||
onAddCategory: (name: string) => void;
|
||||
onDeleteCategory: (id: string) => void;
|
||||
}
|
||||
|
||||
const CategoryManager: React.FC<CategoryManagerProps> = ({
|
||||
categories,
|
||||
onAddCategory,
|
||||
onDeleteCategory,
|
||||
}) => {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
onAddCategory(newCategoryName.trim());
|
||||
setNewCategoryName('');
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="category-manager">
|
||||
<div className="category-header">
|
||||
<h3>分类管理</h3>
|
||||
<button
|
||||
className="add-category-btn"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
+ 添加分类
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<div className="add-category-form">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
placeholder="输入分类名称"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleAdd}>确定</button>
|
||||
<button onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewCategoryName('');
|
||||
}}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="category-list">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.id} className="category-item">
|
||||
<span>{cat.name}</span>
|
||||
{cat.id !== 'default' && (
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => onDeleteCategory(cat.id)}
|
||||
title="删除分类"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryManager;
|
||||
67
src/components/GridWithFill.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { EmptyCard } from './AppCard'
|
||||
|
||||
interface GridWithFillProps<T> {
|
||||
items: T[]
|
||||
renderItem: (item: T, index: number) => React.ReactNode
|
||||
minCellWidth?: number
|
||||
keyPrefix: string
|
||||
}
|
||||
|
||||
const GridWithFill = <T,>({ items, renderItem, minCellWidth = 110, keyPrefix }: GridWithFillProps<T>) => {
|
||||
const gridRef = useRef<HTMLDivElement | null>(null)
|
||||
const [columns, setColumns] = useState<number>(1)
|
||||
|
||||
const rafId = useRef<number | null>(null)
|
||||
const lastWidth = useRef<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const calcColumns = (width: number) => {
|
||||
if (width === 0) return
|
||||
// 避免频繁setState引发布局抖动
|
||||
if (Math.abs(width - lastWidth.current) < 1) return
|
||||
lastWidth.current = width
|
||||
const cols = Math.max(1, Math.floor(width / minCellWidth))
|
||||
setColumns((prev) => (prev === cols ? prev : cols))
|
||||
}
|
||||
|
||||
const scheduleCalc = (width: number) => {
|
||||
if (rafId.current) cancelAnimationFrame(rafId.current)
|
||||
rafId.current = requestAnimationFrame(() => calcColumns(width))
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
const width = entry?.contentRect?.width || 0
|
||||
scheduleCalc(width)
|
||||
})
|
||||
|
||||
if (gridRef.current) {
|
||||
// 初始化
|
||||
scheduleCalc(gridRef.current.clientWidth)
|
||||
observer.observe(gridRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafId.current) cancelAnimationFrame(rafId.current)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [minCellWidth])
|
||||
|
||||
const remainder = columns > 0 ? (columns - (items.length % columns)) % columns : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="apps-grid"
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(100px, 1fr))` }}
|
||||
>
|
||||
{items.map((item, idx) => renderItem(item, idx))}
|
||||
{Array.from({ length: remainder }).map((_, i) => (
|
||||
<EmptyCard key={`${keyPrefix}-empty-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GridWithFill
|
||||
25
src/index.css
Normal file
@@ -0,0 +1,25 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
11
src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
25
src/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// 应用程序类型定义
|
||||
export interface App {
|
||||
id: string;
|
||||
name: string;
|
||||
// 原始目标:可以是 exe / 任意文件 / 文件夹
|
||||
targetPath: string;
|
||||
// Windows 下存放在 userData/data/shortcuts 的 .lnk
|
||||
shortcutPath?: string;
|
||||
// 图标缓存 key(默认用 id),渲染端通过 sprout-icon://app/<key>.png 加载
|
||||
iconKey?: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 分类类型定义
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 配置文件类型定义
|
||||
export interface Config {
|
||||
apps: App[];
|
||||
categories: Category[];
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "electron"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
// Tauri dev server: listen on all interfaces, fixed port.
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
host: '0.0.0.0',
|
||||
hmr: {
|
||||
protocol: 'ws',
|
||||
host: 'localhost',
|
||||
},
|
||||
},
|
||||
// Prevent Vite from obscuring Rust compilation errors.
|
||||
clearScreen: false,
|
||||
// Tauri expects a fixed port.
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
}))
|
||||