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
Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

+15
View File
@@ -375,6 +375,7 @@ function App() {
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const [workbenchResetToken, setWorkbenchResetToken] = useState(0); const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
const [onboardingActive, setOnboardingActive] = useState(false);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub"; const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => { useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true); if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
@@ -473,6 +474,17 @@ function App() {
} }
}, [session, setView, setWorkspaceExpanded]); }, [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 }) => { const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
clearAllUserStorage(); clearAllUserStorage();
clearSessionState(); clearSessionState();
@@ -1320,6 +1332,8 @@ function App() {
key={`workbench-${workbenchResetToken}`} key={`workbench-${workbenchResetToken}`}
isAuthenticated={Boolean(session)} isAuthenticated={Boolean(session)}
session={session} session={session}
onboarding={onboardingActive}
onEndOnboarding={handleEndOnboarding}
onRequireLogin={handleRequireTaskLogin} onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas} onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage} onRefreshUsage={refreshUsage}
@@ -1330,6 +1344,7 @@ function App() {
return ( return (
<HomePage <HomePage
onOpenGenerate={() => handleSetView("workbench")} onOpenGenerate={() => handleSetView("workbench")}
onStartOnboarding={handleStartOnboarding}
onOpenCanvas={() => handleSetView("canvas")} onOpenCanvas={() => handleSetView("canvas")}
onOpenEcommerce={() => handleSetView("ecommerce")} onOpenEcommerce={() => handleSetView("ecommerce")}
onOpenScriptReview={() => handleSetView("scriptTokens")} onOpenScriptReview={() => handleSetView("scriptTokens")}
+500
View File
@@ -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<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,
);
}
+21 -2
View File
@@ -10,7 +10,7 @@ import {
SearchOutlined, SearchOutlined,
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } 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 "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient"; 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 [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null); const uploadInputRef = useRef<HTMLInputElement>(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) => { const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => {
e.preventDefault(); e.preventDefault();
@@ -270,7 +281,15 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
placeholder="搜索资产..." placeholder="搜索资产..."
/> />
</label> </label>
<button type="button" className="studio-generate-btn studio-generate-btn--compact" onClick={() => uploadInputRef.current?.click()} disabled={isUploading}> <button
type="button"
className={`studio-generate-btn studio-generate-btn--compact${isUploadDragging ? " is-dragging" : ""}`}
onClick={() => uploadInputRef.current?.click()}
onDragOver={handleUploadDragOver}
onDragLeave={handleUploadDragLeave}
onDrop={handleUploadDrop}
disabled={isUploading}
>
{isUploading ? <LoadingOutlined /> : <PlusOutlined />} {isUploading ? <LoadingOutlined /> : <PlusOutlined />}
{isUploading ? "上传中..." : "添加"} {isUploading ? "上传中..." : "添加"}
</button> </button>
@@ -61,6 +61,9 @@ function CharacterMixPage({
const abortRef = useRef(false); const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null); const taskIdRef = useRef<string | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
const characterInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
return () => { 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 ( return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移"> <section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<header className="image-workbench-topbar"> <header className="image-workbench-topbar">
@@ -342,6 +362,7 @@ function CharacterMixPage({
<div className="studio-panel__section-body"> <div className="studio-panel__section-body">
<label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}> <label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input <input
ref={characterInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
onChange={(event) => { onChange={(event) => {
@@ -383,6 +404,7 @@ function CharacterMixPage({
<div className="studio-panel__section-body"> <div className="studio-panel__section-body">
<label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}> <label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input <input
ref={videoInputRef}
type="file" type="file"
accept="video/*" accept="video/*"
onChange={(event) => { onChange={(event) => {
@@ -441,12 +463,21 @@ function CharacterMixPage({
) : null} ) : null}
</div> </div>
) : ( ) : (
<div className="studio-canvas-ghost"> <div
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
onClick={handleCanvasClick}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
>
<div className="studio-canvas-ghost__icon"> <div className="studio-canvas-ghost__icon">
<SwapOutlined /> <SwapOutlined />
</div> </div>
<div className="studio-canvas-ghost__title"></div> <div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"></div> <div className="studio-canvas-ghost__hint"> (PNG/JPG) (MP4/MOV/AVI)</div>
</div> </div>
) )
} }
@@ -6,7 +6,7 @@ import {
PictureOutlined, PictureOutlined,
UploadOutlined, UploadOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { communityClient } from "../../api/communityClient"; import { communityClient } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell"; import WorkspacePageShell from "../../components/WorkspacePageShell";
@@ -73,6 +73,29 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
const allowed = canManageCommunityCases(session); const allowed = canManageCommunityCases(session);
const imageInputRef = useRef<HTMLInputElement | null>(null); const imageInputRef = useRef<HTMLInputElement | null>(null);
const workflowInputRef = useRef<HTMLInputElement | null>(null); const workflowInputRef = useRef<HTMLInputElement | null>(null);
const [isImageDragging, setIsImageDragging] = useState(false);
const [isWorkflowDragging, setIsWorkflowDragging] = useState(false);
const handleImageDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsImageDragging(true); };
const handleImageDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsImageDragging(false); };
const handleImageDrop = (e: DragEvent) => {
e.preventDefault();
setIsImageDragging(false);
if (e.dataTransfer.files.length) {
void handleImageChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const handleWorkflowDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsWorkflowDragging(true); };
const handleWorkflowDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsWorkflowDragging(false); };
const handleWorkflowDrop = (e: DragEvent) => {
e.preventDefault();
setIsWorkflowDragging(false);
if (e.dataTransfer.files.length) {
void handleWorkflowChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const [target, setTarget] = useState<CaseTarget>("generation"); const [target, setTarget] = useState<CaseTarget>("generation");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -331,7 +354,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
</label> </label>
<div className="community-case-add-upload-row"> <div className="community-case-add-upload-row">
<input ref={imageInputRef} type="file" accept="image/*" hidden onChange={handleImageChange} /> <input ref={imageInputRef} type="file" accept="image/*" hidden onChange={handleImageChange} />
<button type="button" onClick={() => imageInputRef.current?.click()}> <button
type="button"
className={isImageDragging ? "is-dragging" : ""}
onClick={() => imageInputRef.current?.click()}
onDragOver={handleImageDragOver}
onDragLeave={handleImageDragLeave}
onDrop={handleImageDrop}
>
<UploadOutlined /> <UploadOutlined />
</button> </button>
@@ -345,7 +375,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
<> <>
<div className="community-case-add-upload-row"> <div className="community-case-add-upload-row">
<input ref={workflowInputRef} type="file" accept="application/json,.json" hidden onChange={handleWorkflowChange} /> <input ref={workflowInputRef} type="file" accept="application/json,.json" hidden onChange={handleWorkflowChange} />
<button type="button" onClick={() => workflowInputRef.current?.click()}> <button
type="button"
className={isWorkflowDragging ? "is-dragging" : ""}
onClick={() => workflowInputRef.current?.click()}
onDragOver={handleWorkflowDragOver}
onDragLeave={handleWorkflowDragLeave}
onDrop={handleWorkflowDrop}
>
<UploadOutlined /> <UploadOutlined />
JSON JSON
</button> </button>
@@ -98,6 +98,10 @@ function DigitalHumanPage({
activeTaskIdRef.current = activeTaskId; activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false); const keepaliveRestoredRef = useRef(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const audioInputRef = useRef<HTMLInputElement | null>(null);
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -171,6 +175,39 @@ function DigitalHumanPage({
} }
}; };
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);
const file = e.dataTransfer.files[0];
if (!file) return;
if (file.type.startsWith("image/")) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageName(file.name);
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
pushDebugEntry("选择图片", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
setNotice(`已拖放参考图 ${file.name}`);
} else if (file.type.startsWith("audio/")) {
if (audioPreview) URL.revokeObjectURL(audioPreview);
setAudioName(file.name);
setAudioFile(file);
setAudioPreview(URL.createObjectURL(file));
pushDebugEntry("选择音频", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
setNotice(`已拖放音频 ${file.name}`);
}
};
const handleCanvasClick = () => {
if (!imagePreview) {
imageInputRef.current?.click();
} else if (!audioPreview) {
audioInputRef.current?.click();
}
};
const handleDownloadResult = async () => { const handleDownloadResult = async () => {
if (!resultVideoUrl || isDownloadingResult) return; if (!resultVideoUrl || isDownloadingResult) return;
setIsDownloadingResult(true); setIsDownloadingResult(true);
@@ -463,6 +500,7 @@ function DigitalHumanPage({
<div className="studio-panel__section-body"> <div className="studio-panel__section-body">
<label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}> <label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input <input
ref={imageInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
onChange={(event) => { onChange={(event) => {
@@ -501,6 +539,7 @@ function DigitalHumanPage({
<div className="studio-panel__section-body"> <div className="studio-panel__section-body">
<label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}> <label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input <input
ref={audioInputRef}
type="file" type="file"
accept="audio/*" accept="audio/*"
onChange={(event) => { onChange={(event) => {
@@ -541,12 +580,21 @@ function DigitalHumanPage({
<img src={imagePreview} alt="参考人像" /> <img src={imagePreview} alt="参考人像" />
</div> </div>
) : ( ) : (
<div className="studio-canvas-ghost"> <div
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
onClick={handleCanvasClick}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
>
<div className="studio-canvas-ghost__icon"> <div className="studio-canvas-ghost__icon">
<CustomerServiceOutlined /> <CustomerServiceOutlined />
</div> </div>
<div className="studio-canvas-ghost__title"></div> <div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"></div> <div className="studio-canvas-ghost__hint"> (PNG/JPG/WEBP) (MP3/WAV/M4A)</div>
</div> </div>
) )
} }
+5 -4
View File
@@ -988,6 +988,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const selectedProductSetOutput = const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!; productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!; const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const cloneRequirementPlaceholder =
cloneOutput === "model"
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描写(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
: "建议包含以下信息,产品名称,核心卖点,期望场景,具体参数";
const productSetPreviewReady = productSetStatus === "done"; const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = useMemo( const cloneSetTotal = useMemo(
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0), () => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
@@ -1934,7 +1938,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
age: cloneModelAge, age: cloneModelAge,
ethnicity: cloneModelEthnicity, ethnicity: cloneModelEthnicity,
body: cloneModelBody, body: cloneModelBody,
appearance: cloneModelAppearance,
scenes: selectedCloneModelScenes, scenes: selectedCloneModelScenes,
customScene: cloneModelCustomScene, customScene: cloneModelCustomScene,
} }
@@ -2225,7 +2228,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
cloneModelSelects={cloneModelSelects} cloneModelSelects={cloneModelSelects}
openCloneModelSelect={openCloneModelSelect} openCloneModelSelect={openCloneModelSelect}
cloneModelSelectDropUp={cloneModelSelectDropUp} cloneModelSelectDropUp={cloneModelSelectDropUp}
cloneModelAppearance={cloneModelAppearance}
cloneVideoQuality={cloneVideoQuality} cloneVideoQuality={cloneVideoQuality}
cloneVideoQualityOptions={cloneVideoQualityOptions} cloneVideoQualityOptions={cloneVideoQualityOptions}
cloneVideoDuration={cloneVideoDuration} cloneVideoDuration={cloneVideoDuration}
@@ -2257,7 +2259,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setCloneModelCustomScene={setCloneModelCustomScene} setCloneModelCustomScene={setCloneModelCustomScene}
setOpenCloneModelSelect={setOpenCloneModelSelect} setOpenCloneModelSelect={setOpenCloneModelSelect}
setCloneModelSelectDropUp={setCloneModelSelectDropUp} setCloneModelSelectDropUp={setCloneModelSelectDropUp}
setCloneModelAppearance={setCloneModelAppearance}
setCloneVideoQuality={setCloneVideoQuality} setCloneVideoQuality={setCloneVideoQuality}
setCloneVideoDuration={setCloneVideoDuration} setCloneVideoDuration={setCloneVideoDuration}
clampCloneVideoDuration={clampCloneVideoDuration} clampCloneVideoDuration={clampCloneVideoDuration}
@@ -2620,7 +2621,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (event.key === "Escape") setRequirementImageMentionQuery(null); if (event.key === "Escape") setRequirementImageMentionQuery(null);
}} }}
maxLength={500} maxLength={500}
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数" placeholder={cloneRequirementPlaceholder}
/> />
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? ( {requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} /> <ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
@@ -100,7 +100,6 @@ interface EcommerceClonePanelProps {
cloneModelSelects: CloneModelSelectItem[]; cloneModelSelects: CloneModelSelectItem[];
openCloneModelSelect: CloneModelSelectKey | null; openCloneModelSelect: CloneModelSelectKey | null;
cloneModelSelectDropUp: boolean; cloneModelSelectDropUp: boolean;
cloneModelAppearance: string;
cloneVideoQuality: CloneVideoQualityKey; cloneVideoQuality: CloneVideoQualityKey;
cloneVideoQualityOptions: CloneVideoQualityOption[]; cloneVideoQualityOptions: CloneVideoQualityOption[];
cloneVideoDuration: number; cloneVideoDuration: number;
@@ -132,7 +131,6 @@ interface EcommerceClonePanelProps {
setCloneModelCustomScene: (value: string) => void; setCloneModelCustomScene: (value: string) => void;
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void; setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
setCloneModelSelectDropUp: (value: boolean) => void; setCloneModelSelectDropUp: (value: boolean) => void;
setCloneModelAppearance: (value: string) => void;
setCloneVideoQuality: (value: CloneVideoQualityKey) => void; setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
setCloneVideoDuration: (value: number) => void; setCloneVideoDuration: (value: number) => void;
clampCloneVideoDuration: (value: number) => number; clampCloneVideoDuration: (value: number) => number;
@@ -172,7 +170,6 @@ export default function EcommerceClonePanel({
cloneModelSelects, cloneModelSelects,
openCloneModelSelect, openCloneModelSelect,
cloneModelSelectDropUp, cloneModelSelectDropUp,
cloneModelAppearance,
cloneVideoQuality, cloneVideoQuality,
cloneVideoQualityOptions, cloneVideoQualityOptions,
cloneVideoDuration, cloneVideoDuration,
@@ -204,7 +201,6 @@ export default function EcommerceClonePanel({
setCloneModelCustomScene, setCloneModelCustomScene,
setOpenCloneModelSelect, setOpenCloneModelSelect,
setCloneModelSelectDropUp, setCloneModelSelectDropUp,
setCloneModelAppearance,
setCloneVideoQuality, setCloneVideoQuality,
setCloneVideoDuration, setCloneVideoDuration,
clampCloneVideoDuration, clampCloneVideoDuration,
@@ -668,14 +664,6 @@ export default function EcommerceClonePanel({
); );
})} })}
</div> </div>
<label className="clone-ai-model-textarea">
<strong></strong>
<textarea
value={cloneModelAppearance}
onChange={(event) => setCloneModelAppearance(event.target.value)}
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
/>
</label>
</div> </div>
)} )}
</div> </div>
@@ -758,7 +746,7 @@ export default function EcommerceClonePanel({
style={{ display: "none" }} style={{ display: "none" }}
/> />
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}> <button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"} {videoOutfitVideoUrl ? "重新上传视频" : "点击上传视频"}
</button> </button>
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info"></span> : null} {videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div> </div>
@@ -774,7 +762,7 @@ export default function EcommerceClonePanel({
style={{ display: "none" }} style={{ display: "none" }}
/> />
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}> <button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"} {videoOutfitRefUrl ? "重新上传参考图" : "点击上传参考图"}
</button> </button>
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info"></span> : null} {videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div> </div>
@@ -1,5 +1,5 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons"; import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react"; import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar"; import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceDetailPanelProps { interface EcommerceDetailPanelProps {
@@ -59,6 +59,31 @@ export default function EcommerceDetailPanel({
handleDetailGenerate, handleDetailGenerate,
onCancelGenerate, onCancelGenerate,
}: EcommerceDetailPanelProps) { }: EcommerceDetailPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files.length) {
handleDetailUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
return ( return (
<> <>
<div className="product-clone-panel__scroll"> <div className="product-clone-panel__scroll">
@@ -67,7 +92,14 @@ export default function EcommerceDetailPanel({
<QuestionCircleOutlined /> <QuestionCircleOutlined />
</h2> </h2>
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}> <button
type="button"
className={`product-clone-upload-zone product-detail-upload${isDragging ? " is-dragging" : ""}`}
onClick={() => detailInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<strong> <strong>
<CloudUploadOutlined /> <CloudUploadOutlined />
@@ -1,5 +1,5 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons"; import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react"; import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar"; import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceTryOnPanelProps { interface EcommerceTryOnPanelProps {
@@ -73,12 +73,44 @@ export default function EcommerceTryOnPanel({
handleTryOnGenerate, handleTryOnGenerate,
onCancelGenerate, onCancelGenerate,
}: EcommerceTryOnPanelProps) { }: EcommerceTryOnPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files.length) {
handleGarmentUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
return ( return (
<> <>
<div className="product-clone-panel__scroll"> <div className="product-clone-panel__scroll">
<section className="product-clone-field"> <section className="product-clone-field">
<h2></h2> <h2></h2>
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}> <button
type="button"
className={`product-clone-upload-zone product-try-on-upload${isDragging ? " is-dragging" : ""}`}
onClick={() => garmentInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<strong> <strong>
<CloudUploadOutlined /> <CloudUploadOutlined />
+3 -2
View File
@@ -35,6 +35,7 @@ const {
interface HomePageProps { interface HomePageProps {
onOpenGenerate: () => void; onOpenGenerate: () => void;
onStartOnboarding?: () => void;
onOpenCanvas?: () => void; onOpenCanvas?: () => void;
onOpenEcommerce: () => void; onOpenEcommerce: () => void;
onOpenScriptReview?: () => void; onOpenScriptReview?: () => void;
@@ -468,7 +469,7 @@ function EcommerceFeatureShowcase() {
); );
} }
function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) { function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1"); const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
const [activeSlideIndex, setActiveSlideIndex] = useState(0); const [activeSlideIndex, setActiveSlideIndex] = useState(0);
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null); const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
@@ -620,7 +621,7 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
</div> </div>
<div className="omni-home__actions" aria-label="首页入口"> <div className="omni-home__actions" aria-label="首页入口">
<button type="button" className="omni-home__entry" onClick={onOpenGenerate}> <button type="button" className="omni-home__entry" onClick={onStartOnboarding || onOpenGenerate}>
<PlusOutlined /> <PlusOutlined />
<span></span> <span></span>
</button> </button>
@@ -947,19 +947,22 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div> </div>
</div> </div>
) : ( ) : (
<button <div
type="button" className={`studio-canvas-ghost${isInpaintDragging ? " is-dragging" : ""}`}
className={`image-workbench-empty image-workbench-empty--button${isInpaintDragging ? " is-dragging" : ""}`}
onClick={() => inpaintFileInputRef.current?.click()} onClick={() => inpaintFileInputRef.current?.click()}
onDragOver={handleInpaintDragOver} onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave} onDragLeave={handleInpaintDragLeave}
onDrop={handleInpaintDrop} onDrop={handleInpaintDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inpaintFileInputRef.current?.click(); }}
> >
{isInpaintDragging ? <span className="image-workbench-upload-drop-overlay" style={{ borderRadius: "var(--radius-sm)" }}><span></span></span> : null} <div className="studio-canvas-ghost__icon">
<FileImageOutlined /> <FileImageOutlined />
<strong></strong> </div>
<span> PNG / JPG / WebP</span> <div className="studio-canvas-ghost__title"></div>
</button> <div className="studio-canvas-ghost__hint"> PNG / JPG / WebP使</div>
</div>
)} )}
</section> </section>
@@ -1389,12 +1392,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<img src={referenceImage} alt="参考图预览" /> <img src={referenceImage} alt="参考图预览" />
</div> </div>
) : ( ) : (
<div className="studio-canvas-ghost"> <div
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
>
<div className="studio-canvas-ghost__icon"> <div className="studio-canvas-ghost__icon">
<PictureOutlined /> <PictureOutlined />
</div> </div>
<div className="studio-canvas-ghost__title"></div> <div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"></div> <div className="studio-canvas-ghost__hint"> (PNG / JPG / WebP)</div>
</div> </div>
)} )}
</section> </section>
+37 -4
View File
@@ -18,7 +18,7 @@ import {
ShareAltOutlined, ShareAltOutlined,
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type FormEvent, type KeyboardEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import "../../styles/pages/profile.css"; import "../../styles/pages/profile.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -228,6 +228,28 @@ function ProfilePage({
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访"; const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访";
const avatarInputRef = useRef<HTMLInputElement | null>(null); const avatarInputRef = useRef<HTMLInputElement | null>(null);
const bannerInputRef = useRef<HTMLInputElement | null>(null); const bannerInputRef = useRef<HTMLInputElement | null>(null);
const [isBannerDragging, setIsBannerDragging] = useState(false);
const [isAvatarDragging, setIsAvatarDragging] = useState(false);
const handleBannerDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsBannerDragging(true); };
const handleBannerDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsBannerDragging(false); };
const handleBannerDrop = (e: DragEvent) => {
e.preventDefault();
setIsBannerDragging(false);
if (e.dataTransfer.files.length) {
handleBannerUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const handleAvatarDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsAvatarDragging(true); };
const handleAvatarDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsAvatarDragging(false); };
const handleAvatarDrop = (e: DragEvent) => {
e.preventDefault();
setIsAvatarDragging(false);
if (e.dataTransfer.files.length) {
handleAvatarUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const [mode, setMode] = useState<WebAuthMode>("login"); const [mode, setMode] = useState<WebAuthMode>("login");
const [authTab, setAuthTab] = useState<AuthTab>("password"); const [authTab, setAuthTab] = useState<AuthTab>("password");
@@ -1047,8 +1069,11 @@ function ProfilePage({
<input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} /> <input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} />
<input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} /> <input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
<header <header
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`} className={`profile-page__banner${bannerUrl ? " has-image" : ""}${isBannerDragging ? " is-dragging" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined} style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
onDragOver={handleBannerDragOver}
onDragLeave={handleBannerDragLeave}
onDrop={handleBannerDrop}
> >
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景"> <button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
<CameraOutlined /> <CameraOutlined />
@@ -1060,13 +1085,21 @@ function ProfilePage({
<div className="profile-page__body"> <div className="profile-page__body">
<aside className="profile-page__sidebar"> <aside className="profile-page__sidebar">
<div className="profile-page__sidebar-head"> <div className="profile-page__sidebar-head">
<div className="profile-page__avatar-ring"> <div className={`profile-page__avatar-ring${isAvatarDragging ? " is-dragging" : ""}`}>
{avatarUrl ? ( {avatarUrl ? (
<img className="profile-page__avatar" src={avatarUrl} alt="" /> <img className="profile-page__avatar" src={avatarUrl} alt="" />
) : ( ) : (
<span className="profile-page__avatar">{avatarLabel}</span> <span className="profile-page__avatar">{avatarLabel}</span>
)} )}
<button type="button" className="profile-page__avatar-edit" onClick={() => avatarInputRef.current?.click()} aria-label="更换头像"> <button
type="button"
className="profile-page__avatar-edit"
onClick={() => avatarInputRef.current?.click()}
onDragOver={handleAvatarDragOver}
onDragLeave={handleAvatarDragLeave}
onDrop={handleAvatarDrop}
aria-label="更换头像"
>
<CameraOutlined /> <CameraOutlined />
</button> </button>
<span className="profile-page__avatar-badge"> <span className="profile-page__avatar-badge">
@@ -601,11 +601,20 @@ function ResolutionUpscalePage({
</div> </div>
) )
) : ( ) : (
<div className="studio-canvas-ghost"> <div
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
>
<div className="studio-canvas-ghost__icon"> <div className="studio-canvas-ghost__icon">
<ThunderboltOutlined /> <ThunderboltOutlined />
</div> </div>
<div className="studio-canvas-ghost__title">{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</div> <div className="studio-canvas-ghost__title">{mode === "image" ? "点击或拖拽上传图片" : "点击或拖拽上传视频"}</div>
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div> <div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
</div> </div>
)} )}
@@ -679,14 +679,23 @@ function ScriptTokensPage() {
</div> </div>
) : !result && ( ) : !result && (
<div className="script-eval-v5-input-section"> <div className="script-eval-v5-input-section">
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测"> <div className={`script-eval-v5-illustration${isDragging ? " is-dragging" : ""}`} aria-label="上传剧本并开始评测">
<div <div
className="script-eval-v5-illustration-hit" className="script-eval-v5-illustration-hit"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onKeyDown={uploadKeyDown} onKeyDown={uploadKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
> >
{isDragging && (
<div className="script-eval-v5-upload-drop-overlay">
<UploadOutlined />
<span></span>
</div>
)}
<div className="script-eval-v5-upload-card-icon"> <div className="script-eval-v5-upload-card-icon">
<ShellIcon name="file-text" /> <ShellIcon name="file-text" />
</div> </div>
@@ -447,15 +447,19 @@ function SubtitleRemovalPage({
</div> </div>
) : ( ) : (
<div <div
className="studio-canvas-ghost" className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleFileDrop} onDrop={handleFileDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
> >
<div className="studio-canvas-ghost__icon"> <div className="studio-canvas-ghost__icon">
<VideoCameraOutlined /> <VideoCameraOutlined />
</div> </div>
<div className="studio-canvas-ghost__title"></div> <div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> MP4 1GB 1080P</div> <div className="studio-canvas-ghost__hint"> MP4 1GB 1080P</div>
</div> </div>
)} )}
@@ -12,7 +12,7 @@ import {
ScissorOutlined, ScissorOutlined,
SwapOutlined, SwapOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css"; import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css"; import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -48,6 +48,7 @@ function WatermarkRemovalPage({
const [status, setStatus] = useState("上传含水印的图片,点击开始去水印"); const [status, setStatus] = useState("上传含水印的图片,点击开始去水印");
const [activeTaskId, setActiveTaskId] = useState(""); const [activeTaskId, setActiveTaskId] = useState("");
const [taskProgress, setTaskProgress] = useState(0); const [taskProgress, setTaskProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isSavingAsset, setIsSavingAsset] = useState(false); const [isSavingAsset, setIsSavingAsset] = useState(false);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
@@ -124,6 +125,10 @@ function WatermarkRemovalPage({
setStatus(`已导入 ${file.name}`); setStatus(`已导入 ${file.name}`);
}; };
const handleDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleCanvasDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); handleFileDrop(e); };
const handleImportUrl = () => { const handleImportUrl = () => {
const normalizedUrl = sourceUrl.trim(); const normalizedUrl = sourceUrl.trim();
if (!/^https?:\/\//i.test(normalizedUrl)) { if (!/^https?:\/\//i.test(normalizedUrl)) {
@@ -403,17 +408,22 @@ function WatermarkRemovalPage({
</div> </div>
</div> </div>
) : ( ) : (
<button <div
type="button" className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
className="image-workbench-empty image-workbench-empty--button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()} onDragOver={handleDragOver}
onDrop={handleFileDrop} onDragLeave={handleDragLeave}
onDrop={handleCanvasDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
> >
<DeleteOutlined /> <div className="studio-canvas-ghost__icon">
<strong></strong> <DeleteOutlined />
<span> PNG / JPG / WebP</span> </div>
</button> <div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> PNG / JPG / WebP"开始去水印"</div>
</div>
)} )}
</section> </section>
</main> </main>
+148 -1
View File
@@ -193,12 +193,15 @@ import {
PromptPreviewLayer, PromptPreviewLayer,
} from "./WorkbenchPromptPreview"; } from "./WorkbenchPromptPreview";
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips"; import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
import OnboardingTour, { type TourPhaseId } from "../../components/OnboardingTour";
export type { WorkbenchResultActionPayload } from "./workbenchConstants"; export type { WorkbenchResultActionPayload } from "./workbenchConstants";
interface WorkbenchPageProps { interface WorkbenchPageProps {
isAuthenticated: boolean; isAuthenticated: boolean;
session: WebUserSession | null; session: WebUserSession | null;
onboarding?: boolean;
onEndOnboarding?: () => void;
onRequireLogin: (input: CreatePreviewTaskInput) => void; onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void; onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
onRefreshUsage?: () => void; onRefreshUsage?: () => void;
@@ -231,6 +234,8 @@ function formatCreditValue(value: number): string {
function WorkbenchPage({ function WorkbenchPage({
isAuthenticated, isAuthenticated,
session, session,
onboarding,
onEndOnboarding,
onRequireLogin, onRequireLogin,
onOpenResultInCanvas, onOpenResultInCanvas,
onRefreshUsage, onRefreshUsage,
@@ -264,7 +269,41 @@ function WorkbenchPage({
const renderedMessageIdsRef = useRef<string[]>([]); const renderedMessageIdsRef = useRef<string[]>([]);
const hasHandledInitialMessagesRef = useRef(false); 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 [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages())); const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory()); const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
@@ -294,6 +333,34 @@ function WorkbenchPage({
const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null); const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
const [deleteSubmitting, setDeleteSubmitting] = useState(false); 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 [, setGenerationProgress] = useState(0);
const [cursorIndex, setCursorIndex] = useState(0); const [cursorIndex, setCursorIndex] = useState(0);
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 }); const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
@@ -427,6 +494,7 @@ function WorkbenchPage({
const toolTheme = MODE_META[activeMode]; const toolTheme = MODE_META[activeMode];
const workbenchAccent = "#00ff88"; const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0; const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
const referenceCount = referenceItems.length; const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel); const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue = const activeModelValue =
@@ -1572,7 +1640,41 @@ function WorkbenchPage({
setToolbarMenuId((current) => (current === menuId ? null : menuId)); 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) => { 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); setActiveMode(mode);
setToolbarMenuId(null); setToolbarMenuId(null);
setReferencePreviewOpen(false); setReferencePreviewOpen(false);
@@ -2795,6 +2897,7 @@ function WorkbenchPage({
className="wb-composer__ref-upload" className="wb-composer__ref-upload"
onClick={handleReferenceUploadClick} onClick={handleReferenceUploadClick}
disabled={disabled} disabled={disabled}
data-onboarding={obTarget({ chat: "onboarding-chat-upload", image: "onboarding-image-upload", video: "onboarding-video-upload" })}
aria-label={`上传${referenceUploadLabel}`} aria-label={`上传${referenceUploadLabel}`}
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined} aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined} aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
@@ -2854,6 +2957,7 @@ function WorkbenchPage({
const renderComposerToolbar = (disabled = false, showStop = false) => ( const renderComposerToolbar = (disabled = false, showStop = false) => (
<div className="wb-composer__toolbar"> <div className="wb-composer__toolbar">
<div className="wb-composer__toolbar-left"> <div className="wb-composer__toolbar-left">
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
<SelectChip <SelectChip
chipId="studio-mode" chipId="studio-mode"
value={activeMode} value={activeMode}
@@ -2866,8 +2970,10 @@ function WorkbenchPage({
ariaLabel="工作台模式" ariaLabel="工作台模式"
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
{activeMode === "chat" && ( {activeMode === "chat" && (
<> <>
<span data-onboarding={obTarget({ chat: "onboarding-chat-model" })}>
<SelectChip <SelectChip
chipId="chat-model" chipId="chat-model"
value={chatModel} value={chatModel}
@@ -2880,6 +2986,8 @@ function WorkbenchPage({
ariaLabel="对话模型" ariaLabel="对话模型"
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
<span data-onboarding={obTarget({ chat: "onboarding-chat-speed" })}>
<SelectChip <SelectChip
chipId="chat-speed" chipId="chat-speed"
value={thinkingSpeed} value={thinkingSpeed}
@@ -2892,6 +3000,8 @@ function WorkbenchPage({
ariaLabel="思考速度" ariaLabel="思考速度"
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
<span data-onboarding={obTarget({ chat: "onboarding-chat-depth" })}>
<SelectChip <SelectChip
chipId="chat-depth" chipId="chat-depth"
value={thinkingDepth} value={thinkingDepth}
@@ -2904,10 +3014,12 @@ function WorkbenchPage({
ariaLabel="思考深度" ariaLabel="思考深度"
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
</> </>
)} )}
{activeMode === "image" && ( {activeMode === "image" && (
<> <>
<span data-onboarding={obTarget({ image: "onboarding-image-model" })}>
<SelectChip <SelectChip
chipId="image-model" chipId="image-model"
value={imageModel} value={imageModel}
@@ -2919,6 +3031,8 @@ function WorkbenchPage({
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }} onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
<span data-onboarding={obTarget({ image: "onboarding-image-settings" })}>
<CompoundSelectChip <CompoundSelectChip
chipId="image-settings" chipId="image-settings"
summary={imageSettingsSummary} summary={imageSettingsSummary}
@@ -2928,7 +3042,9 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")} onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
{GRID_SUPPORTED_MODELS.has(imageModel) && ( {GRID_SUPPORTED_MODELS.has(imageModel) && (
<span data-onboarding={obTarget({ image: "onboarding-image-grid" })}>
<SelectChip <SelectChip
chipId="image-grid-mode" chipId="image-grid-mode"
value={imageGridMode} value={imageGridMode}
@@ -2940,11 +3056,13 @@ function WorkbenchPage({
onChange={setImageGridMode} onChange={setImageGridMode}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
)} )}
</> </>
)} )}
{activeMode === "video" && ( {activeMode === "video" && (
<> <>
<span data-onboarding={obTarget({ video: "onboarding-video-model" })}>
<SelectChip <SelectChip
chipId="video-model" chipId="video-model"
value={videoModel} value={videoModel}
@@ -2956,6 +3074,8 @@ function WorkbenchPage({
onChange={setVideoModel} onChange={setVideoModel}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-frame" })}>
<SelectChip <SelectChip
chipId="video-mode" chipId="video-mode"
value={videoFrameMode} value={videoFrameMode}
@@ -2967,6 +3087,8 @@ function WorkbenchPage({
onChange={setVideoFrameMode} onChange={setVideoFrameMode}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-ratio" })}>
<CompoundSelectChip <CompoundSelectChip
chipId="video-ratio" chipId="video-ratio"
summary={videoRatio} summary={videoRatio}
@@ -2976,6 +3098,8 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("video-ratio")} onToggle={() => toggleToolbarMenu("video-ratio")}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-duration" })}>
<InlineOptionChip <InlineOptionChip
chipId="video-duration" chipId="video-duration"
value={videoDuration} value={videoDuration}
@@ -2988,6 +3112,8 @@ function WorkbenchPage({
onChange={setVideoDuration} onChange={setVideoDuration}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-quality" })}>
<InlineOptionChip <InlineOptionChip
chipId="video-quality" chipId="video-quality"
value={videoQuality} value={videoQuality}
@@ -3000,6 +3126,7 @@ function WorkbenchPage({
onChange={setVideoQuality} onChange={setVideoQuality}
direction={dropdownDirection} direction={dropdownDirection}
/> />
</span>
</> </>
)} )}
</div> </div>
@@ -3013,6 +3140,7 @@ function WorkbenchPage({
disabled={sendDisabled || isGenerating} disabled={sendDisabled || isGenerating}
title={isGenerating ? "任务处理中" : sendButtonTitle} title={isGenerating ? "任务处理中" : sendButtonTitle}
aria-label={isGenerating ? "任务处理中" : sendButtonTitle} aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
data-onboarding={obTarget({ video: "onboarding-video-generate" })}
onClick={() => { onClick={() => {
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", { if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
mode: activeMode, mode: activeMode,
@@ -3171,6 +3299,7 @@ function WorkbenchPage({
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`} className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
placeholder={composerPlaceholder} placeholder={composerPlaceholder}
value={inputValue} value={inputValue}
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
onChange={handlePromptChange} onChange={handlePromptChange}
onSelect={handlePromptSelectionChange} onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange} onKeyUp={handlePromptSelectionChange}
@@ -3236,6 +3365,14 @@ function WorkbenchPage({
{renderMessagePreviewOverlay()} {renderMessagePreviewOverlay()}
{renderPromptCaseOverlay()} {renderPromptCaseOverlay()}
{renderDeleteDialog()} {renderDeleteDialog()}
<OnboardingTour
active={Boolean(effectiveOnboarding)}
phase={tourPhase}
stepIndex={tourStep}
onNext={handleTourNext}
onSkip={handleTourSkip}
onDone={handleTourDone}
/>
</section> </section>
); );
} }
@@ -3357,6 +3494,7 @@ function WorkbenchPage({
placeholder={composerPlaceholder} placeholder={composerPlaceholder}
value={inputValue} value={inputValue}
disabled={false} disabled={false}
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
onChange={handlePromptChange} onChange={handlePromptChange}
onSelect={handlePromptSelectionChange} onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange} onKeyUp={handlePromptSelectionChange}
@@ -3404,6 +3542,15 @@ function WorkbenchPage({
{showRechargeModal && RechargeModal ? ( {showRechargeModal && RechargeModal ? (
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} /> <RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
) : null} ) : null}
<OnboardingTour
active={Boolean(effectiveOnboarding)}
phase={tourPhase}
stepIndex={tourStep}
onNext={handleTourNext}
onSkip={handleTourSkip}
onDone={handleTourDone}
/>
</section> </section>
); );
} }
+4 -4
View File
@@ -149,14 +149,14 @@ export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [ export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" }, { value: "default", label: "默认" },
{ value: "high", label: "高" }, { value: "high", label: "思考速度:高" },
{ value: "ultra", label: "急速" }, { value: "ultra", label: "思考速度:急速" },
]; ];
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [ export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" }, { value: "default", label: "默认" },
{ value: "strong", label: "强" }, { value: "strong", label: "推理深度:强" },
{ value: "extreme", label: "极限" }, { value: "extreme", label: "推理深度:极限" },
]; ];
export const CHAT_NATURAL_SYSTEM_PROMPT = [ export const CHAT_NATURAL_SYSTEM_PROMPT = [
+317
View File
@@ -0,0 +1,317 @@
/* ─── Onboarding Tour ──────────────────────────────────────── */
.onboarding-root {
position: fixed;
inset: 0;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
font-family: Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.onboarding-root.is-visible {
opacity: 1;
}
/* ─── Overlay ──────────────────────────────────────────────── */
.onboarding-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.64);
pointer-events: auto;
transition: clip-path 0.32s cubic-bezier(0.4, 0, 0.2, 1);
will-change: clip-path;
}
/* During interactive steps, let clicks pass through to dropdowns etc. */
.onboarding-overlay--passive {
pointer-events: none;
}
/* Tooltip also lets clicks through during interactive steps, except for buttons */
.onboarding-tooltip--passive {
pointer-events: none;
}
.onboarding-tooltip--passive .onboarding-tooltip__btn {
pointer-events: auto;
}
/* ─── Spotlight ring ───────────────────────────────────────── */
.onboarding-spotlight {
position: fixed;
z-index: 10001;
border-radius: 10px;
border: 2px solid rgba(0, 255, 136, 0.5);
box-shadow:
0 0 22px rgba(0, 255, 136, 0.18),
0 0 50px rgba(0, 255, 136, 0.06),
inset 0 0 0 1px rgba(0, 255, 136, 0.05);
pointer-events: none;
transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
top 0.32s cubic-bezier(0.4, 0, 0.2, 1),
width 0.32s cubic-bezier(0.4, 0, 0.2, 1),
height 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.onboarding-spotlight__pulse {
position: absolute;
inset: -4px;
border-radius: 14px;
border: 1px solid rgba(0, 255, 136, 0.3);
animation: ob-pulse 2.4s ease-out infinite;
}
.onboarding-spotlight__pulse--delay {
animation-delay: 1.2s;
}
@keyframes ob-pulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(1.08); opacity: 0; }
}
/* ─── Connector SVG ────────────────────────────────────────── */
.onboarding-connector {
position: fixed;
inset: 0;
z-index: 10002;
width: 100%;
height: 100%;
pointer-events: none;
}
.onboarding-connector__path {
animation: ob-dash 1.6s linear infinite;
stroke-dashoffset: 0;
}
@keyframes ob-dash {
to { stroke-dashoffset: -24; }
}
.onboarding-connector__dot {
animation: ob-dot-pulse 1.4s ease-in-out infinite;
}
@keyframes ob-dot-pulse {
0%, 100% { r: 4; opacity: 0.7; }
50% { r: 7; opacity: 1; }
}
/* ─── Tooltip card ─────────────────────────────────────────── */
.onboarding-tooltip {
position: fixed;
z-index: 10003;
width: min(92vw, 360px);
padding: 20px 22px 18px;
border-radius: 16px;
background: var(--bg-elevated, #1e1e1e);
border: 1px solid var(--border-subtle, #333);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 0 60px rgba(0, 255, 136, 0.04);
pointer-events: auto;
transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
top 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.onboarding-tooltip--pop {
animation: ob-pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes ob-pop-in {
from { opacity: 0; transform: scale(0.92) translateY(6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Tooltip arrow — CSS triangle pointing toward target */
.onboarding-tooltip__arrow {
position: absolute;
width: 0;
height: 0;
display: none; /* replaced by SVG connector; fallback for simple cases */
}
/* ─── Tooltip head ─────────────────────────────────────────── */
.onboarding-tooltip__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.onboarding-tooltip__phase-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 10px;
border-radius: 999px;
background: rgba(var(--accent-rgb, 0, 255, 136), 0.12);
color: var(--accent, #00ff88);
}
.onboarding-tooltip__counter {
font-size: 11px;
font-weight: 600;
color: var(--fg-muted, #777);
font-variant-numeric: tabular-nums;
}
/* ─── Tooltip body ─────────────────────────────────────────── */
.onboarding-tooltip__title {
display: block;
font-size: 17px;
font-weight: 700;
color: var(--fg-body, #e5e5e5);
margin-bottom: 6px;
line-height: 1.3;
}
.onboarding-tooltip__desc {
font-size: 13px;
line-height: 1.6;
color: var(--fg-muted, #999);
margin: 0 0 4px;
}
.onboarding-tooltip__action-hint {
font-size: 13px;
line-height: 1.5;
color: var(--accent, #00ff88);
margin: 8px 0 4px;
font-weight: 600;
animation: ob-hint-blink 1.8s ease-in-out infinite;
}
@keyframes ob-hint-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ─── Tooltip actions ──────────────────────────────────────── */
.onboarding-tooltip__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.onboarding-tooltip__btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 16px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease, transform 0.12s ease;
font-family: inherit;
white-space: nowrap;
}
.onboarding-tooltip__btn:active {
transform: scale(0.97);
}
.onboarding-tooltip__btn--primary {
background: var(--accent, #00ff88);
color: #000;
}
.onboarding-tooltip__btn--primary:hover {
opacity: 0.85;
}
.onboarding-tooltip__btn--ghost {
background: transparent;
color: var(--fg-muted, #888);
border: 1px solid var(--border-subtle, #444);
}
.onboarding-tooltip__btn--ghost:hover {
color: var(--fg-body, #e5e5e5);
border-color: var(--fg-muted, #888);
}
.onboarding-tooltip__wait-hint {
font-size: 11px;
color: var(--fg-muted, #777);
font-style: italic;
animation: ob-hint-blink 2s ease-in-out infinite;
}
/* ─── Progress bar (bottom-right) ──────────────────────────── */
.onboarding-progress {
position: fixed;
z-index: 10002;
bottom: 24px;
right: 32px;
display: flex;
align-items: center;
gap: 40px;
pointer-events: auto;
}
.onboarding-progress__phase {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.onboarding-progress__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-subtle, #444);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
}
.onboarding-progress__dot.is-active {
background: var(--accent, #00ff88);
transform: scale(1.4);
box-shadow: 0 0 12px rgba(0, 255, 136, 0.4);
}
.onboarding-progress__dot.is-done {
background: rgba(var(--accent-rgb, 0, 255, 136), 0.5);
}
.onboarding-progress__phase span {
font-size: 10px;
color: var(--fg-muted, #666);
white-space: nowrap;
}
.onboarding-progress__phase .is-active + span,
.onboarding-progress__phase .is-done + span {
color: var(--fg-body, #ccc);
}
/* ─── Responsive ───────────────────────────────────────────── */
@media (max-width: 640px) {
.onboarding-tooltip {
width: calc(100vw - 20px);
left: 10px !important;
}
.onboarding-progress {
gap: 20px;
}
}
+72 -97
View File
@@ -1542,9 +1542,7 @@
.product-clone-page[data-tool="clone"] .clone-ai-replicate-panel { .product-clone-page[data-tool="clone"] .clone-ai-replicate-panel {
display: grid; display: grid;
flex: 0 0 auto; flex: 0 0 auto;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 9px; gap: 9px;
min-height: 0;
overflow: visible; overflow: visible;
border: 1px solid #303540; border: 1px solid #303540;
border-radius: 14px; border-radius: 14px;
@@ -1874,11 +1872,8 @@
.product-clone-page[data-tool="clone"] .clone-ai-module-panel { .product-clone-page[data-tool="clone"] .clone-ai-module-panel {
display: grid; display: grid;
flex: 0 0 272px; flex: 0 0 auto;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px; gap: 10px;
height: 272px;
min-height: 0;
border: 1px solid #303540; border: 1px solid #303540;
border-radius: 14px; border-radius: 14px;
background: #1c1f26; background: #1c1f26;
@@ -1906,25 +1901,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start; align-content: start;
gap: 8px; gap: 8px;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
} }
.product-clone-page[data-tool="clone"] .clone-ai-module-list button { .product-clone-page[data-tool="clone"] .clone-ai-module-list button {
@@ -1981,11 +1957,8 @@
.product-clone-page[data-tool="clone"] .clone-ai-model-panel { .product-clone-page[data-tool="clone"] .clone-ai-model-panel {
display: grid; display: grid;
flex: 0 0 272px; flex: 0 0 auto;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px; gap: 10px;
height: 272px;
min-height: 0;
border: 1px solid #303540; border: 1px solid #303540;
border-radius: 14px; border-radius: 14px;
background: #1c1f26; background: #1c1f26;
@@ -2032,25 +2005,7 @@
} }
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll { .product-clone-page[data-tool="clone"] .clone-ai-model-scroll {
min-height: 0; overflow: visible;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
} }
.product-clone-page[data-tool="clone"] .clone-ai-model-scenes, .product-clone-page[data-tool="clone"] .clone-ai-model-scenes,
@@ -2223,16 +2178,12 @@
z-index: 30; z-index: 30;
display: grid; display: grid;
gap: 4px; gap: 4px;
max-height: 150px;
overflow-y: auto;
border: 1px solid #303540; border: 1px solid #303540;
border-radius: 8px; border-radius: 8px;
background: #22252d; background: #22252d;
background-image: none; background-image: none;
padding: 5px; padding: 5px;
box-shadow: none; box-shadow: none;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
transform-origin: top center; transform-origin: top center;
animation: clone-ai-model-select-pop 160ms cubic-bezier(0.2, 0.82, 0.2, 1) both; animation: clone-ai-model-select-pop 160ms cubic-bezier(0.2, 0.82, 0.2, 1) both;
} }
@@ -2244,20 +2195,6 @@
animation-name: clone-ai-model-select-pop-up; animation-name: clone-ai-model-select-pop-up;
} }
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu button { .product-clone-page[data-tool="clone"] .clone-ai-model-select__menu button {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2351,31 +2288,14 @@
.product-clone-page[data-tool="clone"] .clone-ai-video-panel { .product-clone-page[data-tool="clone"] .clone-ai-video-panel {
display: grid; display: grid;
flex: 1 1 auto; flex: 0 0 auto;
align-content: start; align-content: start;
gap: 12px; gap: 12px;
min-height: 0; overflow: visible;
overflow-y: auto;
border: 1px solid #303540; border: 1px solid #303540;
border-radius: 14px; border-radius: 14px;
background: #1c1f26; background: #1c1f26;
padding: 12px; padding: 12px;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
} }
.product-clone-page[data-tool="clone"] .clone-ai-video-section { .product-clone-page[data-tool="clone"] .clone-ai-video-section {
@@ -8611,31 +8531,86 @@
transition: none; transition: none;
} }
.clone-ai-video-outfit-upload { .clone-ai-video-outfit-upload {
display: flex; position: relative;
align-items: center; display: grid;
gap: 10px; gap: 8px;
margin-top: 6px; margin-top: 6px;
} }
.clone-ai-video-outfit-upload-btn { .clone-ai-video-outfit-upload-btn {
padding: 7px 16px; display: flex;
border: 1px solid var(--border-subtle); width: 100%;
border-radius: 8px; min-height: 118px;
background: var(--bg-inset); flex-direction: column;
color: var(--fg-body); align-items: center;
justify-content: center;
border: 1.5px dashed var(--ecm-line, var(--border-subtle));
border-radius: var(--ecm-radius-md, 14px);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 65%),
var(--ecm-inset, var(--bg-inset));
color: var(--ecm-text, var(--fg-body));
font-size: 13px; font-size: 13px;
font-weight: 850;
line-height: 1.35;
cursor: pointer; cursor: pointer;
transition: border-color 150ms, background 150ms; text-align: center;
transition:
border-color 150ms,
background 150ms,
color 150ms,
transform 150ms;
}
.clone-ai-video-outfit-upload-btn::before {
display: grid;
width: 38px;
height: 38px;
place-items: center;
margin-bottom: 8px;
border: 1px solid rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.26);
border-radius: 999px;
background: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.08);
color: var(--ecm-accent, var(--accent));
content: "+";
font-size: 24px;
font-weight: 800;
line-height: 1;
} }
.clone-ai-video-outfit-upload-btn:hover { .clone-ai-video-outfit-upload-btn:hover {
border-color: var(--border-default); border-color: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.48);
background: var(--bg-hover); background:
linear-gradient(180deg, rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.08), transparent 72%),
var(--ecm-inset-hover, var(--bg-hover));
color: var(--ecm-text, var(--fg-body));
}
.clone-ai-video-outfit-upload-btn:active {
transform: scale(0.99);
} }
.clone-ai-video-outfit-info { .clone-ai-video-outfit-info {
position: absolute;
top: 10px;
right: 10px;
max-width: calc(100% - 20px);
padding: 4px 8px;
border: 1px solid rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.28);
border-radius: 999px;
background: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.1);
font-size: 12px; font-size: 12px;
color: var(--accent); font-weight: 850;
color: var(--ecm-accent, var(--accent));
pointer-events: none;
}
.clone-ai-video-outfit-upload:has(.clone-ai-video-outfit-info) .clone-ai-video-outfit-upload-btn {
border-style: solid;
border-color: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.38);
background:
linear-gradient(180deg, rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.075), transparent 74%),
var(--ecm-inset, var(--bg-inset));
} }
/* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */ /* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */
+11
View File
@@ -1620,12 +1620,23 @@
border: 0; border: 0;
background: transparent; background: transparent;
cursor: default; cursor: default;
position: relative;
} }
.script-eval-v5-illustration:hover { .script-eval-v5-illustration:hover {
background: transparent; background: transparent;
} }
.script-eval-v5-illustration.is-dragging .script-eval-v5-illustration-hit {
background: var(--v5-green-deep);
outline: 2px dashed var(--v5-green);
transform: scale(1.02);
}
.script-eval-v5-illustration .script-eval-v5-upload-drop-overlay {
border-radius: 10px;
}
.script-eval-v5-illustration-hit { .script-eval-v5-illustration-hit {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
+19
View File
@@ -299,6 +299,25 @@
gap: 10px; gap: 10px;
padding: 32px; padding: 32px;
text-align: center; text-align: center;
border-radius: 12px;
cursor: pointer;
transition: background 0.18s ease, outline 0.18s ease, transform 0.18s ease;
}
.studio-canvas-ghost:hover {
background: rgba(var(--accent-rgb), 0.05);
outline: 1px dashed rgba(var(--accent-rgb), 0.25);
}
.studio-canvas-ghost:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.studio-canvas-ghost.is-dragging {
background: rgba(var(--accent-rgb), 0.1);
outline: 2px dashed var(--accent);
transform: scale(1.02);
} }
.studio-canvas-ghost__icon { .studio-canvas-ghost__icon {