diff --git a/onboarding-fail.png b/onboarding-fail.png new file mode 100644 index 0000000..b3a99f1 Binary files /dev/null and b/onboarding-fail.png differ diff --git a/src/App.tsx b/src/App.tsx index d57bab5..8b62cc9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -375,6 +375,7 @@ function App() { const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); const [workbenchResetToken, setWorkbenchResetToken] = useState(0); + const [onboardingActive, setOnboardingActive] = useState(false); const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub"; useEffect(() => { if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true); @@ -473,6 +474,17 @@ function App() { } }, [session, setView, setWorkspaceExpanded]); + const handleStartOnboarding = useCallback(() => { + setOnboardingActive(true); + try { window.localStorage.setItem("omniai:onboarding", "1"); } catch {} + handleSetView("workbench"); + }, [handleSetView]); + + const handleEndOnboarding = useCallback(() => { + setOnboardingActive(false); + try { window.localStorage.removeItem("omniai:onboarding"); } catch {} + }, []); + const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { clearAllUserStorage(); clearSessionState(); @@ -1320,6 +1332,8 @@ function App() { key={`workbench-${workbenchResetToken}`} isAuthenticated={Boolean(session)} session={session} + onboarding={onboardingActive} + onEndOnboarding={handleEndOnboarding} onRequireLogin={handleRequireTaskLogin} onOpenResultInCanvas={handleOpenResultInCanvas} onRefreshUsage={refreshUsage} @@ -1330,6 +1344,7 @@ function App() { return ( handleSetView("workbench")} + onStartOnboarding={handleStartOnboarding} onOpenCanvas={() => handleSetView("canvas")} onOpenEcommerce={() => handleSetView("ecommerce")} onOpenScriptReview={() => handleSetView("scriptTokens")} diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 0000000..2e6703c --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,500 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { createPortal } from "react-dom"; +import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons"; +import "../styles/components/onboarding.css"; + +// ─── Types ─────────────────────────────────────────────────── + +export type TourPhaseId = "chat" | "image" | "video"; + +interface TooltipStep { + target: string; + title: string; + description: string; + /** Which side of the target to place the tooltip on (preferred). */ + placement?: "top" | "bottom" | "left" | "right"; + /** If true, this step requires the user to interact with the element to proceed. */ + interactive?: boolean; + /** Shown as hint text when interactive. */ + actionHint?: string; +} + +interface TourPhase { + id: TourPhaseId; + label: string; + steps: TooltipStep[]; +} + +interface OnboardingTourProps { + active: boolean; + phase: TourPhaseId; + stepIndex: number; + onNext: (phase: TourPhaseId, stepIndex: number) => void; + onSkip: (phase: TourPhaseId) => void; + onDone: () => void; +} + +// ─── Tour definitions ──────────────────────────────────────── + +const PHASES: Record = { + chat: { + id: "chat", + label: "对话模式", + steps: [ + { + target: "onboarding-chat-upload", + title: "参考素材上传", + description: "点击或拖拽上传图片、视频、音频等参考素材,帮助 AI 更好地理解你的需求。", + placement: "right", + }, + { + target: "onboarding-chat-model", + title: "AI 模型选择", + description: "在这里选择对话使用的 AI 模型,不同模型有不同的擅长领域和风格。", + placement: "bottom", + }, + { + target: "onboarding-chat-speed", + title: "思考速度", + description: "「思考速度:高」回复更迅速简洁;「思考速度:急速」适合快速问答场景。", + placement: "bottom", + }, + { + target: "onboarding-chat-depth", + title: "推理深度", + description: "「推理深度:强」进行更深层逻辑推理;「推理深度:极限」适合复杂多步骤问题。", + placement: "bottom", + }, + { + target: "onboarding-chat-input", + title: "提示词输入框", + description: "在这里输入你的问题或创作需求,按 Enter 发送,Shift + Enter 换行。", + placement: "top", + }, + { + target: "onboarding-mode-selector", + title: "切换到图像生成模式", + description: "点击「下一步」自动切换,或点击这个按钮手动选择「图像生成」进入下一阶段。", + placement: "bottom", + }, + ], + }, + image: { + id: "image", + label: "图像生成", + steps: [ + { + target: "onboarding-image-upload", + title: "参考图上传", + description: "上传参考图片,AI 将基于参考图的风格和内容生成新图像。支持 PNG / JPG / WebP。", + placement: "right", + }, + { + target: "onboarding-image-model", + title: "图像模型选择", + description: "选择用于图像生成的 AI 模型,不同模型在风格、精度和速度上有所侧重。", + placement: "bottom", + }, + { + target: "onboarding-image-settings", + title: "比例与分辨率", + description: "设置生成图像的宽高比(如 16:9、1:1)和清晰度(1K/2K),根据使用场景选择。", + placement: "bottom", + }, + { + target: "onboarding-image-grid", + title: "单图 / 多宫格模式", + description: "「单图」生成一张完整图像;「多宫格」一次生成多张变体供你挑选最佳方案。", + placement: "bottom", + }, + { + target: "onboarding-image-input", + title: "图像提示词", + description: "描述你想要的图像内容、风格和细节,越具体效果越好。", + placement: "top", + }, + { + target: "onboarding-mode-selector", + title: "切换到视频生成模式", + description: "点击「下一步」自动切换,或点击这个按钮手动选择「视频生成」进入下一阶段。", + placement: "bottom", + }, + ], + }, + video: { + id: "video", + label: "视频生成", + steps: [ + { + target: "onboarding-video-upload", + title: "参考素材上传", + description: "上传参考图片或视频片段,帮助 AI 确定视频的风格、色调和内容方向。", + placement: "right", + }, + { + target: "onboarding-video-model", + title: "视频模型选择", + description: "选择视频生成模型。不同模型在画质、时长、运动流畅度上各有优势。", + placement: "bottom", + }, + { + target: "onboarding-video-frame", + title: "生成方式:全能 / 首尾帧", + description: "「全能参考」根据描述直接生成;「首尾帧」通过设定起始和结束画面精确控制转场。", + placement: "bottom", + }, + { + target: "onboarding-video-ratio", + title: "视频画面比例", + description: "选择画面比例。9:16 适合手机短视频(抖音/Reels),16:9 适合横屏展示。", + placement: "bottom", + }, + { + target: "onboarding-video-duration", + title: "视频时长设置", + description: "设置生成视频的秒数。时长越长,生成时间越久,建议从 5 秒开始尝试。", + placement: "bottom", + }, + { + target: "onboarding-video-quality", + title: "分辨率与画质", + description: "选择视频清晰度。720P 生成更快适合预览,1080P 画质更高适合最终成品。", + placement: "bottom", + }, + { + target: "onboarding-video-generate", + title: "一切就绪,开始创作!", + description: "设置完毕后,点击发送按钮(或按 Enter)即可开始你的首次视频生成。祝你创作愉快!", + placement: "top", + }, + ], + }, +}; + +// ─── Connector line calculation ────────────────────────────── + +interface ConnectorPoints { + x1: number; y1: number; // tooltip edge center + x2: number; y2: number; // target edge center +} + +function calcConnector( + tooltipRect: DOMRect, + targetRect: DOMRect, + placement: TooltipStep["placement"], +): ConnectorPoints { + const tx = targetRect.left + targetRect.width / 2; + const ty = targetRect.top + targetRect.height / 2; + const tcx = tooltipRect.left + tooltipRect.width / 2; + const tcy = tooltipRect.top + tooltipRect.height / 2; + + switch (placement) { + case "top": + return { x1: tcx, y1: tooltipRect.bottom, x2: tx, y2: targetRect.top }; + case "bottom": + return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom }; + case "left": + return { x1: tooltipRect.right, y1: tcy, x2: targetRect.left, y2: ty }; + case "right": + return { x1: tooltipRect.left, y1: tcy, x2: targetRect.right, y2: ty }; + default: + return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom }; + } +} + +// ─── Placement engine ───────────────────────────────────────── + +interface PlacementResult { + left: number; + top: number; + actualPlacement: TooltipStep["placement"]; +} + +/** Score a candidate — lower is better. Penalises covering the target or overflow. */ +function scorePlacement( + left: number, top: number, tw: number, th: number, + targetRect: DOMRect, vw: number, vh: number, +): number { + let score = 0; + // Overflow penalty + if (left < 0) score += Math.abs(left); + if (top < 0) score += Math.abs(top); + if (left + tw > vw) score += (left + tw - vw); + if (top + th > vh) score += (top + th - vh); + // Overlap with target penalty (avoid covering the highlighted element) + const overlapX = Math.max(0, Math.min(left + tw, targetRect.right) - Math.max(left, targetRect.left)); + const overlapY = Math.max(0, Math.min(top + th, targetRect.bottom) - Math.max(top, targetRect.top)); + if (overlapX > 0 && overlapY > 0) score += overlapX * overlapY * 0.01; + return score; +} + +function findBestPlacement( + targetRect: DOMRect, tw: number, th: number, + preferred: TooltipStep["placement"], +): PlacementResult { + const gap = 144; + const vw = window.innerWidth; + const vh = window.innerHeight; + const all: Array = [ + preferred ?? "bottom", + ...(["bottom", "top", "right", "left"] as const).filter((p) => p !== (preferred ?? "bottom")), + ]; + + let best: PlacementResult = { left: 0, top: 0, actualPlacement: "bottom" }; + let bestScore = Infinity; + + for (const p of all) { + let left = 0, top = 0; + switch (p) { + case "bottom": + left = targetRect.left + targetRect.width / 2 - tw / 2; + top = targetRect.bottom + gap; + break; + case "top": + left = targetRect.left + targetRect.width / 2 - tw / 2; + top = targetRect.top - th - gap; + break; + case "right": + left = targetRect.right + gap; + top = targetRect.top + targetRect.height / 2 - th / 2; + break; + case "left": + left = targetRect.left - tw - gap; + top = targetRect.top + targetRect.height / 2 - th / 2; + break; + } + left = Math.max(12, Math.min(left, vw - tw - 12)); + top = Math.max(12, Math.min(top, vh - th - 12)); + + const s = scorePlacement(left, top, tw, th, targetRect, vw, vh); + if (s < bestScore) { + bestScore = s; + best = { left, top, actualPlacement: p }; + } + if (s === 0) break; // perfect + } + return best; +} + +// ─── Component ──────────────────────────────────────────────── + +export default function OnboardingTour({ + active, phase, stepIndex, onNext, onSkip, onDone, +}: OnboardingTourProps) { + const [pos, setPos] = useState({ left: 0, top: 0, actualPlacement: "bottom" }); + const [targetRect, setTargetRect] = useState(null); + const [visible, setVisible] = useState(false); + const [connector, setConnector] = useState(null); + const tooltipRef = useRef(null); + const prevPhaseRef = useRef(phase); + const prevStepRef = useRef(stepIndex); + + const phaseDef = PHASES[phase]; + const currentStep = phaseDef?.steps[stepIndex]; + const totalSteps = phaseDef?.steps.length ?? 0; + const isLastStep = stepIndex >= totalSteps - 1; + const isVideoLastStep = phase === "video" && isLastStep; + + const stepChanged = prevPhaseRef.current !== phase || prevStepRef.current !== stepIndex; + prevPhaseRef.current = phase; + prevStepRef.current = stepIndex; + + const recalc = useCallback(() => { + if (!currentStep) return; + const el = document.querySelector(`[data-onboarding="${currentStep.target}"]`) as HTMLElement | null; + if (!el) return; // Will be retried by the polling loop + const rect = el.getBoundingClientRect(); + setTargetRect(rect); + + const tooltip = tooltipRef.current; + if (!tooltip) return; + const tr = tooltip.getBoundingClientRect(); + const best = findBestPlacement(rect, tr.width, tr.height, currentStep.placement); + setPos(best); + + // Recalculate tooltip rect after position update (use the same best pos) + const virtualTooltipRect = new DOMRect(best.left, best.top, tr.width, tr.height); + setConnector(calcConnector(virtualTooltipRect, rect, best.actualPlacement)); + }, [currentStep]); + + useEffect(() => { + if (!active) { setVisible(false); return; } + const t = setTimeout(() => { setVisible(true); recalc(); }, 120); + return () => clearTimeout(t); + }, [active, phase, stepIndex, recalc]); + + // Reposition and retry when elements aren't ready + useEffect(() => { + if (!active || !visible) return; + const h = () => recalc(); + window.addEventListener("resize", h); + window.addEventListener("scroll", h, true); + const obs = new MutationObserver(h); + obs.observe(document.body, { childList: true, subtree: true, attributes: true }); + + // Polling retry: keep looking for the target element if not found yet + let retryId: number | null = null; + let attempts = 0; + const poll = () => { + recalc(); + attempts += 1; + if (attempts < 40) retryId = requestAnimationFrame(poll); + }; + // Start polling after a short delay + const startTimer = setTimeout(() => { poll(); }, 200); + return () => { + window.removeEventListener("resize", h); + window.removeEventListener("scroll", h, true); + obs.disconnect(); + clearTimeout(startTimer); + if (retryId !== null) cancelAnimationFrame(retryId); + }; + }, [active, visible, recalc]); + + // Animate in on step change + useEffect(() => { + if (!active || !visible || !stepChanged) return; + const el = tooltipRef.current; + if (!el) return; + el.classList.remove("onboarding-tooltip--pop"); + void el.offsetWidth; // force reflow + el.classList.add("onboarding-tooltip--pop"); + }, [active, visible, stepChanged, phase, stepIndex]); + + if (!active || !currentStep) return null; + + const connectorPath = connector + ? `M ${connector.x1} ${connector.y1} L ${connector.x2} ${connector.y2}` + : ""; + + const arrowAngle = connector + ? Math.atan2(connector.y2 - connector.y1, connector.x2 - connector.x1) * (180 / Math.PI) + : 0; + + const clipPath = targetRect + ? `polygon(0% 0%, 0% 100%, ${targetRect.left - 6}px 100%, ${targetRect.left - 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px 100%, 100% 100%, 100% 0%)` + : ""; + + return createPortal( +
+ {/* Overlay */} +
+ + {/* Spotlight ring */} + {targetRect && ( +
+ {/* Animated pulse ring */} +
+
+
+ )} + + {/* Connector SVG line */} + {connector && ( + + )} + + {/* Tooltip card */} +
+ {/* Arrow pointing toward target */} +
+ +
+ {phaseDef.label} + + {stepIndex + 1} / {totalSteps} + +
+ + {currentStep.title} +

{currentStep.description}

+ +
+ + {stepIndex > 0 && ( + + )} + {isVideoLastStep ? ( + + ) : isLastStep && phase !== "video" ? ( + + ) : ( + + )} +
+
+ + {/* Bottom progress bar */} + , + document.body, + ); +} diff --git a/src/features/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx index 51b98a3..7956c0d 100644 --- a/src/features/assets/AssetsPage.tsx +++ b/src/features/assets/AssetsPage.tsx @@ -10,7 +10,7 @@ import { SearchOutlined, UserOutlined, } from "@ant-design/icons"; -import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react"; import "../../styles/pages/assets.css"; import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { aiGenerationClient } from "../../api/aiGenerationClient"; @@ -95,6 +95,17 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null); const contextMenuRef = useRef(null); const uploadInputRef = useRef(null); + const [isUploadDragging, setIsUploadDragging] = useState(false); + + const handleUploadDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsUploadDragging(true); }; + const handleUploadDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsUploadDragging(false); }; + const handleUploadDrop = (e: DragEvent) => { + e.preventDefault(); + setIsUploadDragging(false); + if (e.dataTransfer.files.length) { + void handleUploadFiles(e.dataTransfer.files); + } + }; const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => { e.preventDefault(); @@ -270,7 +281,15 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { placeholder="搜索资产..." /> - diff --git a/src/features/character-mix/CharacterMixPage.tsx b/src/features/character-mix/CharacterMixPage.tsx index a2f01be..05a8802 100644 --- a/src/features/character-mix/CharacterMixPage.tsx +++ b/src/features/character-mix/CharacterMixPage.tsx @@ -61,6 +61,9 @@ function CharacterMixPage({ const abortRef = useRef(false); const taskIdRef = useRef(null); const [isDragging, setIsDragging] = useState(false); + const [isCanvasDragging, setIsCanvasDragging] = useState(false); + const characterInputRef = useRef(null); + const videoInputRef = useRef(null); useEffect(() => { return () => { @@ -262,6 +265,23 @@ function CharacterMixPage({ } }; + const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); }; + const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); }; + const handleCanvasDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsCanvasDragging(false); + handleDrop(e); + }; + + const handleCanvasClick = () => { + if (!characterPreview) { + characterInputRef.current?.click(); + } else if (!videoPreview) { + videoInputRef.current?.click(); + } + }; + return (
@@ -342,6 +362,7 @@ function CharacterMixPage({