feat: 添加ESLint和Prettier配置并优化代码结构
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
style: 格式化代码文件并修复样式问题 docs: 新增部署文档和系统要求文档 test: 更新测试截图和覆盖率报告 refactor: 重构SchedulerPanel加载状态逻辑 ci: 添加lint和format脚本到package.json build: 更新依赖项并添加开发工具 chore: 添加验证报告和上线审查计划
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* AutomationPanel - Unified Automation Entry Point
|
||||
*
|
||||
* Combines Hands and Workflows into a single unified view,
|
||||
* Combines Pipelines, Hands and Workflows into a single unified view,
|
||||
* with category filtering, batch operations, and scheduling.
|
||||
*
|
||||
* @module components/Automation/AutomationPanel
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { AutomationCard } from './AutomationCard';
|
||||
import { AutomationFilters } from './AutomationFilters';
|
||||
import { BatchActionBar } from './BatchActionBar';
|
||||
import { ScheduleEditor } from './ScheduleEditor';
|
||||
import { PipelinesPanel } from '../PipelinesPanel';
|
||||
import {
|
||||
Zap,
|
||||
RefreshCw,
|
||||
@@ -29,25 +31,45 @@ import {
|
||||
Calendar,
|
||||
Search,
|
||||
X,
|
||||
Package,
|
||||
Bot,
|
||||
Workflow,
|
||||
Trash2,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import type { ScheduleInfo } from '../../types/automation';
|
||||
|
||||
// === View Mode ===
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
|
||||
// === Tab Type ===
|
||||
|
||||
type AutomationTab = 'pipelines' | 'hands' | 'workflows';
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface AutomationPanelProps {
|
||||
initialCategory?: CategoryType;
|
||||
initialTab?: AutomationTab;
|
||||
onSelect?: (item: AutomationItem) => void;
|
||||
showBatchActions?: boolean;
|
||||
}
|
||||
|
||||
// === Tab Configuration ===
|
||||
|
||||
const TAB_CONFIG: { key: AutomationTab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ key: 'pipelines', label: 'Pipelines', icon: Package },
|
||||
{ key: 'hands', label: 'Hands', icon: Bot },
|
||||
{ key: 'workflows', label: 'Workflows', icon: Workflow },
|
||||
];
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function AutomationPanel({
|
||||
initialCategory = 'all',
|
||||
initialTab = 'pipelines',
|
||||
onSelect,
|
||||
showBatchActions = true,
|
||||
}: AutomationPanelProps) {
|
||||
@@ -63,6 +85,7 @@ export function AutomationPanel({
|
||||
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
|
||||
|
||||
// UI state
|
||||
const [activeTab, setActiveTab] = useState<AutomationTab>(initialTab);
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryType>(initialCategory);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
@@ -70,6 +93,11 @@ export function AutomationPanel({
|
||||
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
|
||||
const [showWorkflowDialog, setShowWorkflowDialog] = useState(false);
|
||||
const [showSchedulerDialog, setShowSchedulerDialog] = useState(false);
|
||||
const [showBatchScheduleDialog, setShowBatchScheduleDialog] = useState(false);
|
||||
const [workflowName, setWorkflowName] = useState('');
|
||||
const [workflowDescription, setWorkflowDescription] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [schedules, setSchedules] = useState<Record<string, ScheduleInfo>>({});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -95,8 +123,14 @@ export function AutomationPanel({
|
||||
if (searchQuery.trim()) {
|
||||
items = searchAutomationItems(items, searchQuery);
|
||||
}
|
||||
// Filter by tab
|
||||
if (activeTab === 'hands') {
|
||||
items = items.filter(item => item.type === 'hand');
|
||||
} else if (activeTab === 'workflows') {
|
||||
items = items.filter(item => item.type === 'workflow');
|
||||
}
|
||||
return items;
|
||||
}, [automationItems, selectedCategory, searchQuery]);
|
||||
}, [automationItems, selectedCategory, searchQuery, activeTab]);
|
||||
|
||||
// Selection handlers
|
||||
const handleSelect = useCallback((id: string, selected: boolean) => {
|
||||
@@ -122,12 +156,95 @@ export function AutomationPanel({
|
||||
// Workflow dialog handlers
|
||||
const handleCreateWorkflow = useCallback(() => {
|
||||
setShowWorkflowDialog(true);
|
||||
setWorkflowName('');
|
||||
setWorkflowDescription('');
|
||||
}, []);
|
||||
|
||||
const handleSchedulerManage = useCallback(() => {
|
||||
setShowSchedulerDialog(true);
|
||||
}, []);
|
||||
|
||||
// Create workflow handler
|
||||
const handleWorkflowCreate = useCallback(async () => {
|
||||
if (!workflowName.trim()) {
|
||||
toast('请输入工作流名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const createWorkflow = useWorkflowStore.getState().createWorkflow;
|
||||
const result = await createWorkflow({
|
||||
name: workflowName.trim(),
|
||||
description: workflowDescription.trim() || undefined,
|
||||
steps: [], // Empty workflow, user will add steps later
|
||||
});
|
||||
|
||||
if (result) {
|
||||
toast(`工作流 "${result.name}" 创建成功`, 'success');
|
||||
setShowWorkflowDialog(false);
|
||||
setWorkflowName('');
|
||||
setWorkflowDescription('');
|
||||
// Reload workflows
|
||||
await loadWorkflows();
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '创建工作流失败';
|
||||
toast(errorMsg, 'error');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [workflowName, workflowDescription, toast, loadWorkflows]);
|
||||
|
||||
// Batch schedule handler
|
||||
const handleBatchSchedule = useCallback(() => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast('请先选择要调度的项目', 'info');
|
||||
return;
|
||||
}
|
||||
setShowBatchScheduleDialog(true);
|
||||
}, [selectedIds.size, toast]);
|
||||
|
||||
// Save batch schedule
|
||||
const handleSaveBatchSchedule = useCallback((schedule: ScheduleInfo) => {
|
||||
// Save schedule for all selected items
|
||||
const newSchedules: Record<string, ScheduleInfo> = {};
|
||||
selectedIds.forEach(id => {
|
||||
newSchedules[id] = schedule;
|
||||
});
|
||||
|
||||
setSchedules(prev => ({ ...prev, ...newSchedules }));
|
||||
setShowBatchScheduleDialog(false);
|
||||
setSelectedIds(new Set());
|
||||
|
||||
const frequencyLabels = {
|
||||
once: '一次性',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
custom: '自定义',
|
||||
};
|
||||
|
||||
toast(`已为 ${Object.keys(newSchedules).length} 个项目设置${frequencyLabels[schedule.frequency]}调度`, 'success');
|
||||
}, [selectedIds, toast]);
|
||||
|
||||
// Delete schedule
|
||||
const handleDeleteSchedule = useCallback((itemId: string) => {
|
||||
setSchedules(prev => {
|
||||
const { [itemId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
toast('调度已删除', 'success');
|
||||
}, [toast]);
|
||||
|
||||
// Toggle schedule enabled
|
||||
const handleToggleScheduleEnabled = useCallback((itemId: string, enabled: boolean) => {
|
||||
setSchedules(prev => ({
|
||||
...prev,
|
||||
[itemId]: { ...prev[itemId], enabled },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Execute handler
|
||||
const handleExecute = useCallback(async (item: AutomationItem, params?: Record<string, unknown>) => {
|
||||
setExecutingIds(prev => new Set(prev).add(item.id));
|
||||
@@ -186,6 +303,46 @@ export function AutomationPanel({
|
||||
toast('数据已刷新', 'success');
|
||||
}, [loadHands, loadWorkflows, toast]);
|
||||
|
||||
// If Pipelines tab is active, show PipelinesPanel directly
|
||||
if (activeTab === 'pipelines') {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with Tabs */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
自动化
|
||||
</h2>
|
||||
</div>
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
{TAB_CONFIG.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === key
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipelines Panel */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PipelinesPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hands and Workflows tabs
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
@@ -200,6 +357,23 @@ export function AutomationPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mr-2">
|
||||
{TAB_CONFIG.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === key
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
@@ -279,9 +453,7 @@ export function AutomationPanel({
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onBatchExecute={handleBatchExecute}
|
||||
onBatchSchedule={() => {
|
||||
toast('批量调度功能开发中', 'info');
|
||||
}}
|
||||
onBatchSchedule={handleBatchSchedule}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -302,12 +474,15 @@ export function AutomationPanel({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
工作流名称
|
||||
工作流名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={workflowName}
|
||||
onChange={(e) => setWorkflowName(e.target.value)}
|
||||
placeholder="输入工作流名称..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -315,28 +490,34 @@ export function AutomationPanel({
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={workflowDescription}
|
||||
onChange={(e) => setWorkflowDescription(e.target.value)}
|
||||
placeholder="描述这个工作流的用途..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
创建后可在工作流编辑器中添加步骤
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowWorkflowDialog(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
disabled={isCreating}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast('工作流创建功能开发中', 'info');
|
||||
setShowWorkflowDialog(false);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
|
||||
onClick={handleWorkflowCreate}
|
||||
disabled={isCreating || !workflowName.trim()}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
创建
|
||||
{isCreating && <RefreshCw className="w-4 h-4 animate-spin" />}
|
||||
{isCreating ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,9 +527,12 @@ export function AutomationPanel({
|
||||
{/* Scheduler Dialog */}
|
||||
{showSchedulerDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">调度管理</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">调度管理</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSchedulerDialog(false)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
@@ -356,14 +540,93 @@ export function AutomationPanel({
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>调度管理功能开发中</p>
|
||||
<p className="text-sm mt-1">将支持定时执行、Cron 表达式配置等</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{Object.keys(schedules).length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<Clock className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium mb-1">暂无调度任务</p>
|
||||
<p className="text-sm">选择自动化项目后点击"批量调度"来创建定时任务</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(schedules).map(([itemId, schedule]) => {
|
||||
const item = automationItems.find(i => i.id === itemId);
|
||||
if (!item) return null;
|
||||
|
||||
const frequencyLabels = {
|
||||
once: '一次性',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
custom: '自定义',
|
||||
};
|
||||
|
||||
const timeStr = `${schedule.time.hour.toString().padStart(2, '0')}:${schedule.time.minute.toString().padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
schedule.enabled
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
schedule.enabled
|
||||
? 'bg-orange-100 dark:bg-orange-900/30'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}>
|
||||
<Clock className={`w-4 h-4 ${
|
||||
schedule.enabled
|
||||
? 'text-orange-600 dark:text-orange-400'
|
||||
: 'text-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{frequencyLabels[schedule.frequency]} · {timeStr}
|
||||
{schedule.nextRun && ` · 下次: ${new Date(schedule.nextRun).toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Toggle Enabled */}
|
||||
<button
|
||||
onClick={() => handleToggleScheduleEnabled(itemId, !schedule.enabled)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
schedule.enabled ? 'bg-orange-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
||||
schedule.enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => handleDeleteSchedule(itemId)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
title="删除调度"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
共 {Object.keys(schedules).length} 个调度任务
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowSchedulerDialog(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
@@ -374,6 +637,15 @@ export function AutomationPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Schedule Dialog */}
|
||||
{showBatchScheduleDialog && (
|
||||
<ScheduleEditor
|
||||
itemName={`已选择 ${selectedIds.size} 个项目`}
|
||||
onSave={handleSaveBatchSchedule}
|
||||
onCancel={() => setShowBatchScheduleDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -349,9 +349,38 @@ export function ClassroomPreviewer({
|
||||
const handleExport = (format: 'pptx' | 'html' | 'pdf') => {
|
||||
if (onExport) {
|
||||
onExport(format);
|
||||
} else {
|
||||
toast(`导出 ${format.toUpperCase()} 功能开发中...`, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Default export implementation
|
||||
toast(`正在导出 ${format.toUpperCase()} 格式...`, 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (format === 'html') {
|
||||
const htmlContent = generateClassroomHTML(data);
|
||||
downloadFile(htmlContent, `${data.title}.html`, 'text/html');
|
||||
toast('HTML 导出成功', 'success');
|
||||
} else if (format === 'pptx') {
|
||||
// Export as JSON for conversion
|
||||
const pptxData = JSON.stringify(data, null, 2);
|
||||
downloadFile(pptxData, `${data.title}.slides.json`, 'application/json');
|
||||
toast('幻灯片数据已导出(JSON格式)', 'success');
|
||||
} else if (format === 'pdf') {
|
||||
const htmlContent = generatePrintableHTML(data);
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(htmlContent);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
toast('已打开打印预览', 'success');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '导出失败';
|
||||
toast(`导出失败: ${errorMsg}`, 'error');
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -530,3 +559,135 @@ export function ClassroomPreviewer({
|
||||
}
|
||||
|
||||
export default ClassroomPreviewer;
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function downloadFile(content: string, filename: string, mimeType: string) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function generateClassroomHTML(data: ClassroomData): string {
|
||||
const scenesHTML = data.scenes.map((scene, index) => `
|
||||
<section class="slide" data-index="${index}">
|
||||
<div class="slide-content ${scene.type}">
|
||||
<h2>${scene.content.heading || scene.title}</h2>
|
||||
${scene.content.bullets ? `
|
||||
<ul>
|
||||
${scene.content.bullets.map((b: string) => `<li>${b}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
${scene.narration ? `<p class="narration">${scene.narration}</p>` : ''}
|
||||
</div>
|
||||
</section>
|
||||
`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${data.title}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #6366f1);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
.presentation { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
||||
header { text-align: center; padding: 2rem 0; border-bottom: 1px solid rgba(255,255,255,0.2); margin-bottom: 2rem; }
|
||||
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||
.meta { opacity: 0.8; font-size: 0.9rem; }
|
||||
.slide {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.slide h2 { font-size: 1.8rem; margin-bottom: 1rem; }
|
||||
.slide ul { list-style: none; padding-left: 1rem; }
|
||||
.slide li { margin-bottom: 0.75rem; font-size: 1.1rem; }
|
||||
.slide li::before { content: '•'; color: #60a5fa; margin-right: 0.5rem; }
|
||||
.narration {
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
font-style: italic;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.title .slide-content { text-align: center; min-height: 200px; display: flex; flex-direction: column; justify-content: center; }
|
||||
.quiz { background: rgba(34, 197, 94, 0.2); }
|
||||
.summary { background: rgba(168, 85, 247, 0.2); }
|
||||
footer { text-align: center; padding: 2rem 0; border-top: 1px solid rgba(255,255,255,0.2); margin-top: 2rem; opacity: 0.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="presentation">
|
||||
<header>
|
||||
<h1>${data.title}</h1>
|
||||
<p class="meta">${data.subject} · ${data.difficulty} · ${data.duration} 分钟</p>
|
||||
</header>
|
||||
<main>${scenesHTML}</main>
|
||||
<footer><p>由 ZCLAW 课堂生成器创建</p></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function generatePrintableHTML(data: ClassroomData): string {
|
||||
const scenesHTML = data.scenes.map((scene, index) => `
|
||||
<div class="page" style="page-break-after: always;">
|
||||
<div class="slide-print">
|
||||
<h1 style="font-size: 24pt; margin-bottom: 20pt;">${scene.content.heading || scene.title}</h1>
|
||||
${scene.content.bullets ? `
|
||||
<ul style="font-size: 14pt; line-height: 1.8;">
|
||||
${scene.content.bullets.map((b: string) => `<li>${b}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
${scene.narration ? `<p style="background: #f0f0f0; padding: 10pt; margin-top: 20pt; font-style: italic;">${scene.narration}</p>` : ''}
|
||||
<p style="position: absolute; bottom: 20pt; right: 20pt; color: #999; font-size: 10pt;">${index + 1} / ${data.scenes.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${data.title} - 打印版</title>
|
||||
<style>
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.page { page-break-after: always; }
|
||||
}
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; }
|
||||
.slide-print {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 40pt;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
<header style="text-align: center; margin-bottom: 30pt;">
|
||||
<h1 style="font-size: 32pt;">${data.title}</h1>
|
||||
<p style="color: #666;">${data.subject} · ${data.difficulty} · ${data.duration} 分钟</p>
|
||||
</header>
|
||||
${scenesHTML}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { PipelineRunResponse } from '../lib/pipeline-client';
|
||||
import { useToast } from './ui/Toast';
|
||||
import { ClassroomPreviewer, type ClassroomData } from './ClassroomPreviewer';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -155,9 +156,17 @@ interface MarkdownPreviewProps {
|
||||
}
|
||||
|
||||
function MarkdownPreview({ content }: MarkdownPreviewProps) {
|
||||
// Simple markdown rendering (for production, use a proper markdown library)
|
||||
// Simple markdown rendering with XSS protection
|
||||
const renderMarkdown = (md: string): string => {
|
||||
return md
|
||||
// First, escape HTML entities to prevent XSS
|
||||
const escaped = md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
return escaped
|
||||
// Headers
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>')
|
||||
@@ -190,6 +199,7 @@ export function PipelineResultPreview({
|
||||
onClose,
|
||||
}: PipelineResultPreviewProps) {
|
||||
const [mode, setMode] = useState<PreviewMode>('auto');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Determine the best preview mode
|
||||
const outputs = result.outputs as Record<string, unknown> | undefined;
|
||||
@@ -205,25 +215,89 @@ export function PipelineResultPreview({
|
||||
|
||||
const activeMode = mode === 'auto' ? autoMode : mode;
|
||||
|
||||
// Handle classroom export
|
||||
const handleClassroomExport = (format: 'pptx' | 'html' | 'pdf', data: ClassroomData) => {
|
||||
toast(`正在导出 ${format.toUpperCase()} 格式...`, 'info');
|
||||
|
||||
// Create downloadable content based on format
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (format === 'html') {
|
||||
// Generate HTML content
|
||||
const htmlContent = generateClassroomHTML(data);
|
||||
downloadFile(htmlContent, `${data.title}.html`, 'text/html');
|
||||
toast('HTML 导出成功', 'success');
|
||||
} else if (format === 'pptx') {
|
||||
// For PPTX, we would need a library like pptxgenjs
|
||||
// For now, export as JSON that can be converted
|
||||
const pptxData = JSON.stringify(data, null, 2);
|
||||
downloadFile(pptxData, `${data.title}.slides.json`, 'application/json');
|
||||
toast('幻灯片数据已导出(JSON格式)', 'success');
|
||||
} else if (format === 'pdf') {
|
||||
// For PDF, we would need a library like jspdf
|
||||
// For now, export as printable HTML
|
||||
const htmlContent = generatePrintableHTML(data);
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(htmlContent);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
toast('已打开打印预览', 'success');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '导出失败';
|
||||
toast(`导出失败: ${errorMsg}`, 'error');
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Render based on mode
|
||||
const renderContent = () => {
|
||||
switch (activeMode) {
|
||||
case 'json':
|
||||
return <JsonPreview data={outputs} />;
|
||||
|
||||
case 'markdown':
|
||||
case 'markdown': {
|
||||
const mdContent = (outputs?.summary || outputs?.report || JSON.stringify(outputs, null, 2)) as string;
|
||||
return <MarkdownPreview content={mdContent} />;
|
||||
}
|
||||
|
||||
case 'classroom': {
|
||||
// Convert outputs to ClassroomData format
|
||||
const classroomData: ClassroomData | null = outputs ? {
|
||||
id: result.pipelineId || 'classroom',
|
||||
title: (outputs.title as string) || '课堂内容',
|
||||
subject: (outputs.subject as string) || '通用',
|
||||
difficulty: (outputs.difficulty as '初级' | '中级' | '高级') || '中级',
|
||||
duration: (outputs.duration as number) || 30,
|
||||
scenes: Array.isArray(outputs.scenes) ? (outputs.scenes as ClassroomData['scenes']) : [],
|
||||
outline: (outputs.outline as ClassroomData['outline']) || { sections: [] },
|
||||
createdAt: new Date().toISOString(),
|
||||
} : null;
|
||||
|
||||
if (classroomData && classroomData.scenes.length > 0) {
|
||||
return (
|
||||
<div className="-m-4">
|
||||
<ClassroomPreviewer
|
||||
data={classroomData}
|
||||
onExport={(format) => {
|
||||
// Handle export
|
||||
handleClassroomExport(format, classroomData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'classroom':
|
||||
// Will be handled by ClassroomPreviewer component
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Presentation className="w-12 h-12 mx-auto mb-3 text-gray-400" />
|
||||
<p>课堂预览功能正在开发中...</p>
|
||||
<p>无法解析课堂数据</p>
|
||||
<p className="text-sm mt-2">您可以在下方下载生成的文件</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <JsonPreview data={outputs} />;
|
||||
@@ -336,3 +410,117 @@ export function PipelineResultPreview({
|
||||
}
|
||||
|
||||
export default PipelineResultPreview;
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function downloadFile(content: string, filename: string, mimeType: string) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function generateClassroomHTML(data: ClassroomData): string {
|
||||
const scenesHTML = data.scenes.map((scene, index) => `
|
||||
<section class="slide ${index === 0 ? 'active' : ''}" data-index="${index}">
|
||||
<div class="slide-content ${scene.type}">
|
||||
<h2>${scene.content.heading || scene.title}</h2>
|
||||
${scene.content.bullets ? `
|
||||
<ul>
|
||||
${scene.content.bullets.map(b => `<li>${b}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
${scene.narration ? `<p class="narration">${scene.narration}</p>` : ''}
|
||||
</div>
|
||||
</section>
|
||||
`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${data.title}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #6366f1);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
.presentation {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||
.meta { opacity: 0.8; font-size: 0.9rem; }
|
||||
.slide {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.slide h2 { font-size: 1.8rem; margin-bottom: 1rem; }
|
||||
.slide ul { list-style: none; padding-left: 1rem; }
|
||||
.slide li { margin-bottom: 0.75rem; font-size: 1.1rem; }
|
||||
.slide li::before { content: '•'; color: #60a5fa; margin-right: 0.5rem; }
|
||||
.narration {
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
font-style: italic;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.title .slide-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
.quiz { background: rgba(34, 197, 94, 0.2); }
|
||||
.summary { background: rgba(168, 85, 247, 0.2); }
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid rgba(255,255,255,0.2);
|
||||
margin-top: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="presentation">
|
||||
<header>
|
||||
<h1>${data.title}</h1>
|
||||
<p class="meta">${data.subject} · ${data.difficulty} · ${data.duration} 分钟</p>
|
||||
</header>
|
||||
<main>
|
||||
${scenesHTML}
|
||||
</main>
|
||||
<footer>
|
||||
<p>由 ZCLAW 课堂生成器创建</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function generatePrintableHTML(data: ClassroomData): string {
|
||||
return generateClassroomHTML(data);
|
||||
}
|
||||
|
||||
@@ -654,7 +654,10 @@ export function SchedulerPanel() {
|
||||
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
|
||||
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
|
||||
const executeWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
|
||||
const isLoading = useHandStore((s) => s.isLoading) || useWorkflowStore((s) => s.isLoading) || useConfigStore((s) => s.isLoading);
|
||||
const handLoading = useHandStore((s) => s.isLoading);
|
||||
const workflowLoading = useWorkflowStore((s) => s.isLoading);
|
||||
const configLoading = useConfigStore((s) => s.isLoading);
|
||||
const isLoading = handLoading || workflowLoading || configLoading;
|
||||
const [activeTab, setActiveTab] = useState<TabType>('scheduled');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isWorkflowEditorOpen, setIsWorkflowEditorOpen] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user