init: Sprout desktop app launcher

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 21:57:50 +08:00
commit b8ac8ab8c5
52 changed files with 13106 additions and 0 deletions

13
.cursor/mcp.json Normal file
View 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"
}
}
}
}

View 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`stringMarkdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "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` 工具(使用 CallMcpToolserver 为 CursorMCP对话插件 对应的 identifiertoolName 为 "check_messages"arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。

18
.eslintrc.cjs Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

4
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

32
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

890
src-tauri/src/lib.rs Normal file
View 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
View 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
View 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
View 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
View 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

View 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);
}

View 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
View 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%;
}

View 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>
);

View 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);
}

View 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;

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
tsconfig.json Normal file
View 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
View 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
View 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_'],
}))