1941 lines
66 KiB
TypeScript
1941 lines
66 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
memo,
|
||
startTransition,
|
||
useCallback,
|
||
useDeferredValue,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
type SyntheticEvent,
|
||
} from "react";
|
||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||
import { motion } from "framer-motion";
|
||
import {
|
||
ArrowRight,
|
||
Check,
|
||
Sparkles,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
ChevronUp,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
getAgentWorkflowCategoryLabels,
|
||
defaultAgentWorkflowUiConfig,
|
||
flattenAgentWorkflowSteps,
|
||
flattenVisibleAgentWorkflowSteps,
|
||
getAgentWorkflowBlockedOptionKeysForSelections,
|
||
getAgentWorkflowBrandStoreEntries,
|
||
getAgentWorkflowPageFeatureSettings,
|
||
getAgentWorkflowOfficialSecondCategoryOptions,
|
||
getAgentWorkflowOfficialThirdCategoryOptions,
|
||
getAgentWorkflowFourthCategoryOptionsByBrandStoreThirdCategory,
|
||
getAgentWorkflowPriceBandOptions,
|
||
getAgentWorkflowThirdCategoryOptionsByBrandStore,
|
||
getAgentWorkflowSelectedOptionPath,
|
||
getAgentWorkflowStoreOptionsByBrand,
|
||
getEnabledAgentWorkflowOptionTree,
|
||
getEnabledAgentWorkflowStepTree,
|
||
getRenderableAgentWorkflowOptionTree,
|
||
getRenderableAgentWorkflowStepTree,
|
||
filterAgentWorkflowStepTreeByOfficialCategory,
|
||
isAgentWorkflowStepSelectionComplete,
|
||
serializeAgentWorkflowSelectionPath,
|
||
} from "@/lib/agents/workflow-ui-config";
|
||
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
|
||
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
||
import { getAgentWorkflowProgress } from "@/lib/agents/workflow-composer";
|
||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||
import type {
|
||
AgentWorkflowMaterialSelections,
|
||
AgentWorkflowShowcaseConfig,
|
||
AgentWorkflowStepKey,
|
||
AgentWorkflowUiConfig,
|
||
} from "@/types/agent";
|
||
|
||
export interface AgentWorkflowInputs {
|
||
primarySku: string;
|
||
secondarySku: string;
|
||
brand: string;
|
||
store: string;
|
||
officialSecondCategory: string;
|
||
officialThirdCategory: string;
|
||
thirdCategory: string;
|
||
fourthCategory: string;
|
||
fifthCategory: string;
|
||
priceBand: string;
|
||
}
|
||
|
||
interface AgentWorkflowPanelDarkV2Props {
|
||
config?: AgentWorkflowUiConfig | null;
|
||
inputs: AgentWorkflowInputs;
|
||
onInputsChange: (next: AgentWorkflowInputs) => void;
|
||
materials: AgentWorkflowMaterialSelections;
|
||
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
|
||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||
onSelectionsChange: (
|
||
next: Partial<Record<AgentWorkflowStepKey, string>>
|
||
) => void;
|
||
onReset?: () => void;
|
||
onNextStep?: () => void;
|
||
onSkipStep?: () => void;
|
||
siteName?: string | null;
|
||
siteLogo?: string | null;
|
||
siteLogoDark?: string | null;
|
||
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
|
||
topNavRightSlot?: ReactNode;
|
||
embedded?: boolean;
|
||
showBrandStoreSection?: boolean;
|
||
}
|
||
|
||
interface AgentWorkflowBrandStorePanelProps {
|
||
config?: AgentWorkflowUiConfig | null;
|
||
inputs: AgentWorkflowInputs;
|
||
onInputsChange: (next: AgentWorkflowInputs) => void;
|
||
theme?: "light" | "dark";
|
||
title?: string;
|
||
description?: string;
|
||
}
|
||
|
||
type WorkflowSelectionState = Partial<Record<AgentWorkflowStepKey, string>>;
|
||
type WorkflowStep = AgentWorkflowUiConfig["steps"][number];
|
||
type WorkflowOption = WorkflowStep["options"][number];
|
||
|
||
const VIRTUAL_OPTION_GRID_GAP = 16;
|
||
const VIRTUAL_OPTION_GRID_MAX_HEIGHT = 640;
|
||
const VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT = 122;
|
||
const VIRTUAL_OPTION_GRID_THRESHOLD = 12;
|
||
const WORKFLOW_OPTION_PREVIEW_WIDTH = 360;
|
||
const WORKFLOW_OPTION_PREVIEW_QUALITY = 62;
|
||
const WORKFLOW_OPTION_PREVIEW_EAGER_COUNT = 10;
|
||
const WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT = 12;
|
||
|
||
function buildWorkflowOptionPreviewUrl(url?: string | null) {
|
||
const source = String(url || "").trim();
|
||
if (!source) return "";
|
||
return buildImageProxyUrl(source, {
|
||
width: WORKFLOW_OPTION_PREVIEW_WIDTH,
|
||
quality: WORKFLOW_OPTION_PREVIEW_QUALITY,
|
||
format: "webp",
|
||
delivery: "proxy",
|
||
});
|
||
}
|
||
|
||
function handleWorkflowOptionPreviewError(
|
||
event: SyntheticEvent<HTMLImageElement, Event>,
|
||
originalUrl?: string | null
|
||
) {
|
||
const fallbackUrl = String(originalUrl || "").trim();
|
||
if (!fallbackUrl) return;
|
||
|
||
const image = event.currentTarget;
|
||
if (image.dataset.workflowPreviewFallbackApplied === "1") {
|
||
return;
|
||
}
|
||
|
||
image.dataset.workflowPreviewFallbackApplied = "1";
|
||
image.src = fallbackUrl;
|
||
}
|
||
|
||
interface WorkflowPanelDerivedState {
|
||
progress: ReturnType<typeof getAgentWorkflowProgress>;
|
||
}
|
||
|
||
function scheduleWorkflowIdleTask(callback: () => void, timeout = 450) {
|
||
if (typeof window === "undefined") {
|
||
callback();
|
||
return () => {};
|
||
}
|
||
|
||
const idleWindow = window as Window & {
|
||
requestIdleCallback?: (
|
||
callback: IdleRequestCallback,
|
||
options?: IdleRequestOptions
|
||
) => number;
|
||
cancelIdleCallback?: (handle: number) => void;
|
||
};
|
||
|
||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||
const idleId = idleWindow.requestIdleCallback(callback, { timeout });
|
||
return () => idleWindow.cancelIdleCallback?.(idleId);
|
||
}
|
||
|
||
const timer = window.setTimeout(callback, Math.min(timeout, 120));
|
||
return () => window.clearTimeout(timer);
|
||
}
|
||
|
||
function buildWorkflowPanelDerivedState(params: {
|
||
config: AgentWorkflowUiConfig;
|
||
selections: WorkflowSelectionState;
|
||
}): WorkflowPanelDerivedState {
|
||
return {
|
||
progress: getAgentWorkflowProgress({
|
||
config: params.config,
|
||
selections: params.selections,
|
||
}),
|
||
};
|
||
}
|
||
|
||
function areWorkflowSelectionStatesEqual(
|
||
left?: WorkflowSelectionState | null,
|
||
right?: WorkflowSelectionState | null
|
||
) {
|
||
const leftEntries = Object.entries(left || {}).filter(([, value]) =>
|
||
Boolean(String(value || "").trim())
|
||
);
|
||
const rightEntries = Object.entries(right || {}).filter(([, value]) =>
|
||
Boolean(String(value || "").trim())
|
||
);
|
||
|
||
if (leftEntries.length !== rightEntries.length) {
|
||
return false;
|
||
}
|
||
|
||
return leftEntries.every(
|
||
([key, value]) =>
|
||
String(right?.[key as AgentWorkflowStepKey] || "") === String(value || "")
|
||
);
|
||
}
|
||
|
||
function getWorkflowOptionGridColumnCount(width: number) {
|
||
if (width >= 1280) return 5;
|
||
if (width >= 1024) return 4;
|
||
if (width >= 640) return 3;
|
||
if (width >= 420) return 2;
|
||
return 1;
|
||
}
|
||
|
||
function getWorkflowStepSelectionSummary(params: {
|
||
step: WorkflowStep;
|
||
selectionValue?: string;
|
||
}) {
|
||
const selectedOptions = getAgentWorkflowSelectedOptionPath({
|
||
step: params.step,
|
||
selectionValue: params.selectionValue,
|
||
});
|
||
|
||
if (selectedOptions.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
return selectedOptions.map((option) => option.buttonName).join(" / ");
|
||
}
|
||
|
||
function getWorkflowStepOptionLevels(params: {
|
||
step: WorkflowStep;
|
||
selectionValue?: string;
|
||
}) {
|
||
const levels: Array<{
|
||
depth: number;
|
||
options: WorkflowStep["options"];
|
||
selectedKey: string;
|
||
selectedOption: WorkflowOption | null;
|
||
}> = [];
|
||
const selectedPath = getAgentWorkflowSelectedOptionPath({
|
||
step: params.step,
|
||
selectionValue: params.selectionValue,
|
||
});
|
||
|
||
let depth = 1;
|
||
let currentOptions = params.step.options || [];
|
||
|
||
while (currentOptions.length > 0) {
|
||
const selectedKey = selectedPath[depth - 1]?.key || "";
|
||
const selectedOption =
|
||
currentOptions.find((option) => option.key === selectedKey) || null;
|
||
|
||
levels.push({
|
||
depth,
|
||
options: currentOptions,
|
||
selectedKey,
|
||
selectedOption,
|
||
});
|
||
|
||
const nextOptions = selectedOption?.children || [];
|
||
if (!selectedOption || nextOptions.length === 0) {
|
||
break;
|
||
}
|
||
|
||
currentOptions = nextOptions;
|
||
depth += 1;
|
||
}
|
||
|
||
return levels;
|
||
}
|
||
|
||
function getWorkflowOptionDepthLabel(depth: number) {
|
||
return depth === 1 ? "主卡片" : `${depth}级子卡片`;
|
||
}
|
||
|
||
function getWorkflowOptionDepthShortLabel(depth: number) {
|
||
return depth === 1 ? "主" : `${depth}级`;
|
||
}
|
||
|
||
const StepOptionCard = memo(function StepOptionCard({
|
||
option,
|
||
step,
|
||
selected,
|
||
disabled = false,
|
||
disabledReason = null,
|
||
onSelect,
|
||
index,
|
||
}: {
|
||
option: {
|
||
key: string;
|
||
buttonName: string;
|
||
description?: string | null;
|
||
previewImageUrl?: string | null;
|
||
};
|
||
step: { title: string };
|
||
selected: boolean;
|
||
disabled?: boolean;
|
||
disabledReason?: "blocked" | "developing" | null;
|
||
onSelect: (optionKey: string) => void;
|
||
index: number;
|
||
}) {
|
||
const handleClick = useCallback(() => {
|
||
onSelect(option.key);
|
||
}, [onSelect, option.key]);
|
||
const previewUrl = useMemo(
|
||
() => buildWorkflowOptionPreviewUrl(option.previewImageUrl),
|
||
[option.previewImageUrl]
|
||
);
|
||
const shouldLoadFast = selected || index < WORKFLOW_OPTION_PREVIEW_EAGER_COUNT;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={handleClick}
|
||
className={cn(
|
||
"group relative overflow-hidden rounded-2xl text-left",
|
||
"aspect-[16/10] w-full",
|
||
"transition-all duration-500",
|
||
disabled && "cursor-not-allowed opacity-45 saturate-0",
|
||
selected
|
||
? "ring-2 ring-white/30 shadow-[0_0_30px_rgba(255,255,255,0.1)]"
|
||
: disabled
|
||
? "ring-1 ring-white/8"
|
||
: "ring-1 ring-white/12 hover:ring-white/24"
|
||
)}
|
||
>
|
||
{/* 背景 */}
|
||
<div className="absolute inset-0 bg-zinc-900">
|
||
{previewUrl ? (
|
||
<>
|
||
<img
|
||
src={previewUrl}
|
||
alt={option.buttonName}
|
||
width={WORKFLOW_OPTION_PREVIEW_WIDTH}
|
||
height={Math.round(WORKFLOW_OPTION_PREVIEW_WIDTH * 0.625)}
|
||
loading={shouldLoadFast ? "eager" : "lazy"}
|
||
decoding="async"
|
||
fetchPriority={shouldLoadFast ? "high" : "low"}
|
||
draggable={false}
|
||
sizes="(min-width: 1536px) 18vw, (min-width: 1280px) 22vw, (min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
|
||
onError={(event) =>
|
||
handleWorkflowOptionPreviewError(event, option.previewImageUrl)
|
||
}
|
||
className={cn(
|
||
"h-full w-full object-cover transition-transform duration-700",
|
||
!disabled && "group-hover:scale-110"
|
||
)}
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
|
||
</>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
|
||
<Sparkles className="h-10 w-10 text-white/20" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 选中标记 */}
|
||
{selected && (
|
||
<div className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-white text-black">
|
||
<Check className="h-4 w-4" />
|
||
</div>
|
||
)}
|
||
|
||
{disabled && !selected ? (
|
||
<div className="absolute right-3 top-3 rounded-full bg-black/65 px-2 py-1 text-[10px] font-medium text-white/80 backdrop-blur-md">
|
||
{disabledReason === "developing" ? "开发中" : "已限制"}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 步骤标签 */}
|
||
<div className="absolute left-3 top-3">
|
||
<span className="rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-medium text-white/80 backdrop-blur-md">
|
||
{step.title}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 底部信息 */}
|
||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||
<h4 className="text-sm font-semibold text-white drop-shadow-lg">
|
||
{option.buttonName}
|
||
</h4>
|
||
{option.description && (
|
||
<p className="mt-1 line-clamp-1 text-xs text-white/60">
|
||
{option.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 选中边框 */}
|
||
{selected && (
|
||
<div className="absolute inset-0 rounded-2xl ring-2 ring-inset ring-white/30" />
|
||
)}
|
||
</button>
|
||
);
|
||
});
|
||
|
||
interface VirtualizedOptionGridProps {
|
||
options: WorkflowStep["options"];
|
||
step: WorkflowStep;
|
||
selectedKey: string;
|
||
blockedOptionKeys: Set<string>;
|
||
onSelect: (optionKey: string) => void;
|
||
}
|
||
|
||
const VirtualizedOptionGrid = memo(function VirtualizedOptionGrid({
|
||
options,
|
||
step,
|
||
selectedKey,
|
||
blockedOptionKeys,
|
||
onSelect,
|
||
}: VirtualizedOptionGridProps) {
|
||
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||
const [containerWidth, setContainerWidth] = useState(0);
|
||
const fallbackColumnCount = 5;
|
||
const columnCount =
|
||
containerWidth > 0
|
||
? getWorkflowOptionGridColumnCount(containerWidth)
|
||
: fallbackColumnCount;
|
||
const rowCount = Math.ceil(options.length / columnCount);
|
||
const cardWidth =
|
||
containerWidth > 0
|
||
? (containerWidth - VIRTUAL_OPTION_GRID_GAP * (columnCount - 1)) /
|
||
columnCount
|
||
: 220;
|
||
const cardHeight = Math.max(
|
||
VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT,
|
||
Math.round(cardWidth * 0.625)
|
||
);
|
||
const rowHeight = cardHeight + VIRTUAL_OPTION_GRID_GAP;
|
||
const shouldVirtualize = options.length > VIRTUAL_OPTION_GRID_THRESHOLD;
|
||
|
||
useEffect(() => {
|
||
const element = scrollParentRef.current;
|
||
if (!element || typeof ResizeObserver === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const updateWidth = () => {
|
||
setContainerWidth(Math.max(0, Math.floor(element.clientWidth)));
|
||
};
|
||
updateWidth();
|
||
|
||
const resizeObserver = new ResizeObserver(updateWidth);
|
||
resizeObserver.observe(element);
|
||
return () => resizeObserver.disconnect();
|
||
}, []);
|
||
|
||
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||
count: rowCount,
|
||
getScrollElement: () => scrollParentRef.current,
|
||
estimateSize: () => rowHeight,
|
||
overscan: 1,
|
||
});
|
||
|
||
const renderCard = (option: WorkflowOption, optionIndex: number) => {
|
||
const isDeveloping = option.enabled === false;
|
||
const isBlocked =
|
||
!isDeveloping &&
|
||
selectedKey !== option.key &&
|
||
blockedOptionKeys.has(option.key);
|
||
const isDisabled = isDeveloping || isBlocked;
|
||
|
||
return (
|
||
<StepOptionCard
|
||
key={option.key}
|
||
disabled={isDisabled}
|
||
disabledReason={
|
||
isDeveloping ? "developing" : isBlocked ? "blocked" : null
|
||
}
|
||
index={optionIndex}
|
||
onSelect={onSelect}
|
||
option={option}
|
||
selected={selectedKey === option.key}
|
||
step={step}
|
||
/>
|
||
);
|
||
};
|
||
|
||
if (!shouldVirtualize) {
|
||
return (
|
||
<div
|
||
className="grid grid-cols-1 gap-4 min-[420px]:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
|
||
ref={scrollParentRef}
|
||
>
|
||
{options.map((option, optionIndex) => renderCard(option, optionIndex))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="overflow-y-auto overscroll-contain pr-1"
|
||
ref={scrollParentRef}
|
||
style={{ maxHeight: VIRTUAL_OPTION_GRID_MAX_HEIGHT }}
|
||
>
|
||
<div
|
||
className="relative w-full"
|
||
style={{
|
||
height: rowVirtualizer.getTotalSize(),
|
||
}}
|
||
>
|
||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||
const startIndex = virtualRow.index * columnCount;
|
||
const rowOptions = options.slice(
|
||
startIndex,
|
||
startIndex + columnCount
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className="absolute left-0 top-0 grid w-full gap-4"
|
||
data-index={virtualRow.index}
|
||
key={virtualRow.key}
|
||
ref={rowVirtualizer.measureElement}
|
||
style={{
|
||
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||
minHeight: cardHeight,
|
||
transform: `translateY(${virtualRow.start}px)`,
|
||
}}
|
||
>
|
||
{rowOptions.map((option, rowOptionIndex) =>
|
||
renderCard(option, startIndex + rowOptionIndex)
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export function AgentWorkflowBrandStorePanel({
|
||
config,
|
||
inputs,
|
||
onInputsChange,
|
||
theme = "dark",
|
||
title = "品牌层级选择",
|
||
description = "先选择品牌、店铺、分类层级和价格带,后续品牌模特与风格步骤会按这里的选择联动。",
|
||
}: AgentWorkflowBrandStorePanelProps) {
|
||
const categoryLabels = useMemo(
|
||
() => getAgentWorkflowCategoryLabels(config),
|
||
[config]
|
||
);
|
||
const firstCategoryLabel = categoryLabels.firstCategory;
|
||
const secondCategoryLabel = categoryLabels.secondCategory;
|
||
const thirdCategoryLabel = categoryLabels.thirdCategory;
|
||
const fourthCategoryLabel = categoryLabels.fourthCategory;
|
||
const resolvedTitle =
|
||
title === "品牌层级选择" ? `${firstCategoryLabel}层级选择` : title;
|
||
const resolvedDescription =
|
||
description ===
|
||
"先选择品牌、店铺、分类层级和价格带,后续品牌模特与风格步骤会按这里的选择联动。"
|
||
? `先选择${firstCategoryLabel}、${secondCategoryLabel}、分类层级和价格带,后续品牌模特与风格步骤会按这里的选择联动。`
|
||
: description;
|
||
const brandOptions = useMemo(
|
||
() =>
|
||
config?.inputOptions?.brands ||
|
||
defaultAgentWorkflowUiConfig.inputOptions?.brands ||
|
||
[],
|
||
[config?.inputOptions?.brands]
|
||
);
|
||
const brandStoreEntries = useMemo(
|
||
() => getAgentWorkflowBrandStoreEntries(config),
|
||
[config]
|
||
);
|
||
const storeOptions = useMemo(
|
||
() => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }),
|
||
[config, inputs.brand]
|
||
);
|
||
const thirdCategoryOptions = useMemo(
|
||
() =>
|
||
getAgentWorkflowThirdCategoryOptionsByBrandStore({
|
||
config,
|
||
brand: inputs.brand,
|
||
store: inputs.store,
|
||
}),
|
||
[config, inputs.brand, inputs.store]
|
||
);
|
||
const fourthCategoryOptions = useMemo(
|
||
() =>
|
||
getAgentWorkflowFourthCategoryOptionsByBrandStoreThirdCategory({
|
||
config,
|
||
brand: inputs.brand,
|
||
store: inputs.store,
|
||
thirdCategory: inputs.thirdCategory,
|
||
}),
|
||
[config, inputs.brand, inputs.store, inputs.thirdCategory]
|
||
);
|
||
const officialSecondCategoryOptions = useMemo(
|
||
() => getAgentWorkflowOfficialSecondCategoryOptions(config),
|
||
[config]
|
||
);
|
||
const officialThirdCategoryOptions = useMemo(
|
||
() =>
|
||
getAgentWorkflowOfficialThirdCategoryOptions({
|
||
config,
|
||
secondCategory: inputs.officialSecondCategory,
|
||
}),
|
||
[config, inputs.officialSecondCategory]
|
||
);
|
||
const priceBandOptions = useMemo(
|
||
() => getAgentWorkflowPriceBandOptions(config),
|
||
[config]
|
||
);
|
||
const hasThirdCategoryLevel = useMemo(
|
||
() =>
|
||
brandStoreEntries.some((entry) =>
|
||
(entry.storeEntries || []).some(
|
||
(storeEntry) =>
|
||
(storeEntry.thirdCategoryEntries || []).length > 0 ||
|
||
(storeEntry.thirdCategories || []).length > 0
|
||
)
|
||
) || Boolean(config?.inputOptions?.thirdCategories?.length),
|
||
[brandStoreEntries, config?.inputOptions?.thirdCategories]
|
||
);
|
||
const hasFourthCategoryLevel = useMemo(
|
||
() =>
|
||
hasThirdCategoryLevel &&
|
||
(brandStoreEntries.some((entry) =>
|
||
(entry.storeEntries || []).some((storeEntry) =>
|
||
(storeEntry.thirdCategoryEntries || []).some(
|
||
(thirdEntry) =>
|
||
(thirdEntry.fourthCategoryEntries || []).length > 0 ||
|
||
(thirdEntry.fourthCategories || []).length > 0
|
||
)
|
||
)
|
||
) ||
|
||
Boolean(config?.inputOptions?.fourthCategories?.length)),
|
||
[
|
||
brandStoreEntries,
|
||
config?.inputOptions?.fourthCategories,
|
||
hasThirdCategoryLevel,
|
||
]
|
||
);
|
||
const isDark = theme === "dark";
|
||
|
||
useEffect(() => {
|
||
let nextInputs = inputs;
|
||
let changed = false;
|
||
|
||
if (!hasThirdCategoryLevel) {
|
||
if (nextInputs.thirdCategory || nextInputs.fourthCategory) {
|
||
nextInputs = {
|
||
...nextInputs,
|
||
thirdCategory: "",
|
||
fourthCategory: "",
|
||
};
|
||
changed = true;
|
||
}
|
||
} else if (!hasFourthCategoryLevel) {
|
||
if (nextInputs.fourthCategory) {
|
||
nextInputs = {
|
||
...nextInputs,
|
||
fourthCategory: "",
|
||
};
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if (
|
||
nextInputs.officialSecondCategory &&
|
||
!officialSecondCategoryOptions.some(
|
||
(option) =>
|
||
option.trim().toLowerCase() ===
|
||
nextInputs.officialSecondCategory.trim().toLowerCase()
|
||
)
|
||
) {
|
||
nextInputs = {
|
||
...nextInputs,
|
||
officialSecondCategory: "",
|
||
officialThirdCategory: "",
|
||
fifthCategory: "",
|
||
};
|
||
changed = true;
|
||
}
|
||
|
||
if (
|
||
nextInputs.officialThirdCategory &&
|
||
!officialThirdCategoryOptions.some(
|
||
(option) =>
|
||
option.trim().toLowerCase() ===
|
||
nextInputs.officialThirdCategory.trim().toLowerCase()
|
||
)
|
||
) {
|
||
nextInputs = {
|
||
...nextInputs,
|
||
officialThirdCategory: "",
|
||
fifthCategory: "",
|
||
};
|
||
changed = true;
|
||
} else if (
|
||
nextInputs.officialThirdCategory &&
|
||
nextInputs.fifthCategory !== nextInputs.officialThirdCategory
|
||
) {
|
||
nextInputs = {
|
||
...nextInputs,
|
||
fifthCategory: nextInputs.officialThirdCategory,
|
||
};
|
||
changed = true;
|
||
}
|
||
|
||
if (changed) {
|
||
onInputsChange(nextInputs);
|
||
}
|
||
}, [
|
||
hasThirdCategoryLevel,
|
||
hasFourthCategoryLevel,
|
||
inputs,
|
||
onInputsChange,
|
||
officialSecondCategoryOptions,
|
||
officialThirdCategoryOptions,
|
||
]);
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"rounded-[24px] border p-4",
|
||
isDark ? "border-white/12 bg-black/20" : "border-border bg-background"
|
||
)}
|
||
>
|
||
<div className="mb-4 space-y-1">
|
||
<div
|
||
className={cn(
|
||
"text-sm font-semibold",
|
||
isDark ? "text-white" : "text-foreground"
|
||
)}
|
||
>
|
||
{resolvedTitle}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"text-xs leading-5",
|
||
isDark ? "text-white/48" : "text-muted-foreground"
|
||
)}
|
||
>
|
||
{resolvedDescription}
|
||
</div>
|
||
</div>
|
||
<div className="grid max-w-7xl grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||
<SearchableSelect
|
||
value={inputs.brand}
|
||
options={brandOptions}
|
||
placeholder={`选择${firstCategoryLabel}`}
|
||
searchPlaceholder={`输入${firstCategoryLabel}名筛选`}
|
||
emptyText={`暂无${firstCategoryLabel}可选`}
|
||
theme={theme}
|
||
onValueChange={(value) =>
|
||
onInputsChange({
|
||
...inputs,
|
||
brand: value,
|
||
store:
|
||
value.trim().toLowerCase() === inputs.brand.trim().toLowerCase()
|
||
? inputs.store
|
||
: "",
|
||
thirdCategory:
|
||
value.trim().toLowerCase() === inputs.brand.trim().toLowerCase()
|
||
? inputs.thirdCategory
|
||
: "",
|
||
fourthCategory: "",
|
||
fifthCategory: inputs.officialThirdCategory || "",
|
||
})
|
||
}
|
||
/>
|
||
<SearchableSelect
|
||
value={inputs.store}
|
||
options={storeOptions}
|
||
placeholder={`选择${secondCategoryLabel}`}
|
||
searchPlaceholder={`输入${secondCategoryLabel}名筛选`}
|
||
emptyText={
|
||
inputs.brand
|
||
? `当前${firstCategoryLabel}下暂无${secondCategoryLabel}可选`
|
||
: `请先选择${firstCategoryLabel}`
|
||
}
|
||
theme={theme}
|
||
disabled={
|
||
!inputs.brand ||
|
||
(brandStoreEntries.length > 0 && storeOptions.length === 0)
|
||
}
|
||
onValueChange={(value) =>
|
||
onInputsChange({
|
||
...inputs,
|
||
store: value,
|
||
thirdCategory:
|
||
value.trim().toLowerCase() === inputs.store.trim().toLowerCase()
|
||
? inputs.thirdCategory
|
||
: "",
|
||
fourthCategory: "",
|
||
})
|
||
}
|
||
/>
|
||
{hasThirdCategoryLevel ? (
|
||
<SearchableSelect
|
||
value={inputs.thirdCategory}
|
||
options={thirdCategoryOptions}
|
||
placeholder={`选择${thirdCategoryLabel}`}
|
||
searchPlaceholder={`输入${thirdCategoryLabel}筛选`}
|
||
emptyText={
|
||
inputs.store
|
||
? `当前${secondCategoryLabel}下暂无${thirdCategoryLabel}`
|
||
: `请先选择${secondCategoryLabel}`
|
||
}
|
||
theme={theme}
|
||
disabled={!inputs.store || thirdCategoryOptions.length === 0}
|
||
onValueChange={(value) =>
|
||
onInputsChange({
|
||
...inputs,
|
||
thirdCategory: value,
|
||
fourthCategory:
|
||
value.trim().toLowerCase() ===
|
||
inputs.thirdCategory.trim().toLowerCase()
|
||
? inputs.fourthCategory
|
||
: "",
|
||
})
|
||
}
|
||
/>
|
||
) : null}
|
||
{hasFourthCategoryLevel ? (
|
||
<SearchableSelect
|
||
value={inputs.fourthCategory}
|
||
options={fourthCategoryOptions}
|
||
placeholder={`选择${fourthCategoryLabel}`}
|
||
searchPlaceholder={`输入${fourthCategoryLabel}筛选`}
|
||
emptyText={
|
||
inputs.thirdCategory
|
||
? `当前${thirdCategoryLabel}下暂无${fourthCategoryLabel}`
|
||
: `请先选择${thirdCategoryLabel}`
|
||
}
|
||
theme={theme}
|
||
disabled={
|
||
!inputs.thirdCategory || fourthCategoryOptions.length === 0
|
||
}
|
||
onValueChange={(value) =>
|
||
onInputsChange({
|
||
...inputs,
|
||
fourthCategory: value,
|
||
})
|
||
}
|
||
/>
|
||
) : null}
|
||
<SearchableSelect
|
||
value={inputs.officialSecondCategory}
|
||
options={officialSecondCategoryOptions}
|
||
placeholder="选择二级品类"
|
||
searchPlaceholder="输入二级品类筛选"
|
||
emptyText="暂无官方二级品类"
|
||
theme={theme}
|
||
disabled={officialSecondCategoryOptions.length === 0}
|
||
onValueChange={(value) =>
|
||
onInputsChange({
|
||
...inputs,
|
||
officialSecondCategory: value,
|
||
officialThirdCategory: "",
|
||
fifthCategory: "",
|
||
})
|
||
}
|
||
/>
|
||
<SearchableSelect
|
||
value={inputs.officialThirdCategory}
|
||
options={officialThirdCategoryOptions}
|
||
placeholder="选择三级品类"
|
||
searchPlaceholder="输入三级品类筛选"
|
||
emptyText={
|
||
inputs.officialSecondCategory
|
||
? "当前二级品类下暂无三级品类"
|
||
: "请先选择二级品类"
|
||
}
|
||
theme={theme}
|
||
disabled={
|
||
!inputs.officialSecondCategory ||
|
||
officialThirdCategoryOptions.length === 0
|
||
}
|
||
onValueChange={(value) =>
|
||
onInputsChange({
|
||
...inputs,
|
||
officialThirdCategory: value,
|
||
fifthCategory: value,
|
||
})
|
||
}
|
||
/>
|
||
<SearchableSelect
|
||
value={inputs.priceBand}
|
||
options={priceBandOptions}
|
||
placeholder="选择价格带"
|
||
searchPlaceholder="输入价格带筛选"
|
||
emptyText="暂无价格带可选"
|
||
theme={theme}
|
||
disabled={priceBandOptions.length === 0}
|
||
onValueChange={(value) =>
|
||
onInputsChange({ ...inputs, priceBand: value })
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AgentWorkflowPanelDarkV2Component({
|
||
config,
|
||
inputs,
|
||
onInputsChange,
|
||
selections,
|
||
onSelectionsChange,
|
||
onNextStep,
|
||
onSkipStep,
|
||
siteName,
|
||
siteLogo,
|
||
siteLogoDark,
|
||
showcaseConfig,
|
||
topNavRightSlot,
|
||
embedded = false,
|
||
showBrandStoreSection = true,
|
||
}: AgentWorkflowPanelDarkV2Props) {
|
||
const categoryLabels = useMemo(
|
||
() => getAgentWorkflowCategoryLabels(config),
|
||
[config]
|
||
);
|
||
const firstCategoryLabel = categoryLabels.firstCategory;
|
||
const secondCategoryLabel = categoryLabels.secondCategory;
|
||
const resolvedConfig = useMemo(() => {
|
||
const source = config || defaultAgentWorkflowUiConfig;
|
||
const filteredSteps = filterAgentWorkflowStepTreeByOfficialCategory({
|
||
steps: source.steps || [],
|
||
officialSecondCategory: inputs.officialSecondCategory,
|
||
officialThirdCategory: inputs.officialThirdCategory,
|
||
});
|
||
return {
|
||
...source,
|
||
steps: getEnabledAgentWorkflowStepTree(filteredSteps),
|
||
};
|
||
}, [config, inputs.officialSecondCategory, inputs.officialThirdCategory]);
|
||
const displayConfig = useMemo(() => {
|
||
const source = config || defaultAgentWorkflowUiConfig;
|
||
const filteredSteps = filterAgentWorkflowStepTreeByOfficialCategory({
|
||
steps: source.steps || [],
|
||
officialSecondCategory: inputs.officialSecondCategory,
|
||
officialThirdCategory: inputs.officialThirdCategory,
|
||
});
|
||
return {
|
||
...source,
|
||
steps: getRenderableAgentWorkflowStepTree(filteredSteps),
|
||
};
|
||
}, [config, inputs.officialSecondCategory, inputs.officialThirdCategory]);
|
||
const pageFeatureSettings = useMemo(
|
||
() => getAgentWorkflowPageFeatureSettings(config),
|
||
[config]
|
||
);
|
||
const [optimisticSelections, setOptimisticSelections] =
|
||
useState<WorkflowSelectionState>(selections);
|
||
const deferredSelections = useDeferredValue(optimisticSelections);
|
||
const latestExternalSelectionsRef = useRef(selections);
|
||
const selectionCommitTimerRef = useRef<number | null>(null);
|
||
const pendingSelectionCommitRef = useRef<WorkflowSelectionState | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (latestExternalSelectionsRef.current === selections) {
|
||
return;
|
||
}
|
||
|
||
latestExternalSelectionsRef.current = selections;
|
||
setOptimisticSelections((current) =>
|
||
areWorkflowSelectionStatesEqual(current, selections)
|
||
? current
|
||
: selections
|
||
);
|
||
}, [selections]);
|
||
|
||
const flushSelectionCommit = useCallback(() => {
|
||
const pendingSelections = pendingSelectionCommitRef.current;
|
||
if (!pendingSelections) {
|
||
return;
|
||
}
|
||
|
||
pendingSelectionCommitRef.current = null;
|
||
if (
|
||
selectionCommitTimerRef.current !== null &&
|
||
typeof window !== "undefined"
|
||
) {
|
||
window.clearTimeout(selectionCommitTimerRef.current);
|
||
}
|
||
selectionCommitTimerRef.current = null;
|
||
startTransition(() => {
|
||
onSelectionsChange(pendingSelections);
|
||
});
|
||
}, [onSelectionsChange]);
|
||
|
||
const emitSelectionsChange = useCallback(
|
||
(nextSelections: WorkflowSelectionState) => {
|
||
setOptimisticSelections(nextSelections);
|
||
pendingSelectionCommitRef.current = nextSelections;
|
||
|
||
if (typeof window === "undefined") {
|
||
flushSelectionCommit();
|
||
return;
|
||
}
|
||
|
||
if (selectionCommitTimerRef.current !== null) {
|
||
window.clearTimeout(selectionCommitTimerRef.current);
|
||
}
|
||
|
||
selectionCommitTimerRef.current = window.setTimeout(() => {
|
||
flushSelectionCommit();
|
||
}, 90);
|
||
},
|
||
[flushSelectionCommit]
|
||
);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
flushSelectionCommit();
|
||
};
|
||
}, [flushSelectionCommit]);
|
||
|
||
const flattenedResolvedSteps = useMemo(
|
||
() =>
|
||
flattenVisibleAgentWorkflowSteps(
|
||
displayConfig.steps,
|
||
optimisticSelections
|
||
).filter(
|
||
({ step }) => Array.isArray(step.options) && step.options.length > 0
|
||
),
|
||
[displayConfig.steps, optimisticSelections]
|
||
);
|
||
|
||
const [workflowDerivedState, setWorkflowDerivedState] =
|
||
useState<WorkflowPanelDerivedState>(() =>
|
||
buildWorkflowPanelDerivedState({
|
||
config: resolvedConfig,
|
||
selections: deferredSelections,
|
||
})
|
||
);
|
||
const workflowDerivedVersionRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
const version = workflowDerivedVersionRef.current + 1;
|
||
workflowDerivedVersionRef.current = version;
|
||
|
||
return scheduleWorkflowIdleTask(() => {
|
||
const nextDerivedState = buildWorkflowPanelDerivedState({
|
||
config: resolvedConfig,
|
||
selections: deferredSelections,
|
||
});
|
||
|
||
if (workflowDerivedVersionRef.current !== version) {
|
||
return;
|
||
}
|
||
|
||
startTransition(() => {
|
||
setWorkflowDerivedState(nextDerivedState);
|
||
});
|
||
});
|
||
}, [deferredSelections, resolvedConfig]);
|
||
const progress = workflowDerivedState.progress;
|
||
const stepSelectionSummaryMap = useMemo(() => {
|
||
const entries = flattenAgentWorkflowSteps(resolvedConfig.steps).map(
|
||
({ step }) => [
|
||
step.key,
|
||
getWorkflowStepSelectionSummary({
|
||
step,
|
||
selectionValue: deferredSelections[step.key],
|
||
}),
|
||
]
|
||
);
|
||
return Object.fromEntries(entries) as Record<string, string>;
|
||
}, [deferredSelections, resolvedConfig.steps]);
|
||
const blockedOptionKeys = useMemo(
|
||
() =>
|
||
new Set(
|
||
getAgentWorkflowBlockedOptionKeysForSelections({
|
||
config: resolvedConfig,
|
||
selections: deferredSelections,
|
||
})
|
||
),
|
||
[deferredSelections, resolvedConfig]
|
||
);
|
||
const [autoAdvanceNotice, setAutoAdvanceNotice] = useState<{
|
||
completedStepTitle: string;
|
||
completedSummary: string;
|
||
nextStepTitle: string;
|
||
} | null>(null);
|
||
const [activeEmbeddedStepKey, setActiveEmbeddedStepKey] =
|
||
useState<string>("");
|
||
const [activeEmbeddedOptionDepth, setActiveEmbeddedOptionDepth] = useState(1);
|
||
const [isEmbeddedPathExpanded, setIsEmbeddedPathExpanded] = useState(false);
|
||
const [pendingAutoAdvanceStepKey, setPendingAutoAdvanceStepKey] = useState<
|
||
string | null
|
||
>(null);
|
||
const activeEmbeddedStepIndex = Math.max(
|
||
flattenedResolvedSteps.findIndex(
|
||
({ step }) => step.key === activeEmbeddedStepKey
|
||
),
|
||
0
|
||
);
|
||
const activeEmbeddedStepEntry =
|
||
flattenedResolvedSteps[activeEmbeddedStepIndex] ||
|
||
flattenedResolvedSteps[0] ||
|
||
null;
|
||
const activeEmbeddedOptionLevels = useMemo(
|
||
() =>
|
||
activeEmbeddedStepEntry
|
||
? getWorkflowStepOptionLevels({
|
||
step: activeEmbeddedStepEntry.step,
|
||
selectionValue:
|
||
optimisticSelections[activeEmbeddedStepEntry.step.key],
|
||
})
|
||
: [],
|
||
[activeEmbeddedStepEntry, optimisticSelections]
|
||
);
|
||
const activeEmbeddedSelectedPath = useMemo(
|
||
() =>
|
||
activeEmbeddedStepEntry
|
||
? getAgentWorkflowSelectedOptionPath({
|
||
step: activeEmbeddedStepEntry.step,
|
||
selectionValue:
|
||
optimisticSelections[activeEmbeddedStepEntry.step.key],
|
||
})
|
||
: [],
|
||
[activeEmbeddedStepEntry, optimisticSelections]
|
||
);
|
||
const activeEmbeddedOptionLevel =
|
||
activeEmbeddedOptionLevels.find(
|
||
(level) => level.depth === activeEmbeddedOptionDepth
|
||
) ||
|
||
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1] ||
|
||
activeEmbeddedOptionLevels[0] ||
|
||
null;
|
||
const workflowOptionPreviewPrefetchUrls = useMemo(() => {
|
||
const options = embedded
|
||
? activeEmbeddedOptionLevel?.options || []
|
||
: flattenedResolvedSteps[0]?.step.options || [];
|
||
return Array.from(
|
||
new Set(
|
||
options
|
||
.slice(0, WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT)
|
||
.map((option) => buildWorkflowOptionPreviewUrl(option.previewImageUrl))
|
||
.filter(Boolean)
|
||
)
|
||
);
|
||
}, [activeEmbeddedOptionLevel?.options, embedded, flattenedResolvedSteps]);
|
||
const activeEmbeddedDeepestSelectedOption = useMemo(
|
||
() =>
|
||
[...activeEmbeddedOptionLevels]
|
||
.reverse()
|
||
.find((level) => level.selectedOption)?.selectedOption || null,
|
||
[activeEmbeddedOptionLevels]
|
||
);
|
||
const activeEmbeddedSelectedOptionHasDevelopingChildren = useMemo(() => {
|
||
if (!activeEmbeddedDeepestSelectedOption) {
|
||
return false;
|
||
}
|
||
|
||
const renderableChildren =
|
||
activeEmbeddedDeepestSelectedOption.children || [];
|
||
if (renderableChildren.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
getEnabledAgentWorkflowOptionTree(
|
||
activeEmbeddedDeepestSelectedOption.children || []
|
||
).length === 0
|
||
);
|
||
}, [activeEmbeddedDeepestSelectedOption]);
|
||
const activeEmbeddedPrimaryOption = activeEmbeddedSelectedPath[0] || null;
|
||
const activeEmbeddedStepSummary =
|
||
(activeEmbeddedStepEntry &&
|
||
stepSelectionSummaryMap[activeEmbeddedStepEntry.step.key]) ||
|
||
"";
|
||
const activeEmbeddedPathStatusLabel =
|
||
activeEmbeddedSelectedPath.length > 0
|
||
? `已选 ${activeEmbeddedSelectedPath.length} 项`
|
||
: "未选择";
|
||
const activeEmbeddedPathSummary =
|
||
activeEmbeddedStepSummary || "先选择当前步骤的主卡片,完整路径会收纳在这里";
|
||
|
||
useEffect(() => {
|
||
if (!embedded) {
|
||
return;
|
||
}
|
||
|
||
if (flattenedResolvedSteps.length === 0) {
|
||
if (activeEmbeddedStepKey) {
|
||
setActiveEmbeddedStepKey("");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (
|
||
activeEmbeddedStepKey &&
|
||
flattenedResolvedSteps.some(
|
||
({ step }) => step.key === activeEmbeddedStepKey
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setActiveEmbeddedStepKey(flattenedResolvedSteps[0]?.step.key || "");
|
||
}, [activeEmbeddedStepKey, embedded, flattenedResolvedSteps]);
|
||
|
||
useEffect(() => {
|
||
if (!embedded) {
|
||
return;
|
||
}
|
||
|
||
if (!activeEmbeddedStepEntry) {
|
||
if (activeEmbeddedOptionDepth !== 1) {
|
||
setActiveEmbeddedOptionDepth(1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const firstIncompleteDepth =
|
||
activeEmbeddedOptionLevels.find((level) => !level.selectedKey)?.depth ||
|
||
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1]
|
||
?.depth ||
|
||
1;
|
||
|
||
setActiveEmbeddedOptionDepth((current) => {
|
||
const maxDepth =
|
||
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1]
|
||
?.depth || 1;
|
||
if (current < 1 || current > maxDepth) {
|
||
return firstIncompleteDepth;
|
||
}
|
||
return current;
|
||
});
|
||
}, [
|
||
activeEmbeddedOptionDepth,
|
||
activeEmbeddedOptionLevels,
|
||
activeEmbeddedStepEntry,
|
||
embedded,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
typeof window === "undefined" ||
|
||
workflowOptionPreviewPrefetchUrls.length === 0
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const cleanupRef = { current: () => {} };
|
||
cleanupRef.current = scheduleWorkflowIdleTask(() => {
|
||
const images = workflowOptionPreviewPrefetchUrls.map((src) => {
|
||
const image = new Image();
|
||
image.decoding = "async";
|
||
image.src = src;
|
||
return image;
|
||
});
|
||
|
||
cleanupRef.current = () => {
|
||
images.forEach((image) => {
|
||
image.src = "";
|
||
});
|
||
};
|
||
}, 120);
|
||
|
||
return () => cleanupRef.current();
|
||
}, [workflowOptionPreviewPrefetchUrls]);
|
||
|
||
useEffect(() => {
|
||
if (!embedded || !activeEmbeddedStepEntry) {
|
||
return;
|
||
}
|
||
|
||
if (pendingAutoAdvanceStepKey !== activeEmbeddedStepEntry.step.key) {
|
||
return;
|
||
}
|
||
|
||
const currentStepComplete = isAgentWorkflowStepSelectionComplete({
|
||
step: activeEmbeddedStepEntry.step,
|
||
selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key],
|
||
});
|
||
if (!currentStepComplete) {
|
||
return;
|
||
}
|
||
|
||
if (activeEmbeddedSelectedOptionHasDevelopingChildren) {
|
||
setPendingAutoAdvanceStepKey(null);
|
||
return;
|
||
}
|
||
|
||
const nextStep = flattenedResolvedSteps
|
||
.slice(activeEmbeddedStepIndex + 1)
|
||
.find(
|
||
({ step }) =>
|
||
!isAgentWorkflowStepSelectionComplete({
|
||
step,
|
||
selectionValue: optimisticSelections[step.key],
|
||
})
|
||
);
|
||
|
||
if (nextStep && nextStep.step.key !== activeEmbeddedStepKey) {
|
||
setAutoAdvanceNotice({
|
||
completedStepTitle: activeEmbeddedStepEntry.step.title,
|
||
completedSummary:
|
||
stepSelectionSummaryMap[activeEmbeddedStepEntry.step.key] ||
|
||
"已完成当前步骤选择",
|
||
nextStepTitle: nextStep.step.title,
|
||
});
|
||
setActiveEmbeddedStepKey(nextStep.step.key);
|
||
setActiveEmbeddedOptionDepth(1);
|
||
}
|
||
|
||
setPendingAutoAdvanceStepKey(null);
|
||
}, [
|
||
activeEmbeddedStepEntry,
|
||
activeEmbeddedStepIndex,
|
||
activeEmbeddedStepKey,
|
||
activeEmbeddedSelectedOptionHasDevelopingChildren,
|
||
embedded,
|
||
flattenedResolvedSteps,
|
||
pendingAutoAdvanceStepKey,
|
||
optimisticSelections,
|
||
stepSelectionSummaryMap,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!autoAdvanceNotice) {
|
||
return;
|
||
}
|
||
|
||
const timer = window.setTimeout(() => {
|
||
setAutoAdvanceNotice(null);
|
||
}, 3200);
|
||
|
||
return () => window.clearTimeout(timer);
|
||
}, [autoAdvanceNotice]);
|
||
useEffect(() => {
|
||
if (!embedded) {
|
||
return;
|
||
}
|
||
|
||
setIsEmbeddedPathExpanded(false);
|
||
}, [activeEmbeddedStepKey, embedded]);
|
||
|
||
const renderOptionLevels = (
|
||
step: AgentWorkflowUiConfig["steps"][number],
|
||
options: AgentWorkflowUiConfig["steps"][number]["options"],
|
||
depth = 1,
|
||
parentPath: string[] = []
|
||
): ReactNode => {
|
||
const selectedPathKeys = getAgentWorkflowSelectedOptionPath({
|
||
step,
|
||
selectionValue: optimisticSelections[step.key],
|
||
}).map((option) => option.key);
|
||
const currentSelectedKey = selectedPathKeys[depth - 1] || "";
|
||
const currentSelectedOption =
|
||
options.find((option) => option.key === currentSelectedKey) || null;
|
||
const visibleChildren = currentSelectedOption?.children || [];
|
||
const levelLabel =
|
||
depth === 1 ? "主卡片" : depth === 2 ? "二级卡片" : "三级卡片";
|
||
const handleSelectOption = (optionKey: string) => {
|
||
const option = options.find((item) => item.key === optionKey);
|
||
if (!option) {
|
||
return;
|
||
}
|
||
|
||
const isDeveloping = option.enabled === false;
|
||
const isBlocked =
|
||
!isDeveloping &&
|
||
currentSelectedKey !== option.key &&
|
||
blockedOptionKeys.has(option.key);
|
||
if (isDeveloping || isBlocked) {
|
||
return;
|
||
}
|
||
|
||
const prefix = parentPath.slice(0, depth - 1);
|
||
const nextKeys =
|
||
currentSelectedKey === option.key ? prefix : [...prefix, option.key];
|
||
const nextSelections = {
|
||
...optimisticSelections,
|
||
[step.key]:
|
||
nextKeys.length > 0
|
||
? serializeAgentWorkflowSelectionPath(nextKeys)
|
||
: undefined,
|
||
};
|
||
|
||
setPendingAutoAdvanceStepKey(step.key);
|
||
emitSelectionsChange(nextSelections);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[11px] font-medium text-white/45">
|
||
{levelLabel}
|
||
</span>
|
||
{depth > 1 ? (
|
||
<span className="text-xs text-white/35">
|
||
该层卡片从上一级已选卡片中继续展开
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<VirtualizedOptionGrid
|
||
blockedOptionKeys={blockedOptionKeys}
|
||
onSelect={handleSelectOption}
|
||
options={options}
|
||
selectedKey={currentSelectedKey}
|
||
step={step}
|
||
/>
|
||
|
||
{currentSelectedOption && visibleChildren.length > 0
|
||
? renderOptionLevels(step, visibleChildren, depth + 1, [
|
||
...parentPath.slice(0, depth - 1),
|
||
currentSelectedOption.key,
|
||
])
|
||
: null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const handleEmbeddedOptionSelect = useCallback(
|
||
(optionKey: string) => {
|
||
if (!activeEmbeddedOptionLevel || !activeEmbeddedStepEntry) {
|
||
return;
|
||
}
|
||
|
||
const option = activeEmbeddedOptionLevel.options.find(
|
||
(item) => item.key === optionKey
|
||
);
|
||
if (!option) {
|
||
return;
|
||
}
|
||
|
||
const currentSelectedKey = activeEmbeddedOptionLevel.selectedKey;
|
||
const isDeveloping = option.enabled === false;
|
||
const isBlocked =
|
||
!isDeveloping &&
|
||
currentSelectedKey !== option.key &&
|
||
blockedOptionKeys.has(option.key);
|
||
if (isDeveloping || isBlocked) {
|
||
return;
|
||
}
|
||
|
||
const selectedPathKeys = activeEmbeddedOptionLevels
|
||
.slice(0, activeEmbeddedOptionLevel.depth - 1)
|
||
.map((level) => level.selectedKey)
|
||
.filter(Boolean);
|
||
const nextKeys =
|
||
currentSelectedKey === option.key
|
||
? selectedPathKeys
|
||
: [...selectedPathKeys, option.key];
|
||
const nextSelections = {
|
||
...optimisticSelections,
|
||
[activeEmbeddedStepEntry.step.key]:
|
||
nextKeys.length > 0
|
||
? serializeAgentWorkflowSelectionPath(nextKeys)
|
||
: undefined,
|
||
};
|
||
|
||
setPendingAutoAdvanceStepKey(activeEmbeddedStepEntry.step.key);
|
||
emitSelectionsChange(nextSelections);
|
||
|
||
if (
|
||
currentSelectedKey !== option.key &&
|
||
(option.children || []).length > 0
|
||
) {
|
||
setActiveEmbeddedOptionDepth(activeEmbeddedOptionLevel.depth + 1);
|
||
} else {
|
||
setActiveEmbeddedOptionDepth(activeEmbeddedOptionLevel.depth);
|
||
}
|
||
},
|
||
[
|
||
activeEmbeddedOptionLevel,
|
||
activeEmbeddedOptionLevels,
|
||
activeEmbeddedStepEntry,
|
||
blockedOptionKeys,
|
||
emitSelectionsChange,
|
||
optimisticSelections,
|
||
]
|
||
);
|
||
|
||
const workflowContent = (
|
||
<div className="space-y-8">
|
||
{showBrandStoreSection ? (
|
||
<motion.section
|
||
className="rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.1 }}
|
||
>
|
||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/20 text-xs font-bold text-emerald-400">
|
||
1
|
||
</span>
|
||
{firstCategoryLabel}和{secondCategoryLabel}选择
|
||
</h2>
|
||
<AgentWorkflowBrandStorePanel
|
||
config={config}
|
||
inputs={inputs}
|
||
onInputsChange={onInputsChange}
|
||
theme="dark"
|
||
/>
|
||
</motion.section>
|
||
) : null}
|
||
|
||
{/* 工作流步骤 */}
|
||
{embedded ? (
|
||
activeEmbeddedStepEntry ? (
|
||
<motion.section
|
||
key={activeEmbeddedStepEntry.step.key}
|
||
className={cn(
|
||
"rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||
activeEmbeddedStepEntry.depth > 1 &&
|
||
"border-l-4 border-l-emerald-500/30"
|
||
)}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
>
|
||
<div className="mb-4 space-y-3">
|
||
<div className="mt-2">
|
||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||
{flattenedResolvedSteps.map(({ step }, stepIndex) => {
|
||
const completed = isAgentWorkflowStepSelectionComplete({
|
||
step,
|
||
selectionValue: optimisticSelections[step.key],
|
||
});
|
||
const active =
|
||
step.key === activeEmbeddedStepEntry.step.key;
|
||
const stepSelectedPath = getAgentWorkflowSelectedOptionPath(
|
||
{
|
||
step,
|
||
selectionValue: optimisticSelections[step.key],
|
||
}
|
||
);
|
||
const primarySelectedOption = stepSelectedPath[0] || null;
|
||
const selectedCountLabel =
|
||
stepSelectedPath.length > 0
|
||
? `${stepSelectedPath.length} 项已选`
|
||
: "待选择";
|
||
|
||
return (
|
||
<div
|
||
key={step.key}
|
||
className={cn(
|
||
"group flex min-h-[84px] flex-col justify-between rounded-[18px] border px-3 py-2.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||
active
|
||
? "border-emerald-300/28 bg-emerald-400/[0.08]"
|
||
: completed
|
||
? "border-white/14 bg-white/[0.05]"
|
||
: "border-white/10 bg-white/[0.025]"
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-2.5 text-left">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setPendingAutoAdvanceStepKey(null);
|
||
setAutoAdvanceNotice(null);
|
||
setActiveEmbeddedStepKey(step.key);
|
||
setActiveEmbeddedOptionDepth(1);
|
||
}}
|
||
className="flex min-w-0 flex-1 items-start gap-2.5 text-left transition-transform duration-200 hover:-translate-y-[1px]"
|
||
>
|
||
<span
|
||
className={cn(
|
||
"mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition-colors",
|
||
active
|
||
? "border-emerald-300 bg-emerald-300 text-black shadow-[0_0_18px_rgba(52,211,153,0.28)]"
|
||
: completed
|
||
? "border-white/30 bg-white text-black"
|
||
: "border-white/12 bg-white/[0.04] text-white/62 group-hover:border-white/24 group-hover:text-white/82"
|
||
)}
|
||
>
|
||
{stepIndex + 1}
|
||
</span>
|
||
<div className="min-w-0 flex-1 space-y-1">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
<div
|
||
className={cn(
|
||
"truncate text-[12px] font-semibold leading-5 transition-colors",
|
||
active
|
||
? "text-white"
|
||
: completed
|
||
? "text-white/82"
|
||
: "text-white/52 group-hover:text-white/76"
|
||
)}
|
||
>
|
||
{step.title}
|
||
</div>
|
||
{active ? (
|
||
<span className="inline-flex h-5 items-center rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2 py-0 text-[9px] font-medium leading-none text-emerald-200">
|
||
当前步骤
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="flex min-w-0 items-center gap-1.5 pt-0.5">
|
||
<span
|
||
className={cn(
|
||
"min-w-0 flex-1 truncate text-[11px] leading-4",
|
||
active
|
||
? "text-emerald-100"
|
||
: completed
|
||
? "text-white/68"
|
||
: "text-white/38"
|
||
)}
|
||
title={
|
||
primarySelectedOption?.buttonName ||
|
||
"待选择"
|
||
}
|
||
>
|
||
{primarySelectedOption?.buttonName ||
|
||
"待选择"}
|
||
</span>
|
||
{stepSelectedPath.length > 1 ? (
|
||
<span className="shrink-0 text-[10px] leading-none text-white/38">
|
||
{selectedCountLabel}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="overflow-hidden rounded-[16px] border border-white/10 bg-white/[0.025]">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setIsEmbeddedPathExpanded((current) => !current)
|
||
}
|
||
aria-expanded={isEmbeddedPathExpanded}
|
||
className="flex w-full items-center justify-between gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-white/[0.03]"
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
<span className="rounded-full border border-white/12 px-2 py-0.5 text-[10px] font-medium text-white/55">
|
||
当前步骤
|
||
</span>
|
||
<span className="truncate text-[11px] text-white/45">
|
||
{activeEmbeddedStepEntry.step.title}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="mt-1 truncate text-xs text-white/68"
|
||
title={activeEmbeddedPathSummary}
|
||
>
|
||
{activeEmbeddedPrimaryOption?.buttonName ||
|
||
activeEmbeddedPathSummary}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2 pl-2">
|
||
<span className="text-[11px] text-white/35">
|
||
{activeEmbeddedPathStatusLabel}
|
||
</span>
|
||
<span className="flex h-7 w-7 items-center justify-center rounded-full border border-white/10 bg-black/20 text-white/58">
|
||
{isEmbeddedPathExpanded ? (
|
||
<ChevronUp className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
)}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
|
||
{isEmbeddedPathExpanded ? (
|
||
<div className="border-t border-white/8 px-3.5 py-3">
|
||
{activeEmbeddedSelectedPath.length > 0 ? (
|
||
<div className="flex flex-wrap items-start gap-1.5">
|
||
{activeEmbeddedSelectedPath.map(
|
||
(option, optionIndex) => {
|
||
const optionDepth = optionIndex + 1;
|
||
const optionLevelActive =
|
||
(activeEmbeddedOptionLevel?.depth || 1) ===
|
||
optionDepth;
|
||
|
||
return (
|
||
<button
|
||
key={`${activeEmbeddedStepEntry.step.key}-${option.key}-${optionDepth}`}
|
||
type="button"
|
||
onClick={() => {
|
||
setPendingAutoAdvanceStepKey(null);
|
||
setAutoAdvanceNotice(null);
|
||
setActiveEmbeddedStepKey(
|
||
activeEmbeddedStepEntry.step.key
|
||
);
|
||
setActiveEmbeddedOptionDepth(optionDepth);
|
||
}}
|
||
className={cn(
|
||
"inline-flex min-h-[26px] max-w-full items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] leading-[1.15] transition-colors",
|
||
optionLevelActive
|
||
? "border-emerald-300/35 bg-emerald-400/12 text-emerald-100"
|
||
: "border-white/14 bg-white/[0.06] text-white/72 hover:border-white/22 hover:bg-white/[0.09] hover:text-white"
|
||
)}
|
||
title={`返回${getWorkflowOptionDepthLabel(optionDepth)}:${option.buttonName}`}
|
||
>
|
||
<span className="text-[9px] opacity-70">
|
||
{getWorkflowOptionDepthShortLabel(
|
||
optionDepth
|
||
)}
|
||
</span>
|
||
<span className="max-w-[14rem] break-words whitespace-normal">
|
||
{option.buttonName}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs leading-5 text-white/35">
|
||
先选择当前步骤的主卡片,完整子节点路径会收纳在这里,需要时再展开查看。
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{activeEmbeddedOptionLevel ? (
|
||
<div className="space-y-4">
|
||
{autoAdvanceNotice ? (
|
||
<motion.div
|
||
key={`${autoAdvanceNotice.completedStepTitle}-${autoAdvanceNotice.nextStepTitle}`}
|
||
initial={{ opacity: 0, y: -10, scale: 0.98 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="rounded-2xl border border-emerald-300/16 bg-[linear-gradient(135deg,rgba(16,185,129,0.12),rgba(6,10,12,0.86))] px-4 py-3 shadow-[0_14px_40px_rgba(0,0,0,0.28)]"
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-white">
|
||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-300 text-black">
|
||
<Check className="h-4 w-4" />
|
||
</span>
|
||
<span>
|
||
已完成「{autoAdvanceNotice.completedStepTitle}」
|
||
</span>
|
||
<ArrowRight className="h-4 w-4 text-emerald-200/80" />
|
||
<span className="text-emerald-200">
|
||
已自动切换到「{autoAdvanceNotice.nextStepTitle}」
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 pl-9 text-xs leading-5 text-emerald-50/72">
|
||
刚刚完成的内容:{autoAdvanceNotice.completedSummary}
|
||
</div>
|
||
</motion.div>
|
||
) : null}
|
||
|
||
<div className="flex items-center gap-2">
|
||
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[11px] font-medium text-white/45">
|
||
{activeEmbeddedOptionLevel.depth === 1
|
||
? "主卡片"
|
||
: `${activeEmbeddedOptionLevel.depth}级子卡片`}
|
||
</span>
|
||
<span className="text-xs text-white/35">
|
||
{activeEmbeddedOptionLevel.depth === 1
|
||
? "先选择当前步骤的主卡片"
|
||
: "当前层级只展示这一层的子节点卡片,选完再进入下一层"}
|
||
</span>
|
||
</div>
|
||
|
||
<VirtualizedOptionGrid
|
||
blockedOptionKeys={blockedOptionKeys}
|
||
onSelect={handleEmbeddedOptionSelect}
|
||
options={activeEmbeddedOptionLevel.options}
|
||
selectedKey={activeEmbeddedOptionLevel.selectedKey}
|
||
step={activeEmbeddedStepEntry.step}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</motion.section>
|
||
) : null
|
||
) : (
|
||
flattenedResolvedSteps.map(({ step, depth }, stepIndex) => (
|
||
<motion.section
|
||
key={step.key}
|
||
className={cn(
|
||
"rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||
depth > 1 && "ml-3 border-l-4 border-l-emerald-500/30 sm:ml-6"
|
||
)}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.15 + stepIndex * 0.05 }}
|
||
>
|
||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/20 text-xs font-bold text-emerald-400">
|
||
{stepIndex + 2}
|
||
</span>
|
||
{step.title}
|
||
{depth > 1 ? (
|
||
<span className="rounded-full border border-white/12 px-2 py-0.5 text-[11px] font-medium text-white/45">
|
||
{`${depth}级模块`}
|
||
</span>
|
||
) : null}
|
||
</h2>
|
||
<div className="mb-3 text-xs text-white/55">
|
||
{stepSelectionSummaryMap[step.key]
|
||
? `已选:${stepSelectionSummaryMap[step.key]}`
|
||
: "待选择:先确定这一组风格卡片,系统再继续组合 Prompt"}
|
||
</div>
|
||
{step.description && (
|
||
<p className="mb-4 text-sm text-white/40">{step.description}</p>
|
||
)}
|
||
{renderOptionLevels(step, step.options || [])}
|
||
</motion.section>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
if (embedded) {
|
||
return workflowContent;
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-dvh flex-col overflow-hidden bg-black">
|
||
<AgentCanvasTopNavDark
|
||
siteName={siteName}
|
||
siteLogo={siteLogo}
|
||
siteLogoDark={siteLogoDark}
|
||
activeKey="agent"
|
||
batchProcessingEnabled={pageFeatureSettings.batchProcessingEnabled}
|
||
rightSlot={topNavRightSlot}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto overscroll-contain pt-16">
|
||
{/* Hero Section */}
|
||
<section className="relative">
|
||
<div className="mx-auto max-w-[1480px] px-6 py-8 lg:px-8 xl:px-10">
|
||
<AgentWorkflowShowcaseBanner
|
||
config={showcaseConfig}
|
||
actionLabel="开始选择风格"
|
||
secondaryActionLabel={onSkipStep ? "进入风格生图" : undefined}
|
||
onActionClick={() => {
|
||
const target = document.getElementById(
|
||
"agent-workflow-content"
|
||
);
|
||
if (!target) return;
|
||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}}
|
||
onSecondaryActionClick={onSkipStep}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Main Content */}
|
||
<main className="pb-40">
|
||
<div
|
||
id="agent-workflow-content"
|
||
className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10"
|
||
>
|
||
{workflowContent}
|
||
</div>
|
||
</main>
|
||
|
||
{onNextStep ? (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 18 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.2 }}
|
||
className="mx-auto mt-8 w-full max-w-[1480px] px-6 lg:px-8 xl:px-10"
|
||
>
|
||
<div className="flex w-full flex-col gap-3 rounded-[24px] border border-white/12 bg-black/88 px-3 py-3 shadow-[0_18px_54px_rgba(0,0,0,0.42)] backdrop-blur-2xl sm:px-4">
|
||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="text-[13px] font-medium text-white">
|
||
工作流进度
|
||
</div>
|
||
<div className="text-[11px] text-white/45">
|
||
已完成 {progress.completed} / {progress.total} 个步骤
|
||
{progress.isComplete
|
||
? ",可以直接进入下一步"
|
||
: ",也可以选择跳过直接进入下一步"}
|
||
</div>
|
||
</div>
|
||
<div className="text-[13px] font-semibold text-white">
|
||
{progress.total > 0
|
||
? Math.round((progress.completed / progress.total) * 100)
|
||
: 0}
|
||
%
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
|
||
<motion.div
|
||
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400"
|
||
initial={{ width: 0 }}
|
||
animate={{
|
||
width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%`,
|
||
}}
|
||
transition={{ duration: 0.45 }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||
{onSkipStep ? (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={onSkipStep}
|
||
className="h-10 gap-2 rounded-full border border-white/10 bg-white/[0.03] px-5 text-white/75 hover:bg-white/10 hover:text-white"
|
||
>
|
||
跳过并进入下一步
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
onClick={onNextStep}
|
||
disabled={progress.total > 0 && !progress.isComplete}
|
||
className={cn(
|
||
"h-10 gap-2 rounded-full px-6",
|
||
"bg-gradient-to-r from-emerald-500 to-teal-500",
|
||
"text-sm font-medium text-white",
|
||
"hover:from-emerald-400 hover:to-teal-400",
|
||
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
|
||
"disabled:opacity-30"
|
||
)}
|
||
>
|
||
下一步:选择生图模式
|
||
<ArrowRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
) : null}
|
||
|
||
{/* 页脚 */}
|
||
<footer className="mt-8 border-t border-white/5 pb-8 pt-6">
|
||
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
|
||
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
|
||
<a href="#" className="hover:text-white/60 transition-colors">
|
||
使用协议
|
||
</a>
|
||
<a href="#" className="hover:text-white/60 transition-colors">
|
||
隐私条款
|
||
</a>
|
||
<span>© 2024 AI创作平台</span>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const AgentWorkflowPanelDarkV2 = memo(AgentWorkflowPanelDarkV2Component);
|