Initial commit: DeskFloat 桌面透明浮动工具栏

Tauri 2 + React:任务列表、快捷方式、番茄钟、系统托盘、钉子穿透模式等。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wjl
2026-05-26 20:01:43 +08:00
commit 8e9d108995
103 changed files with 12278 additions and 0 deletions
@@ -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;
}
+183
View File
@@ -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>
);
}
+66
View File
@@ -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;
}
+209
View File
@@ -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));
}
+325
View File
@@ -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>
);
}
+72
View File
@@ -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;
}
+95
View File
@@ -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>
);
}
+336
View File
@@ -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));
}
+367
View File
@@ -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>
);
}