feat(ui): 集成命令管理面板到 web 和 desktop
- 新增 CommandPanel 组件用于命令列表/搜索/CRUD - 新增 CommandEditor 组件用于命令编辑/创建 - web/desktop 工具栏添加 Terminal 图标按钮 - 点击按钮打开命令管理面板
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* CommandPanel Component
|
||||
*
|
||||
* 命令管理面板:列出、搜索、创建、编辑、删除命令
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Search,
|
||||
Pencil,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
User,
|
||||
FolderOpen,
|
||||
Cog,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '../utils/cn';
|
||||
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
|
||||
import { Button } from '../primitives/Button';
|
||||
import { Input } from '../primitives/Input';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { CommandEditor } from './CommandEditor';
|
||||
import {
|
||||
listCommands,
|
||||
deleteCommand,
|
||||
reloadCommands,
|
||||
type CommandListResponse,
|
||||
} from '../api/client.js';
|
||||
|
||||
interface CommandPanelProps {
|
||||
onClose: () => void;
|
||||
/** 是否启用响应式布局 */
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
type CommandItem = CommandListResponse['commands'][number];
|
||||
|
||||
// Source 图标映射
|
||||
function getSourceIcon(source: string) {
|
||||
switch (source) {
|
||||
case 'builtin':
|
||||
return <Cog size={14} className="text-gray-400" />;
|
||||
case 'user':
|
||||
return <User size={14} className="text-blue-400" />;
|
||||
case 'project':
|
||||
return <FolderOpen size={14} className="text-green-400" />;
|
||||
default:
|
||||
return <Terminal size={14} className="text-gray-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
// Source 标签样式
|
||||
function getSourceBadgeClass(source: string) {
|
||||
switch (source) {
|
||||
case 'builtin':
|
||||
return 'bg-gray-700 text-gray-300';
|
||||
case 'user':
|
||||
return 'bg-blue-500/20 text-blue-400';
|
||||
case 'project':
|
||||
return 'bg-green-500/20 text-green-400';
|
||||
default:
|
||||
return 'bg-gray-700 text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
export function CommandPanel({ onClose, responsive = false }: CommandPanelProps) {
|
||||
// 数据状态
|
||||
const [commands, setCommands] = useState<CommandItem[]>([]);
|
||||
const [filteredCommands, setFilteredCommands] = useState<CommandItem[]>([]);
|
||||
const [stats, setStats] = useState<{ total: number; bySource: Record<string, number> } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [deletingCommand, setDeletingCommand] = useState<string | null>(null);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
|
||||
// 编辑器状态
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingCommand, setEditingCommand] = useState<string | undefined>(undefined);
|
||||
|
||||
// 加载命令列表
|
||||
const loadCommands = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listCommands();
|
||||
if (result.success) {
|
||||
setCommands(result.data.commands);
|
||||
setFilteredCommands(result.data.commands);
|
||||
setStats(result.data.stats);
|
||||
} else {
|
||||
toast.error('Failed to load commands');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load commands');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadCommands();
|
||||
}, [loadCommands]);
|
||||
|
||||
// 搜索过滤
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredCommands(commands);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = commands.filter(
|
||||
(cmd) =>
|
||||
cmd.name.toLowerCase().includes(query) || cmd.description?.toLowerCase().includes(query)
|
||||
);
|
||||
setFilteredCommands(filtered);
|
||||
}, [searchQuery, commands]);
|
||||
|
||||
// 删除命令
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!confirm(`Are you sure you want to delete command "/${name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCommand(name);
|
||||
try {
|
||||
const result = await deleteCommand(name);
|
||||
if (result.success) {
|
||||
toast.success(`Command "/${name}" deleted`);
|
||||
loadCommands();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to delete command');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete command');
|
||||
} finally {
|
||||
setDeletingCommand(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 重新加载命令
|
||||
const handleReload = async () => {
|
||||
setReloading(true);
|
||||
try {
|
||||
const result = await reloadCommands();
|
||||
if (result.success) {
|
||||
toast.success('Commands reloaded');
|
||||
loadCommands();
|
||||
} else {
|
||||
toast.error('Failed to reload commands');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to reload commands');
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开编辑器(创建)
|
||||
const openCreateEditor = () => {
|
||||
setEditingCommand(undefined);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
// 打开编辑器(编辑)
|
||||
const openEditEditor = (name: string) => {
|
||||
setEditingCommand(name);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
// 编辑器保存回调
|
||||
const handleEditorSaved = () => {
|
||||
loadCommands();
|
||||
};
|
||||
|
||||
// Loading 骨架屏
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 flex z-50',
|
||||
responsive
|
||||
? 'items-end md:items-center justify-center'
|
||||
: 'items-center justify-center'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
|
||||
responsive
|
||||
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
|
||||
: 'rounded-lg w-full max-w-2xl mx-4'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-gray-700',
|
||||
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||
)}
|
||||
>
|
||||
{responsive && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold">Commands</h2>
|
||||
{stats && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.total} commands ({stats.bySource.builtin || 0} builtin,{' '}
|
||||
{stats.bySource.user || 0} user, {stats.bySource.project || 0} project)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 border-b border-gray-700',
|
||||
responsive ? 'px-4 md:px-6 py-3' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
/>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search commands..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleReload}
|
||||
disabled={reloading}
|
||||
title="Reload commands"
|
||||
>
|
||||
<RefreshCw size={18} className={cn(reloading && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button onClick={openCreateEditor}>
|
||||
<Plus size={16} className="mr-1" />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Command List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<Terminal size={48} className="mb-4 opacity-50" />
|
||||
<p>
|
||||
{searchQuery
|
||||
? `No commands matching "${searchQuery}"`
|
||||
: 'No commands found'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button variant="ghost" className="mt-4" onClick={openCreateEditor}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create your first command
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
|
||||
>
|
||||
{filteredCommands.map((command) => (
|
||||
<motion.div
|
||||
key={command.name}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg',
|
||||
'hover:bg-gray-900/80 transition-colors group'
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
{getSourceIcon(command.source)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-gray-200">/{command.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 text-xs rounded',
|
||||
getSourceBadgeClass(command.source)
|
||||
)}
|
||||
>
|
||||
{command.source}
|
||||
</span>
|
||||
</div>
|
||||
{command.description && (
|
||||
<p className="text-xs text-gray-500 truncate mt-0.5">
|
||||
{command.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEditEditor(command.name)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
{command.source !== 'builtin' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(command.name)}
|
||||
disabled={deletingCommand === command.name}
|
||||
title="Delete"
|
||||
>
|
||||
{deletingCommand === command.name ? (
|
||||
<div className="animate-spin rounded-full h-3.5 w-3.5 border-t border-b border-red-400" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Command Editor */}
|
||||
{editorOpen && (
|
||||
<CommandEditor
|
||||
commandName={editingCommand}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSaved={handleEditorSaved}
|
||||
responsive={responsive}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user