feat: 新增引导式新手指引 (OnboardingTour) 组件,全站页面接入

This commit is contained in:
OmniAI Developer
2026-06-08 21:30:48 +08:00
parent 1e756808c1
commit 6ed65ca3ee
24 changed files with 1414 additions and 164 deletions
+148 -1
View File
@@ -193,12 +193,15 @@ import {
PromptPreviewLayer,
} from "./WorkbenchPromptPreview";
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
import OnboardingTour, { type TourPhaseId } from "../../components/OnboardingTour";
export type { WorkbenchResultActionPayload } from "./workbenchConstants";
interface WorkbenchPageProps {
isAuthenticated: boolean;
session: WebUserSession | null;
onboarding?: boolean;
onEndOnboarding?: () => void;
onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
onRefreshUsage?: () => void;
@@ -231,6 +234,8 @@ function formatCreditValue(value: number): string {
function WorkbenchPage({
isAuthenticated,
session,
onboarding,
onEndOnboarding,
onRequireLogin,
onOpenResultInCanvas,
onRefreshUsage,
@@ -264,7 +269,41 @@ function WorkbenchPage({
const renderedMessageIdsRef = useRef<string[]>([]);
const hasHandledInitialMessagesRef = useRef(false);
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
// Onboarding signal — init from prop or localStorage
const [effectiveOnboarding, setEffectiveOnboarding] = useState(
() => onboarding || (() => { try { return window.localStorage.getItem("omniai:onboarding") === "1"; } catch { return false; } })(),
);
// Track whether onboarding prop was ever true, to avoid overwriting localStorage-initiated true
const obWasActiveRef = useRef(onboarding);
useEffect(() => {
if (onboarding) {
obWasActiveRef.current = true;
setEffectiveOnboarding(true);
} else if (obWasActiveRef.current) {
// Only deactivate when prop transitions true→false (user dismissed)
setEffectiveOnboarding(false);
obWasActiveRef.current = false;
}
// If prop was never true, don't touch effectiveOnboarding (preserves localStorage init)
}, [onboarding]);
// Poll localStorage as a fallback (handles cases where prop isn't propagated)
useEffect(() => {
if (effectiveOnboarding) return;
const check = () => {
try {
if (window.localStorage.getItem("omniai:onboarding") === "1") {
setEffectiveOnboarding(true);
}
} catch {}
};
check();
const interval = setInterval(check, 200);
return () => clearInterval(interval);
}, [effectiveOnboarding]);
const [activeMode, setActiveMode] = useState<WorkbenchMode>(() => effectiveOnboarding ? "chat" : "video");
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
@@ -294,6 +333,34 @@ function WorkbenchPage({
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
// ── Onboarding tour state ──────────────────────────────
const [tourPhase, setTourPhase] = useState<TourPhaseId>("chat");
const [tourStep, setTourStep] = useState(0);
// Sync activeMode with tour phase and keep home view during onboarding
useEffect(() => {
if (!effectiveOnboarding) return;
// Reset tour state for repeat runs
setTourPhase("chat");
setTourStep(0);
// Force "今天想生成什么?" home view — prevent conversation auto-select
skipConversationAutoSelectRef.current = true;
setWorkspaceStarted(false);
setActiveConversationId(null);
activeConversationIdRef.current = null;
persistActiveConversationId(null);
messagesRef.current = [];
setMessages([]);
}, [effectiveOnboarding]);
useEffect(() => {
if (effectiveOnboarding) {
if (tourPhase === "chat") setActiveMode("chat");
else if (tourPhase === "image") setActiveMode("image");
else if (tourPhase === "video") setActiveMode("video");
}
}, [effectiveOnboarding, tourPhase]);
// ───────────────────────────────────────────────────────
const [, setGenerationProgress] = useState(0);
const [cursorIndex, setCursorIndex] = useState(0);
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
@@ -427,6 +494,7 @@ function WorkbenchPage({
const toolTheme = MODE_META[activeMode];
const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
@@ -1572,7 +1640,41 @@ function WorkbenchPage({
setToolbarMenuId((current) => (current === menuId ? null : menuId));
};
// ── Onboarding tour helpers ────────────────────────────
const obTarget = (map: Partial<Record<TourPhaseId, string>>): string | undefined =>
effectiveOnboarding ? map[tourPhase] : undefined;
const handleTourNext = useCallback((_phase: TourPhaseId, stepIndex: number) => {
setTourStep(stepIndex);
}, []);
const handleTourSkip = useCallback((phase: TourPhaseId) => {
const next: Record<TourPhaseId, TourPhaseId> = { chat: "image", image: "video", video: "video" };
const nextPhase = next[phase];
if (nextPhase === phase) {
onEndOnboarding?.();
} else {
setTourPhase(nextPhase);
setTourStep(0);
if (nextPhase === "image") setActiveMode("image");
else if (nextPhase === "video") setActiveMode("video");
}
}, [onEndOnboarding, setActiveMode]);
const handleTourDone = useCallback(() => {
setEffectiveOnboarding(false);
onEndOnboarding?.();
}, [onEndOnboarding]);
// Advance tour phase when user switches mode during onboarding
const handleModeChange = (mode: WorkbenchMode) => {
if (effectiveOnboarding) {
// Advance tour phase when switching to the next mode
if (tourPhase === "chat" && mode === "image") { setTourPhase("image"); setTourStep(0); }
else if (tourPhase === "image" && mode === "video") { setTourPhase("video"); setTourStep(0); }
// Block switching to other modes during guided tour
else if (mode !== tourPhase) return;
}
setActiveMode(mode);
setToolbarMenuId(null);
setReferencePreviewOpen(false);
@@ -2795,6 +2897,7 @@ function WorkbenchPage({
className="wb-composer__ref-upload"
onClick={handleReferenceUploadClick}
disabled={disabled}
data-onboarding={obTarget({ chat: "onboarding-chat-upload", image: "onboarding-image-upload", video: "onboarding-video-upload" })}
aria-label={`上传${referenceUploadLabel}`}
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
@@ -2854,6 +2957,7 @@ function WorkbenchPage({
const renderComposerToolbar = (disabled = false, showStop = false) => (
<div className="wb-composer__toolbar">
<div className="wb-composer__toolbar-left">
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
<SelectChip
chipId="studio-mode"
value={activeMode}
@@ -2866,8 +2970,10 @@ function WorkbenchPage({
ariaLabel="工作台模式"
direction={dropdownDirection}
/>
</span>
{activeMode === "chat" && (
<>
<span data-onboarding={obTarget({ chat: "onboarding-chat-model" })}>
<SelectChip
chipId="chat-model"
value={chatModel}
@@ -2880,6 +2986,8 @@ function WorkbenchPage({
ariaLabel="对话模型"
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ chat: "onboarding-chat-speed" })}>
<SelectChip
chipId="chat-speed"
value={thinkingSpeed}
@@ -2892,6 +3000,8 @@ function WorkbenchPage({
ariaLabel="思考速度"
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ chat: "onboarding-chat-depth" })}>
<SelectChip
chipId="chat-depth"
value={thinkingDepth}
@@ -2904,10 +3014,12 @@ function WorkbenchPage({
ariaLabel="思考深度"
direction={dropdownDirection}
/>
</span>
</>
)}
{activeMode === "image" && (
<>
<span data-onboarding={obTarget({ image: "onboarding-image-model" })}>
<SelectChip
chipId="image-model"
value={imageModel}
@@ -2919,6 +3031,8 @@ function WorkbenchPage({
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ image: "onboarding-image-settings" })}>
<CompoundSelectChip
chipId="image-settings"
summary={imageSettingsSummary}
@@ -2928,7 +3042,9 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection}
/>
</span>
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<span data-onboarding={obTarget({ image: "onboarding-image-grid" })}>
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
@@ -2940,11 +3056,13 @@ function WorkbenchPage({
onChange={setImageGridMode}
direction={dropdownDirection}
/>
</span>
)}
</>
)}
{activeMode === "video" && (
<>
<span data-onboarding={obTarget({ video: "onboarding-video-model" })}>
<SelectChip
chipId="video-model"
value={videoModel}
@@ -2956,6 +3074,8 @@ function WorkbenchPage({
onChange={setVideoModel}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-frame" })}>
<SelectChip
chipId="video-mode"
value={videoFrameMode}
@@ -2967,6 +3087,8 @@ function WorkbenchPage({
onChange={setVideoFrameMode}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-ratio" })}>
<CompoundSelectChip
chipId="video-ratio"
summary={videoRatio}
@@ -2976,6 +3098,8 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("video-ratio")}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-duration" })}>
<InlineOptionChip
chipId="video-duration"
value={videoDuration}
@@ -2988,6 +3112,8 @@ function WorkbenchPage({
onChange={setVideoDuration}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-quality" })}>
<InlineOptionChip
chipId="video-quality"
value={videoQuality}
@@ -3000,6 +3126,7 @@ function WorkbenchPage({
onChange={setVideoQuality}
direction={dropdownDirection}
/>
</span>
</>
)}
</div>
@@ -3013,6 +3140,7 @@ function WorkbenchPage({
disabled={sendDisabled || isGenerating}
title={isGenerating ? "任务处理中" : sendButtonTitle}
aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
data-onboarding={obTarget({ video: "onboarding-video-generate" })}
onClick={() => {
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
mode: activeMode,
@@ -3171,6 +3299,7 @@ function WorkbenchPage({
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
placeholder={composerPlaceholder}
value={inputValue}
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
onChange={handlePromptChange}
onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange}
@@ -3236,6 +3365,14 @@ function WorkbenchPage({
{renderMessagePreviewOverlay()}
{renderPromptCaseOverlay()}
{renderDeleteDialog()}
<OnboardingTour
active={Boolean(effectiveOnboarding)}
phase={tourPhase}
stepIndex={tourStep}
onNext={handleTourNext}
onSkip={handleTourSkip}
onDone={handleTourDone}
/>
</section>
);
}
@@ -3357,6 +3494,7 @@ function WorkbenchPage({
placeholder={composerPlaceholder}
value={inputValue}
disabled={false}
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
onChange={handlePromptChange}
onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange}
@@ -3404,6 +3542,15 @@ function WorkbenchPage({
{showRechargeModal && RechargeModal ? (
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
) : null}
<OnboardingTour
active={Boolean(effectiveOnboarding)}
phase={tourPhase}
stepIndex={tourStep}
onNext={handleTourNext}
onSkip={handleTourSkip}
onDone={handleTourDone}
/>
</section>
);
}