501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
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<TourPhaseId, TourPhase> = {
|
|
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<TooltipStep["placement"]> = [
|
|
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<PlacementResult>({ left: 0, top: 0, actualPlacement: "bottom" });
|
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
const [connector, setConnector] = useState<ConnectorPoints | null>(null);
|
|
const tooltipRef = useRef<HTMLDivElement>(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(
|
|
<div className={`onboarding-root${visible ? " is-visible" : ""}`} aria-label="新手引导教程">
|
|
{/* Overlay */}
|
|
<div className="onboarding-overlay" style={{ clipPath, WebkitClipPath: clipPath }} />
|
|
|
|
{/* Spotlight ring */}
|
|
{targetRect && (
|
|
<div
|
|
className="onboarding-spotlight"
|
|
style={{
|
|
left: targetRect.left - 8,
|
|
top: targetRect.top - 8,
|
|
width: targetRect.width + 16,
|
|
height: targetRect.height + 16,
|
|
}}
|
|
>
|
|
{/* Animated pulse ring */}
|
|
<div className="onboarding-spotlight__pulse" />
|
|
<div className="onboarding-spotlight__pulse onboarding-spotlight__pulse--delay" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Connector SVG line */}
|
|
{connector && (
|
|
<svg className="onboarding-connector" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="ob-conn-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="var(--accent, #00ff88)" stopOpacity="0.2" />
|
|
<stop offset="100%" stopColor="var(--accent, #00ff88)" stopOpacity="0.9" />
|
|
</linearGradient>
|
|
</defs>
|
|
{/* Animated dash line */}
|
|
<path
|
|
d={connectorPath}
|
|
fill="none"
|
|
stroke="var(--accent, #00ff88)"
|
|
strokeWidth="2"
|
|
strokeDasharray="8 4"
|
|
strokeLinecap="round"
|
|
opacity="0.7"
|
|
className="onboarding-connector__path"
|
|
/>
|
|
{/* Arrow at target end */}
|
|
<circle
|
|
cx={connector.x2}
|
|
cy={connector.y2}
|
|
r="5"
|
|
fill="var(--accent, #00ff88)"
|
|
className="onboarding-connector__dot"
|
|
/>
|
|
</svg>
|
|
)}
|
|
|
|
{/* Tooltip card */}
|
|
<div
|
|
ref={tooltipRef}
|
|
className={`onboarding-tooltip onboarding-tooltip--${pos.actualPlacement}`}
|
|
style={{ left: pos.left, top: pos.top }}
|
|
role="dialog"
|
|
aria-label={currentStep.title}
|
|
>
|
|
{/* Arrow pointing toward target */}
|
|
<div
|
|
className={`onboarding-tooltip__arrow onboarding-tooltip__arrow--${pos.actualPlacement}`}
|
|
style={{ transform: `rotate(${arrowAngle}deg)` }}
|
|
/>
|
|
|
|
<div className="onboarding-tooltip__head">
|
|
<span className="onboarding-tooltip__phase-badge">{phaseDef.label}</span>
|
|
<span className="onboarding-tooltip__counter">
|
|
{stepIndex + 1} / {totalSteps}
|
|
</span>
|
|
</div>
|
|
|
|
<strong className="onboarding-tooltip__title">{currentStep.title}</strong>
|
|
<p className="onboarding-tooltip__desc">{currentStep.description}</p>
|
|
|
|
<div className="onboarding-tooltip__actions">
|
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost" onClick={onDone}>
|
|
<CloseOutlined /> 跳过教程
|
|
</button>
|
|
{stepIndex > 0 && (
|
|
<button
|
|
type="button"
|
|
className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost"
|
|
onClick={() => onNext(phase, stepIndex - 1)}
|
|
>
|
|
<LeftOutlined /> 上一步
|
|
</button>
|
|
)}
|
|
{isVideoLastStep ? (
|
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={onDone}>
|
|
开始使用 <RightOutlined />
|
|
</button>
|
|
) : isLastStep && phase !== "video" ? (
|
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onSkip(phase)}>
|
|
{phase === "chat" ? "进入图像生成" : "进入视频生成"} <RightOutlined />
|
|
</button>
|
|
) : (
|
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onNext(phase, stepIndex + 1)}>
|
|
下一步 <RightOutlined />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom progress bar */}
|
|
<div className="onboarding-progress" aria-hidden="true">
|
|
{(["chat", "image", "video"] as TourPhaseId[]).map((p) => (
|
|
<div key={p} className="onboarding-progress__phase">
|
|
<div
|
|
className={`onboarding-progress__dot${p === phase ? " is-active" : ""}${
|
|
(["chat", "image", "video"].indexOf(p) < ["chat", "image", "video"].indexOf(phase)) ? " is-done" : ""
|
|
}`}
|
|
/>
|
|
<span>{PHASES[p].label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|