- 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()
271 lines
8.9 KiB
TypeScript
271 lines
8.9 KiB
TypeScript
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>
|
||
);
|
||
}
|