feat(ui): 集成命令管理面板到 web 和 desktop
- 新增 CommandPanel 组件用于命令列表/搜索/CRUD - 新增 CommandEditor 组件用于命令编辑/创建 - web/desktop 工具栏添加 Terminal 图标按钮 - 点击按钮打开命令管理面板
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
Sidebar,
|
Sidebar,
|
||||||
FileBrowser,
|
FileBrowser,
|
||||||
ConfigPanel,
|
ConfigPanel,
|
||||||
|
CommandPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -19,6 +20,7 @@ export function App() {
|
|||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -89,6 +91,7 @@ export function App() {
|
|||||||
showFileBrowser={showFileBrowser}
|
showFileBrowser={showFileBrowser}
|
||||||
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
||||||
onOpenConfig={() => setShowConfig(true)}
|
onOpenConfig={() => setShowConfig(true)}
|
||||||
|
onOpenCommands={() => setShowCommands(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -112,6 +115,9 @@ export function App() {
|
|||||||
{/* 配置面板 */}
|
{/* 配置面板 */}
|
||||||
{showConfig && <ConfigPanel onClose={() => setShowConfig(false)} />}
|
{showConfig && <ConfigPanel onClose={() => setShowConfig(false)} />}
|
||||||
|
|
||||||
|
{/* 命令面板 */}
|
||||||
|
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} />}
|
||||||
|
|
||||||
{/* Toast 通知 */}
|
{/* Toast 通知 */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { WifiOff, MessageSquare, Settings, FolderOpen } from 'lucide-react';
|
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -20,6 +20,7 @@ interface ChatPageProps {
|
|||||||
showFileBrowser?: boolean;
|
showFileBrowser?: boolean;
|
||||||
onToggleFileBrowser?: () => void;
|
onToggleFileBrowser?: () => void;
|
||||||
onOpenConfig?: () => void;
|
onOpenConfig?: () => void;
|
||||||
|
onOpenCommands?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -28,6 +29,7 @@ export function ChatPage({
|
|||||||
showFileBrowser,
|
showFileBrowser,
|
||||||
onToggleFileBrowser,
|
onToggleFileBrowser,
|
||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
|
onOpenCommands,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -121,8 +123,21 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
|
|
||||||
{/* 工具栏按钮 */}
|
{/* 工具栏按钮 */}
|
||||||
{(onOpenConfig || onToggleFileBrowser) && (
|
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
|
||||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
|
{/* 命令按钮 */}
|
||||||
|
{onOpenCommands && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={onOpenCommands}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||||
|
title="Commands"
|
||||||
|
>
|
||||||
|
<Terminal size={20} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 配置按钮 */}
|
{/* 配置按钮 */}
|
||||||
{onOpenConfig && (
|
{onOpenConfig && (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* CommandEditor Component
|
||||||
|
*
|
||||||
|
* 命令编辑/创建对话框
|
||||||
|
* 支持编辑命令名称、描述、模板等
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Save, AlertCircle } 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 { Switch } from '../primitives/Switch';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/Select';
|
||||||
|
import {
|
||||||
|
createCommand,
|
||||||
|
updateCommand,
|
||||||
|
getCommandContent,
|
||||||
|
type CreateCommandInput,
|
||||||
|
type UpdateCommandInput,
|
||||||
|
type CommandContent,
|
||||||
|
} from '../api/client.js';
|
||||||
|
|
||||||
|
interface CommandEditorProps {
|
||||||
|
/** 编辑模式:指定命令名称;创建模式:undefined */
|
||||||
|
commandName?: string;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 保存成功回调 */
|
||||||
|
onSaved?: () => void;
|
||||||
|
/** 是否启用响应式布局 */
|
||||||
|
responsive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandEditor({
|
||||||
|
commandName,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
responsive = false,
|
||||||
|
}: CommandEditorProps) {
|
||||||
|
const isEditMode = !!commandName;
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [template, setTemplate] = useState('');
|
||||||
|
const [agent, setAgent] = useState('');
|
||||||
|
const [model, setModel] = useState('');
|
||||||
|
const [subtask, setSubtask] = useState(false);
|
||||||
|
const [scope, setScope] = useState<'user' | 'project'>('user');
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [originalData, setOriginalData] = useState<CommandContent | null>(null);
|
||||||
|
|
||||||
|
// 编辑模式:加载现有命令
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditMode && commandName) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
getCommandContent(commandName)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const data = result.data;
|
||||||
|
setOriginalData(data);
|
||||||
|
setName(data.name);
|
||||||
|
setDescription(data.description || '');
|
||||||
|
setTemplate(data.template);
|
||||||
|
setAgent(data.agent || '');
|
||||||
|
setModel(data.model || '');
|
||||||
|
setSubtask(data.subtask || false);
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to load command');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load command');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isEditMode, commandName]);
|
||||||
|
|
||||||
|
// 保存命令
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 验证
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error('Command name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!template.trim()) {
|
||||||
|
toast.error('Template is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditMode) {
|
||||||
|
// 更新模式
|
||||||
|
const input: UpdateCommandInput = {
|
||||||
|
description: description || undefined,
|
||||||
|
template,
|
||||||
|
agent: agent || undefined,
|
||||||
|
model: model || undefined,
|
||||||
|
subtask,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateCommand(commandName!, input);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Command updated');
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to update command');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建模式
|
||||||
|
const input: CreateCommandInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description || undefined,
|
||||||
|
template,
|
||||||
|
agent: agent || undefined,
|
||||||
|
model: model || undefined,
|
||||||
|
subtask,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createCommand(input);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Command created');
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to create command');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save command');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 是否为内置命令(不可编辑)
|
||||||
|
const isBuiltin = originalData?.source === 'builtin';
|
||||||
|
|
||||||
|
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-auto',
|
||||||
|
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(
|
||||||
|
'sticky top-0 flex items-center justify-between border-b border-gray-700 bg-gray-800 z-10',
|
||||||
|
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" />
|
||||||
|
)}
|
||||||
|
<h2 className={cn('text-lg font-semibold', responsive && 'mt-2 md:mt-0')}>
|
||||||
|
{isEditMode ? `Edit Command: /${commandName}` : 'Create Command'}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-6 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn('space-y-5', responsive ? 'p-4 md:p-6' : 'p-6')}>
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm"
|
||||||
|
>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Builtin Warning */}
|
||||||
|
{isBuiltin && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg text-yellow-400 text-sm">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
Builtin commands cannot be modified
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="deploy/staging"
|
||||||
|
disabled={isEditMode || isBuiltin}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Command name. Use / for nested commands (e.g., deploy/staging)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">Description</label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Deploy to staging environment"
|
||||||
|
disabled={isBuiltin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Template <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={template}
|
||||||
|
onChange={(e) => setTemplate(e.target.value)}
|
||||||
|
placeholder="Please help me with $ARGUMENTS"
|
||||||
|
disabled={isBuiltin}
|
||||||
|
rows={6}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg',
|
||||||
|
'text-gray-100 placeholder:text-gray-500',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||||
|
'font-mono text-sm resize-y',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Template syntax: $ARGUMENTS (all args), $1 $2 (positional), @file (include
|
||||||
|
file), !`cmd` (shell output)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent & Model */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">Agent</label>
|
||||||
|
<Input
|
||||||
|
value={agent}
|
||||||
|
onChange={(e) => setAgent(e.target.value)}
|
||||||
|
placeholder="code-review"
|
||||||
|
disabled={isBuiltin}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">Optional agent to use</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">Model</label>
|
||||||
|
<Input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="claude-sonnet-4-20250514"
|
||||||
|
disabled={isBuiltin}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">Optional model override</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtask & Scope */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300">Subtask</label>
|
||||||
|
<p className="text-xs text-gray-500">Run as background subtask</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={subtask} onCheckedChange={setSubtask} disabled={isBuiltin} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditMode && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">Scope</label>
|
||||||
|
<Select
|
||||||
|
value={scope}
|
||||||
|
onValueChange={(v) => setScope(v as 'user' | 'project')}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User (global)</SelectItem>
|
||||||
|
<SelectItem value="project">Project (local)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-500">Where to store the command</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Info (edit mode) */}
|
||||||
|
{isEditMode && originalData && (
|
||||||
|
<div className="pt-4 border-t border-gray-700">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-2">Command Info</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Source:</span>
|
||||||
|
<span className="ml-2 text-gray-300">{originalData.source}</span>
|
||||||
|
</div>
|
||||||
|
{originalData.sourcePath && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-gray-500">Path:</span>
|
||||||
|
<span className="ml-2 text-gray-300 font-mono text-xs break-all">
|
||||||
|
{originalData.sourcePath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'sticky bottom-0 border-t border-gray-700 bg-gray-800',
|
||||||
|
responsive
|
||||||
|
? 'flex flex-col-reverse md:flex-row items-stretch md:items-center justify-end gap-2 md:gap-3 p-4 md:px-6 md:py-4 safe-area-pb'
|
||||||
|
: 'flex items-center justify-end gap-3 px-6 py-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(responsive && 'justify-center')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{!isBuiltin && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || loading}
|
||||||
|
className={cn(responsive && 'justify-center')}
|
||||||
|
>
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
{saving ? 'Saving...' : isEditMode ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ export * from './utils/animations.js';
|
|||||||
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
|
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
|
||||||
export { ChatInput } from './components/ChatInput.js';
|
export { ChatInput } from './components/ChatInput.js';
|
||||||
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
||||||
|
export { CommandPanel } from './components/CommandPanel.js';
|
||||||
|
export { CommandEditor } from './components/CommandEditor.js';
|
||||||
export { Sidebar } from './components/Sidebar.js';
|
export { Sidebar } from './components/Sidebar.js';
|
||||||
export { FileBrowser } from './components/FileBrowser.js';
|
export { FileBrowser } from './components/FileBrowser.js';
|
||||||
export { ConfigPanel } from './components/ConfigPanel.js';
|
export { ConfigPanel } from './components/ConfigPanel.js';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Sidebar,
|
Sidebar,
|
||||||
FileBrowser,
|
FileBrowser,
|
||||||
ConfigPanel,
|
ConfigPanel,
|
||||||
|
CommandPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -21,6 +22,7 @@ export function App() {
|
|||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -105,6 +107,7 @@ export function App() {
|
|||||||
showFileBrowser={showFileBrowser}
|
showFileBrowser={showFileBrowser}
|
||||||
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
||||||
onOpenConfig={() => setShowConfig(true)}
|
onOpenConfig={() => setShowConfig(true)}
|
||||||
|
onOpenCommands={() => setShowCommands(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -153,6 +156,9 @@ export function App() {
|
|||||||
{/* 配置面板 */}
|
{/* 配置面板 */}
|
||||||
{showConfig && <ConfigPanel onClose={() => setShowConfig(false)} responsive />}
|
{showConfig && <ConfigPanel onClose={() => setShowConfig(false)} responsive />}
|
||||||
|
|
||||||
|
{/* 命令面板 */}
|
||||||
|
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
|
||||||
|
|
||||||
{/* 移动端底部文件按钮 */}
|
{/* 移动端底部文件按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFileBrowser(true)}
|
onClick={() => setShowFileBrowser(true)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { WifiOff, MessageSquare, Settings, FolderOpen } from 'lucide-react';
|
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -22,6 +22,7 @@ interface ChatPageProps {
|
|||||||
showFileBrowser?: boolean;
|
showFileBrowser?: boolean;
|
||||||
onToggleFileBrowser?: () => void;
|
onToggleFileBrowser?: () => void;
|
||||||
onOpenConfig?: () => void;
|
onOpenConfig?: () => void;
|
||||||
|
onOpenCommands?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -32,6 +33,7 @@ export function ChatPage({
|
|||||||
showFileBrowser,
|
showFileBrowser,
|
||||||
onToggleFileBrowser,
|
onToggleFileBrowser,
|
||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
|
onOpenCommands,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -126,8 +128,21 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
|
|
||||||
{/* 工具栏按钮 */}
|
{/* 工具栏按钮 */}
|
||||||
{(onOpenConfig || onToggleFileBrowser) && (
|
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
|
||||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
|
{/* 命令按钮 */}
|
||||||
|
{onOpenCommands && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={onOpenCommands}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||||
|
title="Commands"
|
||||||
|
>
|
||||||
|
<Terminal size={20} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 配置按钮 */}
|
{/* 配置按钮 */}
|
||||||
{onOpenConfig && (
|
{onOpenConfig && (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
Reference in New Issue
Block a user