Files
pixel/frontend/src/components/canvas/controls/OnboardingTour.tsx
张鹏 f9f4560459 Initial commit: Pixel AI comic/video creation platform
- FastAPI backend with SQLModel, Alembic migrations, AgentScope agents
- Next.js 15 frontend with React 19, Tailwind, Zustand, React Flow
- Multi-provider AI system (DashScope, Kling, MiniMax, Volcengine, OpenAI, etc.)
- All HTTP clients migrated from sync requests to async httpx
- Admin-managed API keys via environment variables
- SSRF vulnerability fixed in ensure_url()
2026-04-29 01:20:12 +08:00

271 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronRight, ChevronLeft, Sparkles, MousePointer, Keyboard, Zap, Check } from 'lucide-react';
import { cn } from '@/utils';
interface OnboardingStep {
id: string;
title: string;
description: string;
icon: React.ReactNode;
target?: string;
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
}
const onboardingSteps: OnboardingStep[] = [
{
id: 'welcome',
title: '欢迎使用 Pixel',
description: 'Pixel 是一个 AI 驱动的漫画和视频创作平台。让我们花几分钟了解一下核心功能。',
icon: <Sparkles className="w-6 h-6 text-cyan-400" />,
position: 'center'
},
{
id: 'canvas',
title: '画布工作区',
description: '这是你的创作画布。你可以在这里添加节点、连接它们来构建创作流程。拖拽左侧的节点到画布上开始创作。',
icon: <MousePointer className="w-6 h-6 text-purple-400" />,
target: '.react-flow__pane',
position: 'center'
},
{
id: 'sidebar',
title: '左侧工具栏',
description: '这里提供了各种 AI 生成节点:图像生成、视频生成、剧本处理等。点击或拖拽到画布上使用。',
icon: <Zap className="w-6 h-6 text-yellow-400" />,
target: '[data-panel="left-dock"]',
position: 'right'
},
{
id: 'shortcuts',
title: '快捷键',
description: '使用快捷键可以提高创作效率。Ctrl+S 保存Ctrl+Z 撤销Ctrl+A 全选。点击右下角的键盘图标查看全部快捷键。',
icon: <Keyboard className="w-6 h-6 text-green-400" />,
target: '[data-shortcut-button]',
position: 'top'
},
{
id: 'complete',
title: '准备开始创作',
description: '你已经了解了基本操作。开始你的第一个 AI 创作项目吧!如有问题,随时查看帮助文档。',
icon: <Check className="w-6 h-6 text-emerald-400" />,
position: 'center'
}
];
const STORAGE_KEY = 'pixel-onboarding-completed';
export function OnboardingTour() {
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [hasCompleted, setHasCompleted] = useState(false);
useEffect(() => {
// Check if user has completed onboarding
const completed = localStorage.getItem(STORAGE_KEY);
if (!completed) {
// Show onboarding after a short delay
const timer = setTimeout(() => {
setIsOpen(true);
}, 1000);
return () => clearTimeout(timer);
} else {
setHasCompleted(true);
}
}, []);
const handleComplete = useCallback(() => {
localStorage.setItem(STORAGE_KEY, 'true');
setHasCompleted(true);
setIsOpen(false);
}, []);
const handleNext = useCallback(() => {
if (currentStep < onboardingSteps.length - 1) {
setCurrentStep(prev => prev + 1);
} else {
handleComplete();
}
}, [currentStep, handleComplete]);
const handlePrev = useCallback(() => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
}, [currentStep]);
const handleSkip = useCallback(() => {
localStorage.setItem(STORAGE_KEY, 'true');
setHasCompleted(true);
setIsOpen(false);
}, []);
const handleRestart = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setHasCompleted(false);
setCurrentStep(0);
setIsOpen(true);
}, []);
const step = onboardingSteps[currentStep];
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === onboardingSteps.length - 1;
const progress = ((currentStep + 1) / onboardingSteps.length) * 100;
if (!isOpen) {
// Show restart button if user has completed but wants to see it again
if (hasCompleted) {
return (
<button
onClick={handleRestart}
className="fixed bottom-6 left-6 z-50 p-2 bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 rounded-full text-muted-foreground hover:text-white transition-all"
title="重新查看新手引导"
>
<Sparkles size={18} />
</button>
);
}
return null;
}
return (
<>
{/* Backdrop */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9998] bg-black/60 backdrop-blur-sm"
onClick={handleSkip}
/>
)}
</AnimatePresence>
{/* Onboarding Card */}
<AnimatePresence mode="wait">
<motion.div
key={step.id}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className={cn(
"fixed z-[9999] w-[400px] bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden",
step.position === 'center' && "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
step.position === 'top' && "bottom-24 right-6",
step.position === 'bottom' && "top-24 left-1/2 -translate-x-1/2",
step.position === 'left' && "top-1/2 right-6 -translate-y-1/2",
step.position === 'right' && "top-1/2 left-6 -translate-y-1/2"
)}
>
{/* Progress bar */}
<div className="absolute top-0 left-0 right-0 h-1 bg-white/5">
<motion.div
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Close button */}
<button
onClick={handleSkip}
className="absolute top-3 right-3 p-1.5 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<X size={16} />
</button>
{/* Content */}
<div className="p-6">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
{step.icon}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-white mb-2">
{step.title}
</h3>
{/* Description */}
<p className="text-sm text-muted-foreground leading-relaxed mb-6">
{step.description}
</p>
{/* Step indicator */}
<div className="flex items-center justify-center gap-1.5 mb-6">
{onboardingSteps.map((_, index) => (
<div
key={index}
className={cn(
"w-2 h-2 rounded-full transition-colors",
index === currentStep
? "bg-cyan-400"
: index < currentStep
? "bg-white/40"
: "bg-white/10"
)}
/>
))}
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={handlePrev}
disabled={isFirstStep}
className={cn(
"flex items-center gap-1 px-3 py-2 text-sm rounded-lg transition-colors",
isFirstStep
? "text-muted-foreground/50 cursor-not-allowed"
: "text-muted-foreground hover:text-white hover:bg-white/10"
)}
>
<ChevronLeft size={16} />
</button>
<div className="flex items-center gap-2">
{!isLastStep && (
<button
onClick={handleSkip}
className="px-3 py-2 text-sm text-muted-foreground hover:text-white transition-colors"
>
</button>
)}
<button
onClick={handleNext}
className="flex items-center gap-1 px-4 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 transition-colors"
>
{isLastStep ? '完成' : '下一步'}
{!isLastStep && <ChevronRight size={16} />}
</button>
</div>
</div>
</div>
</motion.div>
</AnimatePresence>
</>
);
}
export function ResetOnboardingButton() {
const handleReset = () => {
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
};
return (
<button
onClick={handleReset}
className="text-xs text-muted-foreground hover:text-white underline"
>
</button>
);
}