feat(phase2): complete P1 tasks - Channels, Triggers, Skills CRUD and UI enhancements

Phase 2 P1 Tasks Completed:

API Layer (gateway-client.ts, gatewayStore.ts):
- Add Channels CRUD: getChannel, createChannel, updateChannel, deleteChannel
- Add Triggers CRUD: getTrigger, createTrigger, updateTrigger, deleteTrigger
- Add Skills CRUD: getSkill, createSkill, updateSkill, deleteSkill
- Add Scheduled Tasks API: createScheduledTask, deleteScheduledTask, toggleScheduledTask
- Add loadModels action for dynamic model list

UI Components:
- ModelsAPI.tsx: Dynamic model loading from API with loading/error states
- SchedulerPanel.tsx: Full CreateJobModal with cron/interval/once scheduling
- SecurityStatus.tsx: Loading states, error handling, retry functionality
- WorkflowEditor.tsx: New workflow creation/editing modal (new file)
- WorkflowHistory.tsx: Workflow execution history viewer (new file)
- WorkflowList.tsx: Integrated editor and history access

Configuration:
- Add 4 Hands TOML configs: clip, collector, predictor, twitter

Documentation (SYSTEM_ANALYSIS.md):
- Update API coverage: 65% → 89% (53/62 endpoints)
- Update UI completion: 85% → 92%
- Mark Phase 2 P1 tasks as completed
- Update technical debt cleanup status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-15 01:38:34 +08:00
parent 1f9b6553fc
commit 5599c1a4db
15 changed files with 3216 additions and 296 deletions

View File

@@ -2,10 +2,11 @@
* HandTaskPanel - Hand 任务和结果面板
*
* 显示选中 Hand 的任务清单、执行历史和结果。
* 使用真实 API 数据,移除了 Mock 数据。
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Hand } from '../store/gatewayStore';
import { useGatewayStore, type Hand, type HandRun } from '../store/gatewayStore';
import {
Zap,
Loader2,
@@ -16,6 +17,7 @@ import {
ChevronRight,
Play,
ArrowLeft,
RefreshCw,
} from 'lucide-react';
interface HandTaskPanelProps {
@@ -31,77 +33,65 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; className: string; icon
failed: { label: '失败', className: 'text-red-600 bg-red-100', icon: XCircle },
cancelled: { label: '已取消', className: 'text-gray-500 bg-gray-100', icon: XCircle },
needs_approval: { label: '待审批', className: 'text-yellow-600 bg-yellow-100', icon: AlertCircle },
success: { label: '成功', className: 'text-green-600 bg-green-100', icon: CheckCircle },
error: { label: '错误', className: 'text-red-600 bg-red-100', icon: XCircle },
};
// 模拟任务数据(实际应从 API 获取)
interface MockTask {
id: string;
name: string;
status: string;
startedAt: string;
completedAt?: string;
result?: string;
error?: string;
}
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
const { hands, loadHands, triggerHand } = useGatewayStore();
const { hands, handRuns, loadHands, loadHandRuns, triggerHand, isLoading } = useGatewayStore();
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
const [tasks, setTasks] = useState<MockTask[]>([]);
const [isActivating, setIsActivating] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Load hands on mount
useEffect(() => {
loadHands();
}, [loadHands]);
// Find selected hand
useEffect(() => {
const hand = hands.find(h => h.id === handId || h.name === handId);
setSelectedHand(hand || null);
}, [hands, handId]);
// 模拟加载任务历史
// Load task history when hand is selected
useEffect(() => {
if (selectedHand) {
// TODO: 实际应从 API 获取任务历史
// 目前使用模拟数据
setTasks([
{
id: '1',
name: `${selectedHand.name} - 任务 1`,
status: 'completed',
startedAt: new Date(Date.now() - 3600000).toISOString(),
completedAt: new Date(Date.now() - 3500000).toISOString(),
result: '任务执行成功,生成了 5 个输出文件。',
},
{
id: '2',
name: `${selectedHand.name} - 任务 2`,
status: 'running',
startedAt: new Date(Date.now() - 1800000).toISOString(),
},
{
id: '3',
name: `${selectedHand.name} - 任务 3`,
status: 'needs_approval',
startedAt: new Date(Date.now() - 600000).toISOString(),
},
]);
loadHandRuns(selectedHand.name, { limit: 50 });
}
}, [selectedHand]);
}, [selectedHand, loadHandRuns]);
// Get runs for this hand from store
const tasks: HandRun[] = selectedHand ? (handRuns[selectedHand.name] || []) : [];
// Refresh task history
const handleRefresh = useCallback(async () => {
if (!selectedHand) return;
setIsRefreshing(true);
try {
await loadHandRuns(selectedHand.name, { limit: 50 });
} finally {
setIsRefreshing(false);
}
}, [selectedHand, loadHandRuns]);
// Trigger hand execution
const handleActivate = useCallback(async () => {
if (!selectedHand) return;
setIsActivating(true);
try {
await triggerHand(selectedHand.name);
// 刷新 hands 列表
await loadHands();
// Refresh hands list and task history
await Promise.all([
loadHands(),
loadHandRuns(selectedHand.name, { limit: 50 }),
]);
} catch {
// Error is handled in store
} finally {
setIsActivating(false);
}
}, [selectedHand, triggerHand, loadHands]);
}, [selectedHand, triggerHand, loadHands, loadHandRuns]);
if (!selectedHand) {
return (
@@ -113,29 +103,37 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
}
const runningTasks = tasks.filter(t => t.status === 'running');
const completedTasks = tasks.filter(t => t.status === 'completed' || t.status === 'failed');
const pendingTasks = tasks.filter(t => t.status === 'pending' || t.status === 'needs_approval');
const completedTasks = tasks.filter(t => ['completed', 'success', 'failed', 'error', 'cancelled'].includes(t.status));
const pendingTasks = tasks.filter(t => ['pending', 'needs_approval'].includes(t.status));
return (
<div className="h-full flex flex-col">
{/* 头部 */}
<div className="p-4 border-b border-gray-200 bg-white flex-shrink-0">
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-3">
{onBack && (
<button
onClick={onBack}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<span className="text-2xl">{selectedHand.icon || '🤖'}</span>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-gray-900 truncate">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedHand.name}
</h2>
<p className="text-xs text-gray-500 truncate">{selectedHand.description}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedHand.description}</p>
</div>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleActivate}
disabled={selectedHand.status !== 'idle' || isActivating}
@@ -158,6 +156,14 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 加载状态 */}
{isLoading && tasks.length === 0 && (
<div className="text-center py-8">
<Loader2 className="w-8 h-8 mx-auto text-gray-400 animate-spin mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">...</p>
</div>
)}
{/* 运行中的任务 */}
{runningTasks.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
@@ -167,7 +173,7 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
</h3>
<div className="space-y-2">
{runningTasks.map(task => (
<TaskCard key={task.id} task={task} />
<TaskCard key={task.runId} task={task} />
))}
</div>
</div>
@@ -182,7 +188,7 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
</h3>
<div className="space-y-2">
{pendingTasks.map(task => (
<TaskCard key={task.id} task={task} />
<TaskCard key={task.runId} task={task} />
))}
</div>
</div>
@@ -196,14 +202,14 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
</h3>
<div className="space-y-2">
{completedTasks.map(task => (
<TaskCard key={task.id} task={task} expanded />
<TaskCard key={task.runId} task={task} expanded />
))}
</div>
</div>
)}
{/* 空状态 */}
{tasks.length === 0 && (
{!isLoading && tasks.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-gray-400" />
@@ -220,11 +226,16 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
}
// 任务卡片组件
function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boolean }) {
function TaskCard({ task, expanded = false }: { task: HandRun; expanded?: boolean }) {
const [isExpanded, setIsExpanded] = useState(expanded);
const config = RUN_STATUS_CONFIG[task.status] || RUN_STATUS_CONFIG.pending;
const StatusIcon = config.icon;
// Format result for display
const resultText = task.result
? (typeof task.result === 'string' ? task.result : JSON.stringify(task.result, null, 2))
: undefined;
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 border border-gray-100 dark:border-gray-700">
<div
@@ -234,7 +245,7 @@ function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boole
<div className="flex items-center gap-2 min-w-0">
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${task.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{task.name}
#{task.runId.slice(0, 8)}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
@@ -248,6 +259,10 @@ function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boole
{/* 展开详情 */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span> ID</span>
<span className="font-mono">{task.runId}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{new Date(task.startedAt).toLocaleString()}</span>
@@ -258,9 +273,9 @@ function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boole
<span>{new Date(task.completedAt).toLocaleString()}</span>
</div>
)}
{task.result && (
<div className="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400">
{task.result}
{resultText && (
<div className="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 whitespace-pre-wrap max-h-40 overflow-auto">
{resultText}
</div>
)}
{task.error && (