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

style: 格式化代码文件并修复样式问题

docs: 新增部署文档和系统要求文档

test: 更新测试截图和覆盖率报告

refactor: 重构SchedulerPanel加载状态逻辑

ci: 添加lint和format脚本到package.json

build: 更新依赖项并添加开发工具

chore: 添加验证报告和上线审查计划
This commit is contained in:
iven
2026-03-26 08:02:23 +08:00
parent bf6d81f9c6
commit d0c6319fc1
286 changed files with 239803 additions and 1118 deletions

View File

@@ -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>
);
}

View File

@@ -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>`;
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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);
}

View File

@@ -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);