feat(*): 创作工作台 - 优化全景打光交互、生成模式与图片缓存
This commit is contained in:
@@ -1,18 +1,16 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Bot, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Button, Dropdown, Tooltip } from "antd";
|
||||
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
|
||||
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
import type { CanvasMediaPerformanceMode } from "@/types/canvas";
|
||||
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
|
||||
|
||||
type CanvasTopBarProps = {
|
||||
@@ -282,82 +280,6 @@ export function CanvasTopBar({
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const reducedMotion = useReducedMotion();
|
||||
const simple = mode === "simple";
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutsidePress = (event: PointerEvent) => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOnOutsidePress);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePress);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const selectMode = (nextMode: CanvasWorkspaceMode) => {
|
||||
if (nextMode !== mode) onChange(nextMode);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto relative z-[var(--dock-z-popover)]">
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={reducedMotion ? undefined : { y: -1 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
style={{ ...canvasDockStyle(theme, theme.node.text), background: open ? "var(--dock-command-active)" : "var(--dock-surface)", outlineColor: theme.accent.primary }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`当前为${simple ? "简洁" : "专业"}模式,点击切换`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
|
||||
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 whitespace-nowrap text-[var(--fs-caption)] font-semibold leading-none">{simple ? "简洁模式" : "专业模式"}</span>
|
||||
<motion.span animate={{ rotate: open ? 180 : 0 }} transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock} className="grid size-4 place-items-center" style={{ color: theme.node.muted }}>
|
||||
<ChevronDown className="size-3" />
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
|
||||
<div className="absolute bottom-11 right-0 w-72" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
role="listbox"
|
||||
aria-label="选择画布工作模式"
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.92 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] p-1.5 backdrop-blur-2xl"
|
||||
style={{ ...canvasDockStyle(theme, theme.node.text), background: "var(--dock-surface)" }}
|
||||
>
|
||||
<ModeOption active={simple} motionEnabled={!reducedMotion} icon={<Sparkles className="size-4" />} title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
|
||||
<ModeOption active={!simple} motionEnabled={!reducedMotion} icon={<Settings2 className="size-4" />} title="专业模式" description="显示完整节点、导演台与生成控制" theme={theme} onClick={() => selectMode("professional")} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
|
||||
// 顶栏是 --z-toolbar 上的绝对定位浮层,会为子元素建立层叠上下文。行内绝对定位的提示
|
||||
// 无论 z-index 多高都无法越过它,会被顶栏下方 --z-panel-floating 的生成任务面板盖住,
|
||||
// 因此提示必须走 portal 的浮层层级。
|
||||
@@ -369,25 +291,6 @@ function CanvasTopBarTooltip({ label, children }: { label: string; children: Rea
|
||||
);
|
||||
}
|
||||
|
||||
function ModeOption({ active, motionEnabled, icon, title, description, theme, onClick }: { active: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; theme: CanvasTheme; onClick: () => void }) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
whileTap={motionEnabled ? { scale: 0.98 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="canvas-mode-switch-option group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0"
|
||||
style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text, outlineColor: theme.accent.primary }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] [&_svg]:size-3.5" style={{ background: theme.spatial.surface, color: active ? theme.accent.primary : theme.node.muted }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-[var(--fs-body)] font-semibold leading-none">{title}</span><span className="mt-1.5 block text-[var(--fs-caption)] leading-5" style={{ color: theme.node.muted }}>{description}</span></span>
|
||||
<span className="grid size-5 shrink-0 place-items-center" style={{ color: theme.accent.primary, opacity: active ? 1 : 0 }}><Check className="size-3.5" /></span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
|
||||
return (
|
||||
<span className="flex min-w-36 items-center justify-between gap-8">
|
||||
|
||||
@@ -82,7 +82,7 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
|
||||
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
|
||||
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
|
||||
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
||||
@@ -90,7 +90,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
|
||||
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { cancelGenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
|
||||
import { CanvasTopBar } from "./canvas-project-top-bar";
|
||||
import { LibTVImportDialog } from "./components/libtv-import-dialog";
|
||||
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
|
||||
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
|
||||
@@ -295,7 +295,7 @@ function InfiniteCanvasPage() {
|
||||
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("box-select");
|
||||
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(readCanvasWorkspaceMode);
|
||||
const workspaceMode: CanvasWorkspaceMode = "professional";
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
|
||||
@@ -352,10 +352,6 @@ function InfiniteCanvasPage() {
|
||||
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
|
||||
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
persistCanvasWorkspaceMode(workspaceMode);
|
||||
}, [workspaceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
persistCanvasMediaPerformanceMode(mediaPerformanceMode);
|
||||
}, [mediaPerformanceMode]);
|
||||
@@ -1115,6 +1111,9 @@ function InfiniteCanvasPage() {
|
||||
setArtCritiqueNodeId(node.id);
|
||||
} else if (node.type === CanvasNodeType.MediaConversion) {
|
||||
setDialogNodeId(null);
|
||||
} else if (node.type === CanvasNodeType.Panorama) {
|
||||
// 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。
|
||||
setDialogNodeId(null);
|
||||
} else {
|
||||
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
|
||||
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
|
||||
@@ -1132,7 +1131,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const handleNodeDragEnd = useCallback((nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
|
||||
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === CanvasNodeType.Panorama || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
|
||||
setDialogNodeId(null);
|
||||
return;
|
||||
}
|
||||
@@ -2323,19 +2322,6 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!focusMode ? (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="pointer-events-none absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] z-[var(--z-toolbar)] transition-[right,bottom] duration-300 lg:bottom-[var(--canvas-inset-y)]"
|
||||
style={{ right: assistantMounted ? `calc(var(--canvas-inset-x) + ${assistantWidth}px + var(--space-3))` : "var(--canvas-inset-x)" }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={setWorkspaceMode} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CanvasNodeSearchModal
|
||||
open={nodeSearchOpen}
|
||||
nodes={nodes}
|
||||
@@ -2613,16 +2599,17 @@ function InfiniteCanvasPage() {
|
||||
flush
|
||||
open
|
||||
centered
|
||||
title="打光效果"
|
||||
title={null}
|
||||
closable={false}
|
||||
footer={null}
|
||||
width={900}
|
||||
width={720}
|
||||
onCancel={() => setLightingNodeId(null)}
|
||||
>
|
||||
<CanvasNodeLightingPanel
|
||||
dataUrl={lightingNode.metadata.content}
|
||||
onClose={() => setLightingNodeId(null)}
|
||||
onConfirm={(options, prompt) => {
|
||||
void generateLightingNode(lightingNode, options, prompt);
|
||||
generateLightingNode(lightingNode, options, prompt);
|
||||
}}
|
||||
/>
|
||||
</AppModal>
|
||||
@@ -2642,7 +2629,7 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
|
||||
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && dialogNode.type !== CanvasNodeType.Panorama && !selectionBox && !isCanvasNodeMoving ? (
|
||||
<CanvasNodePanelOverlay
|
||||
node={dialogNode}
|
||||
viewport={viewport}
|
||||
|
||||
@@ -588,7 +588,8 @@ export function useCanvasMediaTools({
|
||||
});
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(childId);
|
||||
// 全景节点是纯查看器,创建后不弹提示词面板。
|
||||
setDialogNodeId(null);
|
||||
setPanoramaConfigNodeId(null);
|
||||
message.success(config.sourceMode === "image" ? "已创建全景查看节点" : "已创建全景生成节点");
|
||||
}, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
@@ -863,49 +864,35 @@ export function useCanvasMediaTools({
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
const generateLightingNode = useCallback(async (node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
|
||||
const generateLightingNode = useCallback((node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
|
||||
if (!node.metadata?.content) return;
|
||||
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const childId = nanoid();
|
||||
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const title = buildLightingLabel(options);
|
||||
const source = nodeReferenceImage(node);
|
||||
if (!source) return;
|
||||
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
|
||||
if (!styleExecution) return;
|
||||
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
|
||||
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
|
||||
setLightingNodeId(null);
|
||||
setRunningNodeId(childId);
|
||||
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: imageSpec.width, height: imageSpec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
|
||||
setNodes((current) => [...current, {
|
||||
id: childId,
|
||||
type: CanvasNodeType.Image,
|
||||
title,
|
||||
position: { x: node.position.x + node.width + 96, y: node.position.y },
|
||||
width: imageSpec.width,
|
||||
height: imageSpec.height,
|
||||
metadata: {
|
||||
prompt,
|
||||
status: NODE_STATUS_IDLE,
|
||||
generationMode: "image",
|
||||
...generationMetadata,
|
||||
},
|
||||
}]);
|
||||
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(childId);
|
||||
const controller = startGenerationRequest(childId, node.id, childId);
|
||||
try {
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "lighting", lighting: options, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
|
||||
const image = result.images?.[0];
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("打光生成节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = generationErrorMessage(error);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
} finally {
|
||||
finishGenerationRequest(childId, controller);
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
}, [effectiveConfig, setConnections, setDialogNodeId, setLightingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
|
||||
if (!node.metadata?.content) return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AppDrawer } from "@/components/ui/product/app-drawer";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { Reorder } from "motion/react";
|
||||
import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
|
||||
@@ -624,16 +624,19 @@ export function CreationComposer(props: ComposerProps) {
|
||||
}
|
||||
|
||||
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
|
||||
{ mode: "video", icon: <Film />, label: "视频生成" },
|
||||
{ mode: "image", icon: <ImageIcon />, label: "图片生成" },
|
||||
{ mode: "text", icon: <MessageSquareText />, label: "文本创作" },
|
||||
{ mode: "video", icon: <Film />, label: "视频" },
|
||||
{ mode: "image", icon: <ImageIcon />, label: "图片" },
|
||||
{ mode: "text", icon: <MessageSquareText />, label: "文本" },
|
||||
];
|
||||
const current = items.find((item) => item.mode === mode) || items[0];
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-mode-picker-menu" role="listbox" aria-label="选择生成类型">{items.map((item) => <button key={item.mode} type="button" role="option" aria-selected={item.mode === mode} className={item.mode === mode ? "is-selected" : ""} onClick={() => { onModeChange(item.mode); setOpen(false); }}><span className="creation-menu-icon">{item.icon}</span><span>{item.label}</span>{item.mode === mode ? <Check /> : null}</button>)}</div>}>
|
||||
<button type="button" className="creation-chat-control is-mode" aria-label={`生成类型:${current.label}`}>{current.icon}<span>{current.label}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
return <div className="creation-mode-capsule" role="group" aria-label="生成类型">
|
||||
{items.map((item) => (
|
||||
<button key={item.mode} type="button" className="creation-chat-control" aria-pressed={item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function GenerationSettingsMenu(props: ComposerProps) {
|
||||
|
||||
Reference in New Issue
Block a user