Initial commit: DeskFloat 桌面透明浮动工具栏
Tauri 2 + React:任务列表、快捷方式、番茄钟、系统托盘、钉子穿透模式等。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
.indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--fg-color));
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.indicator:hover {
|
||||
background: rgba(var(--border), 0.08);
|
||||
}
|
||||
.indicatorActive {
|
||||
background: rgba(var(--accent), 0.18);
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
.indicator:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.indicator:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.time {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--fg-dim), 0.5);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.dotIdle {
|
||||
background: rgba(var(--fg-dim), 0.4);
|
||||
}
|
||||
.dotWork {
|
||||
background: rgb(var(--success));
|
||||
box-shadow: 0 0 6px rgba(var(--success), 0.6);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.dotBreak {
|
||||
background: rgb(var(--warning));
|
||||
box-shadow: 0 0 6px rgba(var(--warning), 0.6);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(var(--fg-dim), 0.85);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(var(--border), 0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
transition: width 1s linear;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.fillWork {
|
||||
background: rgb(var(--success));
|
||||
}
|
||||
.fillBreak {
|
||||
background: rgb(var(--warning));
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.primaryBtn,
|
||||
.secondaryBtn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.primaryBtn {
|
||||
background: rgba(var(--accent), 0.22);
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
.primaryBtn:hover {
|
||||
background: rgba(var(--accent), 0.32);
|
||||
}
|
||||
.secondaryBtn {
|
||||
background: rgba(var(--border), 0.06);
|
||||
color: rgba(var(--fg-dim), 0.95);
|
||||
}
|
||||
.secondaryBtn:hover {
|
||||
background: rgba(var(--border), 0.12);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
|
||||
.config {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 10px;
|
||||
color: rgba(var(--fg-dim), 0.85);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.field input {
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Play, Pause, RotateCcw, Coffee, Timer as TimerIcon } from "lucide-react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { notifyUser } from "../../api/tauri";
|
||||
import { usePomodoroStore } from "./pomodoroStore";
|
||||
import styles from "./PomodoroPanel.module.css";
|
||||
|
||||
/**
|
||||
* 工具栏中的番茄钟指示器:显示 MM:SS + 阶段点
|
||||
*/
|
||||
export function PomodoroIndicator({
|
||||
active,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const phase = usePomodoroStore((s) => s.phase);
|
||||
const remaining = usePomodoroStore((s) => s.remaining);
|
||||
const running = usePomodoroStore((s) => s.running);
|
||||
|
||||
const text = formatTime(remaining);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(styles.indicator, active && styles.indicatorActive)}
|
||||
title={disabled ? "已锁定" : "番茄钟(点击展开)"}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.dot,
|
||||
running && phase === "work" && styles.dotWork,
|
||||
running && phase === "break" && styles.dotBreak,
|
||||
!running && styles.dotIdle
|
||||
)}
|
||||
/>
|
||||
<span className={styles.time}>{text}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const s = Math.floor(sec % 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 番茄钟展开面板:开始/暂停/重置 + 可调工作/休息时长
|
||||
*/
|
||||
export function PomodoroPanel() {
|
||||
const config = useAppStore((s) => s.pomodoro);
|
||||
const updatePomodoro = useAppStore((s) => s.updatePomodoro);
|
||||
|
||||
const phase = usePomodoroStore((s) => s.phase);
|
||||
const remaining = usePomodoroStore((s) => s.remaining);
|
||||
const running = usePomodoroStore((s) => s.running);
|
||||
const start = usePomodoroStore((s) => s.start);
|
||||
const pause = usePomodoroStore((s) => s.pause);
|
||||
const reset = usePomodoroStore((s) => s.reset);
|
||||
const tick = usePomodoroStore((s) => s.tick);
|
||||
const setConfigInTimer = usePomodoroStore((s) => s.setConfig);
|
||||
|
||||
// 同步配置到计时器
|
||||
useEffect(() => {
|
||||
setConfigInTimer({ workSec: config.workMin * 60, breakSec: config.breakMin * 60 });
|
||||
}, [config.workMin, config.breakMin, setConfigInTimer]);
|
||||
|
||||
// 计时器主 tick
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const id = window.setInterval(() => {
|
||||
const finished = tick();
|
||||
if (finished) {
|
||||
const next = phase === "work" ? "break" : "work";
|
||||
const title = next === "break" ? "工作完成" : "休息结束";
|
||||
const body =
|
||||
next === "break"
|
||||
? `休息 ${config.breakMin} 分钟,放松一下吧。`
|
||||
: `继续专注 ${config.workMin} 分钟。`;
|
||||
void notifyUser(title, body);
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [running, tick, phase, config.workMin, config.breakMin]);
|
||||
|
||||
// 监听全局快捷键事件
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (usePomodoroStore.getState().running) pause();
|
||||
else start();
|
||||
};
|
||||
window.addEventListener("deskfloat:toggle-pomodoro", handler);
|
||||
return () => window.removeEventListener("deskfloat:toggle-pomodoro", handler);
|
||||
}, [start, pause]);
|
||||
|
||||
const [workMin, setWorkMin] = useState(config.workMin);
|
||||
const [breakMin, setBreakMin] = useState(config.breakMin);
|
||||
useEffect(() => setWorkMin(config.workMin), [config.workMin]);
|
||||
useEffect(() => setBreakMin(config.breakMin), [config.breakMin]);
|
||||
|
||||
const total = phase === "work" ? config.workMin * 60 : config.breakMin * 60;
|
||||
const progress = total === 0 ? 0 : ((total - remaining) / total) * 100;
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
{phase === "work" ? <TimerIcon size={14} /> : <Coffee size={14} />}
|
||||
{phase === "work" ? "工作中" : "休息中"}
|
||||
<span className={styles.muted}>{formatTime(remaining)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.progress}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.progressFill,
|
||||
phase === "work" ? styles.fillWork : styles.fillBreak
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
{running ? (
|
||||
<button className={styles.primaryBtn} onClick={pause}>
|
||||
<Pause size={14} /> 暂停
|
||||
</button>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} onClick={start}>
|
||||
<Play size={14} /> 开始
|
||||
</button>
|
||||
)}
|
||||
<button className={styles.secondaryBtn} onClick={reset}>
|
||||
<RotateCcw size={14} /> 重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.config}>
|
||||
<div className={styles.field}>
|
||||
<label>工作 (分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={180}
|
||||
value={workMin}
|
||||
onChange={(e) => setWorkMin(Number(e.target.value))}
|
||||
onBlur={() => {
|
||||
const v = Math.max(1, Math.min(180, workMin || 25));
|
||||
updatePomodoro({ workMin: v });
|
||||
setWorkMin(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>休息 (分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={breakMin}
|
||||
onChange={(e) => setBreakMin(Number(e.target.value))}
|
||||
onBlur={() => {
|
||||
const v = Math.max(1, Math.min(60, breakMin || 5));
|
||||
updatePomodoro({ breakMin: v });
|
||||
setBreakMin(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* 番茄钟内存状态(不持久化,只保留剩余秒数 + 阶段 + 运行标志)
|
||||
*/
|
||||
type Phase = "work" | "break";
|
||||
|
||||
interface PomodoroTimer {
|
||||
phase: Phase;
|
||||
remaining: number;
|
||||
running: boolean;
|
||||
workSec: number;
|
||||
breakSec: number;
|
||||
setConfig: (c: { workSec: number; breakSec: number }) => void;
|
||||
start: () => void;
|
||||
pause: () => void;
|
||||
reset: () => void;
|
||||
/** 1 秒 tick;返回 true 表示该秒发生了阶段切换 */
|
||||
tick: () => boolean;
|
||||
}
|
||||
|
||||
export const usePomodoroStore = create<PomodoroTimer>((set, get) => ({
|
||||
phase: "work",
|
||||
remaining: 25 * 60,
|
||||
running: false,
|
||||
workSec: 25 * 60,
|
||||
breakSec: 5 * 60,
|
||||
|
||||
setConfig({ workSec, breakSec }) {
|
||||
const s = get();
|
||||
set({ workSec, breakSec });
|
||||
if (!s.running) {
|
||||
set({ remaining: s.phase === "work" ? workSec : breakSec });
|
||||
}
|
||||
},
|
||||
|
||||
start() {
|
||||
set({ running: true });
|
||||
},
|
||||
|
||||
pause() {
|
||||
set({ running: false });
|
||||
},
|
||||
|
||||
reset() {
|
||||
const s = get();
|
||||
set({
|
||||
running: false,
|
||||
phase: "work",
|
||||
remaining: s.workSec,
|
||||
});
|
||||
},
|
||||
|
||||
tick() {
|
||||
const s = get();
|
||||
if (!s.running) return false;
|
||||
if (s.remaining > 1) {
|
||||
set({ remaining: s.remaining - 1 });
|
||||
return false;
|
||||
}
|
||||
const nextPhase: Phase = s.phase === "work" ? "break" : "work";
|
||||
const nextRemaining = nextPhase === "work" ? s.workSec : s.breakSec;
|
||||
set({ phase: nextPhase, remaining: nextRemaining });
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,188 @@
|
||||
.panel {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.linkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
color: rgba(var(--fg-dim), 0.95);
|
||||
}
|
||||
.linkBtn:hover {
|
||||
background: rgba(var(--border), 0.08);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--border), 0.03);
|
||||
}
|
||||
|
||||
.rowCol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--border), 0.03);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.muted {
|
||||
color: rgba(var(--fg-dim), 0.8);
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(var(--border), 0.08);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(var(--fg-dim), 0.8);
|
||||
margin-bottom: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(var(--border), 0.1);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--accent));
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.toggleTrack {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(var(--border), 0.2);
|
||||
border-radius: 999px;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.toggleThumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
.toggle input:checked + .toggleTrack {
|
||||
background: rgb(var(--accent));
|
||||
}
|
||||
.toggle input:checked + .toggleTrack .toggleThumb {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
/* Hotkey row */
|
||||
.hotkeyRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.hotkeyLabel {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--fg-color), 0.95);
|
||||
}
|
||||
.hotkeyInput {
|
||||
width: 140px;
|
||||
height: 26px;
|
||||
font-size: 11px;
|
||||
font-family: "Consolas", "Menlo", monospace;
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 10px;
|
||||
color: rgba(var(--fg-dim), 0.7);
|
||||
margin-top: 4px;
|
||||
padding: 0 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--border), 0.1);
|
||||
border: 1px solid rgba(var(--border), 0.12);
|
||||
font-family: "Consolas", "Menlo", monospace;
|
||||
font-size: 10px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
ExternalLink,
|
||||
Power,
|
||||
Pin,
|
||||
Keyboard,
|
||||
Eye,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { openDataDir } from "../../api/tauri";
|
||||
import {
|
||||
disable as disableAutostart,
|
||||
enable as enableAutostart,
|
||||
isEnabled as isAutostartEnabled,
|
||||
} from "@tauri-apps/plugin-autostart";
|
||||
import styles from "./SettingsPanel.module.css";
|
||||
|
||||
/** 设置面板:置顶 / 透明度 / 三个全局热键 / 开机自启 / 数据目录 */
|
||||
export function SettingsPanel() {
|
||||
const settings = useAppStore((s) => s.settings);
|
||||
const updateSettings = useAppStore((s) => s.updateSettings);
|
||||
|
||||
const [autostart, setAutostart] = useState(false);
|
||||
|
||||
// 启动时读取系统当前的自启状态
|
||||
useEffect(() => {
|
||||
isAutostartEnabled()
|
||||
.then((v) => setAutostart(v))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleAutostart = async (v: boolean) => {
|
||||
try {
|
||||
if (v) await enableAutostart();
|
||||
else await disableAutostart();
|
||||
setAutostart(v);
|
||||
updateSettings({ autoStart: v });
|
||||
} catch (e) {
|
||||
console.error("[DeskFloat] autostart toggle failed:", e);
|
||||
alert("开机自启切换失败:" + (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateHotkey = (
|
||||
key: keyof typeof settings.globalHotkeys,
|
||||
val: string
|
||||
) => {
|
||||
updateSettings({
|
||||
globalHotkeys: { ...settings.globalHotkeys, [key]: val },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<SettingsIcon size={14} /> 设置
|
||||
</div>
|
||||
<button
|
||||
className={styles.linkBtn}
|
||||
onClick={() => void openDataDir().catch(() => {})}
|
||||
>
|
||||
<ExternalLink size={11} /> 数据目录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{/* 显示与置顶 */}
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowLabel}>
|
||||
<Pin size={12} /> 始终置顶
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.alwaysOnTop}
|
||||
onChange={(v) => updateSettings({ alwaysOnTop: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowLabel}>
|
||||
<Lock size={12} /> 仅悬浮(锁定,防误触)
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.locked}
|
||||
onChange={(v) => updateSettings({ locked: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.rowCol}>
|
||||
<div className={styles.rowLabel}>
|
||||
<Eye size={12} /> 背景透明度
|
||||
<span className={styles.muted}>
|
||||
{Math.round(settings.opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={settings.opacity}
|
||||
onChange={(e) =>
|
||||
updateSettings({ opacity: Number(e.target.value) })
|
||||
}
|
||||
className={styles.range}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowLabel}>
|
||||
<Power size={12} /> 开机自启
|
||||
</div>
|
||||
<Toggle checked={autostart} onChange={toggleAutostart} />
|
||||
</div>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
{/* 全局快捷键 */}
|
||||
<div className={styles.sectionTitle}>
|
||||
<Keyboard size={12} /> 全局快捷键
|
||||
</div>
|
||||
|
||||
<HotkeyField
|
||||
label="显示 / 隐藏窗口"
|
||||
value={settings.globalHotkeys.showHide}
|
||||
onChange={(v) => updateHotkey("showHide", v)}
|
||||
placeholder="Ctrl+Alt+`"
|
||||
/>
|
||||
<HotkeyField
|
||||
label="新建任务"
|
||||
value={settings.globalHotkeys.newTask}
|
||||
onChange={(v) => updateHotkey("newTask", v)}
|
||||
placeholder="Ctrl+Alt+T"
|
||||
/>
|
||||
<HotkeyField
|
||||
label="番茄钟 开始/暂停"
|
||||
value={settings.globalHotkeys.togglePomodoro}
|
||||
onChange={(v) => updateHotkey("togglePomodoro", v)}
|
||||
placeholder="Ctrl+Alt+P"
|
||||
/>
|
||||
|
||||
<div className={styles.hint}>
|
||||
格式:修饰键 + 字母,例如{" "}
|
||||
<span className={styles.kbd}>Ctrl+Alt+T</span>。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 自定义开关组件 */
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={styles.toggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.toggleTrack}>
|
||||
<span className={styles.toggleThumb} />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单个全局快捷键编辑行 */
|
||||
function HotkeyField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
|
||||
return (
|
||||
<div className={styles.hotkeyRow}>
|
||||
<span className={styles.hotkeyLabel}>{label}</span>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (draft !== value) onChange(draft.trim());
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className={styles.hotkeyInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
.barBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--fg-color));
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.barBtn span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.barBtn:hover {
|
||||
background: rgba(var(--border), 0.1);
|
||||
}
|
||||
.barBtn:active {
|
||||
background: rgba(var(--accent), 0.18);
|
||||
}
|
||||
|
||||
.barAddBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--fg-dim), 0.8);
|
||||
border: 1px dashed rgba(var(--border), 0.18);
|
||||
}
|
||||
.barAddBtn:hover {
|
||||
color: rgb(var(--accent));
|
||||
border-color: rgba(var(--accent), 0.4);
|
||||
}
|
||||
|
||||
.emptyBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(var(--fg-dim), 0.9);
|
||||
border: 1px dashed rgba(var(--border), 0.18);
|
||||
font-size: 11px;
|
||||
}
|
||||
.emptyBtn:hover {
|
||||
color: rgb(var(--accent));
|
||||
border-color: rgba(var(--accent), 0.4);
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(var(--fg-dim), 0.8);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.addBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--accent), 0.18);
|
||||
color: rgb(var(--accent));
|
||||
font-size: 11px;
|
||||
}
|
||||
.addBtn:hover {
|
||||
background: rgba(var(--accent), 0.28);
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: rgba(var(--fg-dim), 0.7);
|
||||
font-size: 11px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(var(--border), 0.05);
|
||||
}
|
||||
|
||||
.rowIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--accent), 0.12);
|
||||
color: rgb(var(--accent));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rowMain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rowLabel {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.rowMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: rgba(var(--fg-dim), 0.85);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.typeTag {
|
||||
background: rgba(var(--border), 0.08);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.target {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.row:hover .rowActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--fg-dim), 0.9);
|
||||
}
|
||||
.iconBtn:hover {
|
||||
background: rgba(var(--border), 0.1);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.danger:hover {
|
||||
background: rgba(var(--danger), 0.18);
|
||||
color: rgb(var(--danger));
|
||||
}
|
||||
|
||||
/* Inline Editor */
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(var(--border), 0.06);
|
||||
}
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(var(--fg-dim), 0.95);
|
||||
}
|
||||
.backBtn:hover {
|
||||
background: rgba(var(--border), 0.08);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.editorTitle {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editorBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.field label {
|
||||
font-size: 10px;
|
||||
color: rgba(var(--fg-dim), 0.85);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.field input {
|
||||
height: 30px;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.typeRow {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.typeBtn {
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--border), 0.05);
|
||||
color: rgba(var(--fg-dim), 0.95);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.typeBtn:hover {
|
||||
background: rgba(var(--border), 0.1);
|
||||
}
|
||||
.typeBtnActive {
|
||||
background: rgba(var(--accent), 0.2);
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
|
||||
.iconGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(14, 1fr);
|
||||
gap: 4px;
|
||||
max-height: 84px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
background: rgba(var(--border), 0.04);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.iconCell {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--fg-dim), 0.9);
|
||||
min-width: 24px;
|
||||
}
|
||||
.iconCell:hover {
|
||||
background: rgba(var(--border), 0.1);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.iconCellActive {
|
||||
background: rgba(var(--accent), 0.22);
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
|
||||
.silentRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--border), 0.04);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.silentRow input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: rgb(var(--accent));
|
||||
margin: 0;
|
||||
}
|
||||
.silentHint {
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
color: rgba(var(--fg-dim), 0.7);
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(var(--border), 0.06);
|
||||
}
|
||||
.cancelBtn,
|
||||
.saveBtn {
|
||||
padding: 6px 18px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cancelBtn {
|
||||
color: rgba(var(--fg-dim), 0.95);
|
||||
background: rgba(var(--border), 0.05);
|
||||
}
|
||||
.cancelBtn:hover {
|
||||
background: rgba(var(--border), 0.1);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.saveBtn {
|
||||
background: rgb(var(--accent));
|
||||
color: white;
|
||||
}
|
||||
.saveBtn:hover {
|
||||
background: rgb(var(--accent-strong));
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Plus, Pencil, Trash2, Zap, ArrowLeft } from "lucide-react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import type { Shortcut, ShortcutType } from "../../store/types";
|
||||
import { runShortcut } from "../../api/tauri";
|
||||
import { getIcon, ICON_OPTIONS } from "./iconMap";
|
||||
import styles from "./ShortcutsPanel.module.css";
|
||||
|
||||
/**
|
||||
* 主工具栏上的横向快捷按钮区
|
||||
*/
|
||||
export function ShortcutsBar({ disabled = false }: { disabled?: boolean }) {
|
||||
const shortcuts = useAppStore((s) => s.shortcuts);
|
||||
const setActivePanel = useAppStore((s) => s.setActivePanel);
|
||||
|
||||
async function exec(s: Shortcut) {
|
||||
try {
|
||||
await runShortcut(s);
|
||||
} catch (e) {
|
||||
console.error("[DeskFloat] run shortcut failed", e);
|
||||
alert(`执行失败:${(e as Error).message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shortcuts.length === 0) {
|
||||
return (
|
||||
<button
|
||||
className={styles.emptyBtn}
|
||||
title="添加你的第一个快捷方式"
|
||||
onClick={() => setActivePanel("shortcuts")}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus size={12} /> 添加快捷方式
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shortcuts.map((s) => {
|
||||
const Icon = getIcon(s.icon);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
className={styles.barBtn}
|
||||
title={`${s.label}\n${s.type.toUpperCase()}: ${s.target}`}
|
||||
onClick={() => exec(s)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setActivePanel("shortcuts");
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className={styles.barAddBtn}
|
||||
title="管理 / 新增快捷方式"
|
||||
onClick={() => setActivePanel("shortcuts")}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开的"管理快捷方式"面板:列表视图 / 编辑视图(inline 切换)
|
||||
*/
|
||||
export function ShortcutsPanel() {
|
||||
const shortcuts = useAppStore((s) => s.shortcuts);
|
||||
const removeShortcut = useAppStore((s) => s.removeShortcut);
|
||||
|
||||
// null = 列表视图;"new" = 新增;Shortcut = 编辑现有
|
||||
const [editing, setEditing] = useState<Shortcut | "new" | null>(null);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<ShortcutEditor
|
||||
initial={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Zap size={14} /> 快捷方式
|
||||
<span className={styles.muted}>{shortcuts.length}</span>
|
||||
</div>
|
||||
<button className={styles.addBtn} onClick={() => setEditing("new")}>
|
||||
<Plus size={12} /> 新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.list}>
|
||||
{shortcuts.length === 0 && (
|
||||
<div className={styles.empty}>暂无快捷方式,点击右上角 “+ 新增”</div>
|
||||
)}
|
||||
{shortcuts.map((s) => {
|
||||
const Icon = getIcon(s.icon);
|
||||
return (
|
||||
<div key={s.id} className={styles.row}>
|
||||
<div className={styles.rowIcon}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.rowLabel}>{s.label}</div>
|
||||
<div className={styles.rowMeta}>
|
||||
<span className={styles.typeTag}>{s.type}</span>
|
||||
<span className={styles.target}>{s.target}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.rowActions}>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
title="编辑"
|
||||
onClick={() => setEditing(s)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.iconBtn, styles.danger)}
|
||||
title="删除"
|
||||
onClick={() => removeShortcut(s.id)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutEditor({
|
||||
initial,
|
||||
onClose,
|
||||
}: {
|
||||
initial: Shortcut | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const addShortcut = useAppStore((s) => s.addShortcut);
|
||||
const updateShortcut = useAppStore((s) => s.updateShortcut);
|
||||
|
||||
const [label, setLabel] = useState(initial?.label ?? "");
|
||||
const [icon, setIcon] = useState(initial?.icon ?? "zap");
|
||||
const [type, setType] = useState<ShortcutType>(initial?.type ?? "url");
|
||||
const [target, setTarget] = useState(initial?.target ?? "");
|
||||
const [argsText, setArgsText] = useState((initial?.args ?? []).join(" "));
|
||||
const [cwd, setCwd] = useState(initial?.cwd ?? "");
|
||||
const [silent, setSilent] = useState(initial?.silent ?? false);
|
||||
|
||||
function save() {
|
||||
if (!label.trim() || !target.trim()) {
|
||||
alert("名称和目标都不能为空");
|
||||
return;
|
||||
}
|
||||
const args =
|
||||
argsText
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean) ?? [];
|
||||
const data = {
|
||||
label: label.trim(),
|
||||
icon,
|
||||
type,
|
||||
target: target.trim(),
|
||||
args,
|
||||
cwd: cwd.trim() || undefined,
|
||||
silent: type === "cmd" || type === "powershell" ? silent : undefined,
|
||||
};
|
||||
if (initial) updateShortcut(initial.id, data);
|
||||
else addShortcut(data);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const placeholderByType: Record<ShortcutType, string> = {
|
||||
url: "https://example.com",
|
||||
app: "C:\\Path\\To\\App.exe",
|
||||
cmd: "echo hello && pause",
|
||||
powershell: "Get-Process | Select-Object -First 5",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.editor}>
|
||||
<div className={styles.editorHeader}>
|
||||
<button
|
||||
className={styles.backBtn}
|
||||
onClick={onClose}
|
||||
title="返回列表"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<div className={styles.editorTitle}>
|
||||
{initial ? "编辑快捷方式" : "新增快捷方式"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.editorBody}>
|
||||
<div className={styles.grid2}>
|
||||
<div className={styles.field}>
|
||||
<label>名称</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="按钮显示文本"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>类型</label>
|
||||
<div className={styles.typeRow}>
|
||||
{(["url", "app", "cmd", "powershell"] as ShortcutType[]).map(
|
||||
(t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={clsx(
|
||||
styles.typeBtn,
|
||||
type === t && styles.typeBtnActive
|
||||
)}
|
||||
onClick={() => setType(t)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>目标</label>
|
||||
<input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder={placeholderByType[type]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(type === "app" || type === "cmd" || type === "powershell") && (
|
||||
<>
|
||||
<div className={styles.grid2}>
|
||||
{type === "app" ? (
|
||||
<div className={styles.field}>
|
||||
<label>参数(空格分隔,可选)</label>
|
||||
<input
|
||||
value={argsText}
|
||||
onChange={(e) => setArgsText(e.target.value)}
|
||||
placeholder="--flag value"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.field} />
|
||||
)}
|
||||
<div className={styles.field}>
|
||||
<label>工作目录(可选)</label>
|
||||
<input
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
placeholder="D:\\projects\\app"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(type === "cmd" || type === "powershell") && (
|
||||
<label className={styles.silentRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={silent}
|
||||
onChange={(e) => setSilent(e.target.checked)}
|
||||
/>
|
||||
<span>静默执行(隐藏控制台窗口)</span>
|
||||
<span className={styles.silentHint}>
|
||||
默认会弹出控制台便于查看输出;勾选后则后台静默运行
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>图标</label>
|
||||
<div className={styles.iconGrid}>
|
||||
{ICON_OPTIONS.map((o) => {
|
||||
const Icon = o.Icon;
|
||||
return (
|
||||
<button
|
||||
key={o.name}
|
||||
className={clsx(
|
||||
styles.iconCell,
|
||||
icon === o.name && styles.iconCellActive
|
||||
)}
|
||||
title={o.name}
|
||||
onClick={() => setIcon(o.name)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.editorActions}>
|
||||
<button className={styles.cancelBtn} onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className={styles.saveBtn} onClick={save}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Globe,
|
||||
Github,
|
||||
Code,
|
||||
Terminal,
|
||||
FileText,
|
||||
Hammer,
|
||||
Play,
|
||||
Folder,
|
||||
Music,
|
||||
Image,
|
||||
Mail,
|
||||
Search,
|
||||
Zap,
|
||||
Star,
|
||||
Heart,
|
||||
Bookmark,
|
||||
Settings,
|
||||
Chrome,
|
||||
Cpu,
|
||||
Bot,
|
||||
Box,
|
||||
Cloud,
|
||||
Coffee,
|
||||
Calendar,
|
||||
Send,
|
||||
Download,
|
||||
Power,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 可在编辑器中选择的图标白名单(保持小而精,避免引入全量图标) */
|
||||
export const ICON_OPTIONS: { name: string; Icon: LucideIcon }[] = [
|
||||
{ name: "globe", Icon: Globe },
|
||||
{ name: "github", Icon: Github },
|
||||
{ name: "code", Icon: Code },
|
||||
{ name: "terminal", Icon: Terminal },
|
||||
{ name: "file-text", Icon: FileText },
|
||||
{ name: "hammer", Icon: Hammer },
|
||||
{ name: "play", Icon: Play },
|
||||
{ name: "folder", Icon: Folder },
|
||||
{ name: "music", Icon: Music },
|
||||
{ name: "image", Icon: Image },
|
||||
{ name: "mail", Icon: Mail },
|
||||
{ name: "search", Icon: Search },
|
||||
{ name: "zap", Icon: Zap },
|
||||
{ name: "star", Icon: Star },
|
||||
{ name: "heart", Icon: Heart },
|
||||
{ name: "bookmark", Icon: Bookmark },
|
||||
{ name: "settings", Icon: Settings },
|
||||
{ name: "chrome", Icon: Chrome },
|
||||
{ name: "cpu", Icon: Cpu },
|
||||
{ name: "bot", Icon: Bot },
|
||||
{ name: "box", Icon: Box },
|
||||
{ name: "cloud", Icon: Cloud },
|
||||
{ name: "coffee", Icon: Coffee },
|
||||
{ name: "calendar", Icon: Calendar },
|
||||
{ name: "send", Icon: Send },
|
||||
{ name: "download", Icon: Download },
|
||||
{ name: "power", Icon: Power },
|
||||
];
|
||||
|
||||
const MAP: Record<string, LucideIcon> = ICON_OPTIONS.reduce(
|
||||
(acc, o) => ({ ...acc, [o.name]: o.Icon }),
|
||||
{} as Record<string, LucideIcon>
|
||||
);
|
||||
|
||||
/** 根据 icon 名取组件,找不到时回退到 Zap */
|
||||
export function getIcon(name: string | undefined): LucideIcon {
|
||||
if (!name) return Zap;
|
||||
return MAP[name] ?? Zap;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
.root {
|
||||
width: fit-content;
|
||||
min-width: 180px;
|
||||
max-width: 420px;
|
||||
padding: 6px 10px 8px;
|
||||
pointer-events: none; /* 列表区域不拦截鼠标,穿透到下层 */
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 仅钉子按钮可接收点击(配合 Rust 热区轮询) */
|
||||
.pinBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--accent));
|
||||
background: rgba(var(--accent), 0.25);
|
||||
border: 1px solid rgba(var(--accent), 0.45);
|
||||
pointer-events: auto;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.12s ease, background 0.12s;
|
||||
}
|
||||
.pinBtn:hover {
|
||||
background: rgba(var(--accent), 0.4);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.pinBtnActive {
|
||||
box-shadow: 0 0 10px rgba(var(--accent), 0.45);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--fg-color), 0.75);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--accent));
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: rgba(var(--fg-color), 0.92);
|
||||
word-break: break-word;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--fg-dim), 0.7);
|
||||
padding: 8px 6px;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Pin } from "lucide-react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { setPinMode, setWindowSize, updatePinHotzone } from "../../api/tauri";
|
||||
import styles from "./PinnedTasksOverlay.module.css";
|
||||
|
||||
const MIN_W = 200;
|
||||
const MAX_W = 420;
|
||||
const ROW_H = 28;
|
||||
const CHROME_H = 44;
|
||||
|
||||
/**
|
||||
* 任务钉子模式:极透明浮层,只列出未完成任务;窗口默认点击穿透,
|
||||
* 鼠标移到钉子按钮上才可点击退出。
|
||||
*/
|
||||
export function PinnedTasksOverlay() {
|
||||
const tasks = useAppStore((s) => s.tasks);
|
||||
const toggleTaskPinMode = useAppStore((s) => s.toggleTaskPinMode);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const pinRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const activeTasks = tasks
|
||||
.filter((t) => !t.done)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
// 进入/退出时同步系统点击穿透与窗口尺寸
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("task-pin-mode");
|
||||
void setPinMode(true);
|
||||
return () => {
|
||||
document.documentElement.classList.remove("task-pin-mode");
|
||||
void setPinMode(false);
|
||||
void updatePinHotzone(0, 0, 0, 0, false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const syncLayout = () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const w = Math.min(MAX_W, Math.max(MIN_W, root.scrollWidth + 16));
|
||||
const h = CHROME_H + Math.max(activeTasks.length, 1) * ROW_H + 8;
|
||||
void setWindowSize(w, h);
|
||||
|
||||
const pin = pinRef.current;
|
||||
if (pin) {
|
||||
const r = pin.getBoundingClientRect();
|
||||
void updatePinHotzone(r.left, r.top, r.width, r.height, true);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncLayout();
|
||||
}, [activeTasks.length, activeTasks.map((t) => t.title).join("|")]);
|
||||
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(() => syncLayout());
|
||||
if (rootRef.current) ro.observe(rootRef.current);
|
||||
const t = window.setInterval(syncLayout, 500);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.clearInterval(t);
|
||||
};
|
||||
}, [activeTasks.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<div className={styles.header}>
|
||||
<button
|
||||
ref={pinRef}
|
||||
type="button"
|
||||
className={clsx(styles.pinBtn, styles.pinBtnActive)}
|
||||
title="取消钉子模式"
|
||||
onClick={() => toggleTaskPinMode()}
|
||||
>
|
||||
<Pin size={16} fill="currentColor" />
|
||||
</button>
|
||||
<span className={styles.label}>进行中 {activeTasks.length}</span>
|
||||
</div>
|
||||
|
||||
<ul className={styles.list}>
|
||||
{activeTasks.length === 0 ? (
|
||||
<li className={styles.empty}>暂无未完成任务</li>
|
||||
) : (
|
||||
activeTasks.map((t) => (
|
||||
<li key={t.id} className={styles.item}>
|
||||
<span className={styles.dot} />
|
||||
<span className={styles.title}>{t.title}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
.triggerGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--fg-color));
|
||||
transition: background 0.12s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pinTrigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(var(--fg-dim), 0.9);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.pinTrigger:hover:not(:disabled) {
|
||||
background: rgba(var(--border), 0.1);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.pinTriggerOn {
|
||||
color: rgb(var(--accent));
|
||||
background: rgba(var(--accent), 0.2);
|
||||
}
|
||||
.pinTrigger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.trigger:hover {
|
||||
background: rgba(var(--border), 0.08);
|
||||
}
|
||||
.triggerActive {
|
||||
background: rgba(var(--accent), 0.18);
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
.trigger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.trigger:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: rgba(var(--accent), 0.25);
|
||||
color: rgb(var(--accent));
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
/* 固定高度:避免 active/done tab 切换时列表长度变化引发窗口尺寸抖动 */
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
/* ============ Tabs ============ */
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-bottom: 6px;
|
||||
position: relative;
|
||||
border-bottom: 1px solid rgba(var(--border), 0.06);
|
||||
}
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(var(--fg-dim), 0.95);
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
.tab:hover {
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.tab:active {
|
||||
transform: scale(0.96);
|
||||
transition: transform 0.08s ease, color 0.18s ease;
|
||||
}
|
||||
.tabActive {
|
||||
color: rgb(var(--accent));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 底部滑块指示器:跟随激活 tab 平滑滑动 */
|
||||
.tabIndicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: rgb(var(--accent));
|
||||
border-radius: 2px;
|
||||
transition: transform 0.28s cubic-bezier(0.4, 0.0, 0.2, 1),
|
||||
width 0.28s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 6px rgba(var(--accent), 0.4);
|
||||
}
|
||||
.tabCount {
|
||||
background: rgba(var(--border), 0.12);
|
||||
color: rgba(var(--fg-color), 0.9);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
line-height: 14px;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.tabActive .tabCount {
|
||||
background: rgba(var(--accent), 0.3);
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
/* 未激活的「已完成」用黄色脉冲气泡提醒待清理;激活后回归 accent 配色,
|
||||
避免颜色被静态徽章吃掉,导致切换"看不出动效" */
|
||||
.tabCountAlert {
|
||||
background: rgba(var(--warning), 0.25);
|
||||
color: rgb(var(--warning));
|
||||
animation: bubble 1.6s ease-in-out infinite;
|
||||
}
|
||||
.tabActive .tabCountAlert {
|
||||
background: rgba(var(--accent), 0.3);
|
||||
color: rgb(var(--accent));
|
||||
animation: none;
|
||||
}
|
||||
@keyframes bubble {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--danger), 0.12);
|
||||
color: rgb(var(--danger));
|
||||
font-size: 12px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.clearBtn:hover {
|
||||
background: rgba(var(--danger), 0.22);
|
||||
}
|
||||
|
||||
/* ============ Input ============ */
|
||||
.inputRow {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.inputRow input {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
min-width: 0;
|
||||
padding: 6px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.addBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--accent), 0.18);
|
||||
color: rgb(var(--accent));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.addBtn:hover {
|
||||
background: rgba(var(--accent), 0.28);
|
||||
}
|
||||
|
||||
/* ============ List ============ */
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0; /* flex 子项允许收缩,配合 overflow-y 滚动 */
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: rgba(var(--fg-dim), 0.7);
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(var(--border), 0.04);
|
||||
}
|
||||
.rowDone .title2 {
|
||||
color: rgba(var(--fg-dim), 0.55);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(var(--fg-dim), 0.5);
|
||||
opacity: 0;
|
||||
cursor: grab;
|
||||
width: 18px;
|
||||
height: 22px;
|
||||
}
|
||||
.row:hover .grip {
|
||||
opacity: 1;
|
||||
}
|
||||
.grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.check {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.check input {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.check span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid rgba(var(--border), 0.3);
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.check input:checked + span {
|
||||
background: rgb(var(--accent));
|
||||
border-color: rgb(var(--accent));
|
||||
}
|
||||
.check input:checked + span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.title2 {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
|
||||
.editInput {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.row:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--fg-dim), 0.9);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.iconBtn:hover {
|
||||
background: rgba(var(--border), 0.1);
|
||||
color: rgb(var(--fg-color));
|
||||
}
|
||||
.danger:hover {
|
||||
background: rgba(var(--danger), 0.18);
|
||||
color: rgb(var(--danger));
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
CheckSquare,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Trash,
|
||||
GripHorizontal,
|
||||
ListTodo,
|
||||
CheckCircle2,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import type { Task } from "../../store/types";
|
||||
import styles from "./TasksPanel.module.css";
|
||||
|
||||
type View = "active" | "done";
|
||||
|
||||
/**
|
||||
* 工具栏上的"任务"按钮(含未完成徽标)
|
||||
*/
|
||||
export function TasksTrigger({
|
||||
active,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const pending = useAppStore((s) => s.tasks.filter((t) => !t.done).length);
|
||||
const taskPinMode = useAppStore((s) => s.taskPinMode);
|
||||
const toggleTaskPinMode = useAppStore((s) => s.toggleTaskPinMode);
|
||||
|
||||
return (
|
||||
<div className={styles.triggerGroup}>
|
||||
<button
|
||||
className={clsx(styles.trigger, active && styles.triggerActive)}
|
||||
title={disabled ? "已锁定" : "任务列表"}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
<span>任务</span>
|
||||
{pending > 0 && <span className={styles.badge}>{pending}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.pinTrigger, taskPinMode && styles.pinTriggerOn)}
|
||||
title="钉子模式:透明悬浮,仅显示未完成任务,点击穿透下层"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disabled) toggleTaskPinMode();
|
||||
}}
|
||||
>
|
||||
<Pin size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开的任务面板:active(未完成) / done(已完成) 两个视图切换
|
||||
* - active:输入框 + 未完成列表(默认)
|
||||
* - done:已完成列表 + 一键清除
|
||||
*/
|
||||
export function TasksPanel() {
|
||||
const tasks = useAppStore((s) => s.tasks);
|
||||
const addTask = useAppStore((s) => s.addTask);
|
||||
const reorderTasks = useAppStore((s) => s.reorderTasks);
|
||||
const clearDoneTasks = useAppStore((s) => s.clearDoneTasks);
|
||||
|
||||
const [view, setViewRaw] = useState<View>("active");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
// 底部滑块动画:测量当前激活 tab 的位置/宽度,给滑块设 transform + width
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const [indicator, setIndicator] = useState({ left: 0, width: 0 });
|
||||
useLayoutEffect(() => {
|
||||
if (!tabsRef.current) return;
|
||||
const el = tabsRef.current.querySelector(
|
||||
`[data-tab="${view}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
setIndicator({ left: el.offsetLeft, width: el.offsetWidth });
|
||||
}, [view, tasks.length]); // tasks 变化会改变徽标宽度,需要重测
|
||||
|
||||
/** 幂等切换:当前已是目标 view 时直接 return,避免重复点击触发任何 re-render/动画 */
|
||||
const setView = (next: View) => {
|
||||
if (next === view) return;
|
||||
setViewRaw(next);
|
||||
};
|
||||
|
||||
// 响应全局快捷键的聚焦事件,并自动切回 active 视图
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setViewRaw("active");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
window.addEventListener("deskfloat:focus-new-task", handler);
|
||||
if (view === "active") inputRef.current?.focus();
|
||||
return () =>
|
||||
window.removeEventListener("deskfloat:focus-new-task", handler);
|
||||
}, [view]);
|
||||
|
||||
const activeTasks = useMemo(
|
||||
() => tasks.filter((t) => !t.done).sort((a, b) => a.order - b.order),
|
||||
[tasks]
|
||||
);
|
||||
const doneTasks = useMemo(
|
||||
() => tasks.filter((t) => t.done).sort((a, b) => b.createdAt - a.createdAt),
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const visibleTasks = view === "active" ? activeTasks : doneTasks;
|
||||
const ids = useMemo(() => visibleTasks.map((t) => t.id), [visibleTasks]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
||||
);
|
||||
|
||||
/** 仅 active 视图允许拖拽排序 */
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
if (view !== "active") return;
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = ids.indexOf(active.id as string);
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
// 仅对未完成任务重排序,已完成位置保持不变
|
||||
const newActiveIds = arrayMove(ids, oldIndex, newIndex);
|
||||
const doneIds = doneTasks.map((t) => t.id);
|
||||
reorderTasks([...newActiveIds, ...doneIds]);
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
addTask(draft);
|
||||
setDraft("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") onSubmit();
|
||||
}
|
||||
|
||||
function handleClearDone() {
|
||||
if (doneTasks.length === 0) return;
|
||||
if (window.confirm(`确认清除 ${doneTasks.length} 条已完成任务?`)) {
|
||||
clearDoneTasks();
|
||||
// 清完跳回 active
|
||||
setView("active");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.tabs} ref={tabsRef}>
|
||||
<button
|
||||
data-tab="active"
|
||||
className={clsx(styles.tab, view === "active" && styles.tabActive)}
|
||||
onClick={() => setView("active")}
|
||||
>
|
||||
<ListTodo size={14} />
|
||||
<span>进行中</span>
|
||||
<span className={styles.tabCount}>{activeTasks.length}</span>
|
||||
</button>
|
||||
<button
|
||||
data-tab="done"
|
||||
className={clsx(styles.tab, view === "done" && styles.tabActive)}
|
||||
onClick={() => setView("done")}
|
||||
>
|
||||
<CheckCircle2 size={14} />
|
||||
<span>已完成</span>
|
||||
{doneTasks.length > 0 && (
|
||||
<span
|
||||
className={clsx(styles.tabCount, styles.tabCountAlert)}
|
||||
title="待清理"
|
||||
>
|
||||
{doneTasks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 底部滑块指示器:transform 平滑滑动 */}
|
||||
<div
|
||||
className={styles.tabIndicator}
|
||||
style={{
|
||||
transform: `translateX(${indicator.left}px)`,
|
||||
width: indicator.width,
|
||||
}}
|
||||
/>
|
||||
|
||||
{view === "done" && doneTasks.length > 0 && (
|
||||
<button
|
||||
className={styles.clearBtn}
|
||||
onClick={handleClearDone}
|
||||
title="一键清除所有已完成任务"
|
||||
>
|
||||
<Trash size={12} />
|
||||
<span>一键清除</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "active" && (
|
||||
<div className={styles.inputRow}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="新增任务(回车保存)"
|
||||
/>
|
||||
<button className={styles.addBtn} onClick={onSubmit} title="新增">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{visibleTasks.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
{view === "active"
|
||||
? "暂无进行中的任务,回车快速新增 ↑"
|
||||
: "还没有完成的任务,加油 💪"}
|
||||
</div>
|
||||
) : view === "active" ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{visibleTasks.map((t) => (
|
||||
<TaskRow key={t.id} task={t} sortable />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
visibleTasks.map((t) => <TaskRow key={t.id} task={t} sortable={false} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单行任务渲染。sortable=false 时去掉拖拽柄 */
|
||||
function TaskRow({ task, sortable }: { task: Task; sortable: boolean }) {
|
||||
const toggleTask = useAppStore((s) => s.toggleTask);
|
||||
const removeTask = useAppStore((s) => s.removeTask);
|
||||
const updateTask = useAppStore((s) => s.updateTask);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(task.title);
|
||||
|
||||
const sortableHook = useSortable({ id: task.id, disabled: !sortable });
|
||||
|
||||
const style = sortable
|
||||
? {
|
||||
transform: CSS.Transform.toString(sortableHook.transform),
|
||||
transition: sortableHook.transition,
|
||||
opacity: sortableHook.isDragging ? 0.6 : 1,
|
||||
}
|
||||
: {};
|
||||
|
||||
function commit() {
|
||||
if (text.trim() && text !== task.title) updateTask(task.id, text);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sortable ? sortableHook.setNodeRef : undefined}
|
||||
style={style}
|
||||
className={clsx(styles.row, task.done && styles.rowDone)}
|
||||
>
|
||||
{sortable && (
|
||||
<button
|
||||
className={styles.grip}
|
||||
{...sortableHook.attributes}
|
||||
{...sortableHook.listeners}
|
||||
title="拖动排序"
|
||||
>
|
||||
<GripHorizontal size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label className={styles.check}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.done}
|
||||
onChange={() => toggleTask(task.id)}
|
||||
/>
|
||||
<span />
|
||||
</label>
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className={styles.editInput}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
setText(task.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={styles.title2}
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
title="双击编辑"
|
||||
>
|
||||
{task.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
{!task.done && (
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={() => setEditing((v) => !v)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={clsx(styles.iconBtn, styles.danger)}
|
||||
onClick={() => removeTask(task.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user