refactor(*): 用户工作台 - 统一画布、任务中心与设置页产品化交互

This commit is contained in:
ddCat
2026-08-14 23:44:55 +08:00
parent fedd48095f
commit 15b679acf0
29 changed files with 1662 additions and 2067 deletions

View File

@@ -1,20 +1,19 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Segmented, Tag } from "antd";
import { Ellipsis, Lock, Plus, Settings2, Unlock } from "lucide-react";
import type { MenuProps } from "antd";
import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { generationErrorMessage } from "@/lib/generation-error";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas";
import { ImageToolSettingsModal } from "./canvas-image-toolbar-settings-modal";
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, isImageQuickToolId, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
import { buildImageToolbarTools } from "./canvas-image-toolbar-tools";
type CanvasNodeToolbarProps = {
node: CanvasNodeData | null;
@@ -71,7 +70,6 @@ const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }>
type ToolbarTool = {
id: string;
title: string;
label: string;
icon: ReactNode;
onClick: () => void;
@@ -80,9 +78,6 @@ type ToolbarTool = {
disabled?: boolean;
};
const MAX_IMAGE_QUICK_TOOLS = 7;
const NODE_DOCK_LABELS_STORAGE_KEY = "canvas-node-dock-show-labels-v1";
export function CanvasNodeToolbar({
node,
viewport,
@@ -123,17 +118,9 @@ export function CanvasNodeToolbar({
onDelete,
workspaceMode = "professional",
}: CanvasNodeToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showDockLabels, setShowDockLabels] = useState(true);
const [draftShowDockLabels, setDraftShowDockLabels] = useState(true);
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
const [imageToolMenuOpen, setImageToolMenuOpen] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
// Dropdown 关闭与菜单点击处于同一事件批次,ref 用来同步守住即将打开的 Modal,避免工具栏先被卸载。
const imageToolSettingsOpenRef = useRef(false);
const imageToolMenuOpenRef = useRef(false);
const { message } = App.useApp();
const copyText = useCopyText();
const themeName = useThemeStore((state) => state.theme);
@@ -141,25 +128,7 @@ export function CanvasNodeToolbar({
const simpleMode = workspaceMode === "simple";
useEffect(() => {
try {
const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored) as unknown;
setQuickImageToolIds(readImageQuickToolsConfig(parsed));
} catch {
window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
}
}, []);
useEffect(() => {
setShowDockLabels(window.localStorage.getItem(NODE_DOCK_LABELS_STORAGE_KEY) !== "0");
}, []);
useEffect(() => {
imageToolSettingsOpenRef.current = false;
imageToolMenuOpenRef.current = false;
setImageToolSettingsOpen(false);
setImageToolMenuOpen(false);
setOpenMenuId(null);
}, [node?.id]);
useLayoutEffect(() => {
@@ -181,7 +150,6 @@ export function CanvasNodeToolbar({
const halfToolbar = toolbarWidth / 2;
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
// 外置标题固定占 24px,额外保留 6px 即可兼顾层级和名称编辑入口。
const top = nodeRect.top - containerRect.top - 30;
if (toolbarRef.current) {
toolbarRef.current.style.left = `${left}px`;
@@ -206,25 +174,17 @@ export function CanvasNodeToolbar({
unsubscribeViewport();
window.removeEventListener("resize", update);
};
}, [anchor === null, containerRef, node, showDockLabels, viewport.k, viewport.x, viewport.y]);
}, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]);
if (!node || !anchor) return null;
const activeNode = node;
const isImage = node.type === CanvasNodeType.Image;
const isVideo = node.type === CanvasNodeType.Video;
const isAudio = node.type === CanvasNodeType.Audio;
const hasImage = isImage && Boolean(node.metadata?.content);
const hasVideo = isVideo && Boolean(node.metadata?.content);
const hasAudio = isAudio && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isCharacterReference = isText && node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId);
const isEditableText = isText && !isCharacterReference;
const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isEditableText || isImage || isVideo;
const requiresPromptChange = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
const canRetry = node.metadata?.status === "error" && !requiresPromptChange;
const quickImageToolIdSet = new Set(quickImageToolIds);
const copyImagePrompt = (target: CanvasNodeData) => {
const prompt = target.metadata?.prompt?.trim();
if (!prompt) {
@@ -235,16 +195,6 @@ export function CanvasNodeToolbar({
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
function openImageToolSettings() {
imageToolSettingsOpenRef.current = true;
imageToolMenuOpenRef.current = false;
onKeep(activeNode.id);
setImageToolMenuOpen(false);
setDraftImageToolIds(quickImageToolIds);
setDraftShowDockLabels(showDockLabels);
setImageToolSettingsOpen(true);
}
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {
onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont,
@@ -276,14 +226,13 @@ export function CanvasNodeToolbar({
handlers: nodeHoverHandlers,
};
// 从注册表解析工具(已按 applicable 过滤、用户偏好排序)
const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, readToolbarPrefs("node-hover") ?? defaultToolbarPrefs("node-hover"));
// 分离 node-lock(始终显示,不参与 quickImageToolIds 过滤)
// 注册表只负责动作合同与适用性,Dock 的业务分组在此处唯一确定。
const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, null);
// 锁定始终放在菜单末尾,避免与业务工具混排。
const otherRegistryTools = registryTools.filter((tool) => tool.id !== "node-lock");
// 转为 ToolbarTool 供组件内部逻辑使用
const otherTools: ToolbarTool[] = otherRegistryTools.map((tool) => ({
id: tool.id,
title: typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label,
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
active: tool.active?.(nodeHoverCtx),
@@ -291,123 +240,107 @@ export function CanvasNodeToolbar({
disabled: tool.disabled?.(nodeHoverCtx),
onClick: () => tool.run(nodeHoverCtx),
}));
// 合并图片工具(当 hasImage && !simpleMode 时追加到 otherTools 末尾)
const allTools: ToolbarTool[] = hasImage && !simpleMode ? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, danger: undefined, disabled: undefined, onClick: tool.onClick }))] : otherTools;
// hasImage 时按 quickImageToolIds 过滤
const toolbarTools = hasImage ? allTools.filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : allTools;
const selectableImageToolbarTools = allTools.filter((tool): tool is ToolbarTool & { id: ImageQuickToolId } => isImageQuickToolId(tool.id));
const temporaryImageToolbarTools = selectableImageToolbarTools.filter((tool) => !quickImageToolIdSet.has(tool.id));
const dockItems: FloatingDockEntry[] = [
...toolbarTools.map((tool) => ({ id: tool.id, label: tool.title, displayLabel: tool.label, icon: tool.icon, active: tool.active, danger: tool.danger, disabled: tool.disabled, onClick: () => tool.onClick() })),
{ kind: "separator", id: "node-state-separator" },
{ id: "node-lock", label: node.metadata?.locked ? "解锁节点" : "锁定位置和尺寸", displayLabel: node.metadata?.locked ? "解锁" : "锁定", icon: node.metadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />, active: Boolean(node.metadata?.locked), onClick: () => onToggleLocked(node) },
];
const closeImageToolSettings = () => {
imageToolSettingsOpenRef.current = false;
setImageToolSettingsOpen(false);
onLeave();
const allTools: ToolbarTool[] = hasImage && !simpleMode
? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, label: tool.label, icon: tool.icon, onClick: tool.onClick }))]
: otherTools;
const toolById = new Map(allTools.map((tool) => [tool.id, tool]));
const takeTools = (ids: string[]) => ids.map((id) => toolById.get(id)).filter((tool): tool is ToolbarTool => Boolean(tool));
const imageBaseTools = takeTools(hasImage ? ["delete", "download"] : ["delete", "uploadImage"]);
const imageEditTools = takeTools(["maskEdit", "crop", "split"]);
const imagePortraitTools = takeTools(["emotion", "portraitTexture"]).map((tool) => tool.id === "emotion" ? { ...tool, label: "人物情绪" } : tool);
const imageAngleTool = toolById.get("angle");
const videoTools = takeTools(["delete", "download", "subtitles", "timeline", "extractLastFrame", "extractAudio", "trimRegenerate", "uploadVideo"]).map((tool) => {
if (tool.id === "extractLastFrame") return { ...tool, label: "尾帧截取" };
if (tool.id === "trimRegenerate") return { ...tool, label: "截取重生成" };
return tool;
});
const genericTools = takeTools(isAudio ? ["delete", "download", "timeline", "uploadAudio"] : isEditableText ? ["delete", "edit", "editText", "generateImage", "saveAsset"] : ["delete", "info", "config"]);
const visibleToolIds = new Set([
...(isImage ? [...imageBaseTools, ...imageEditTools, ...imagePortraitTools, ...(imageAngleTool ? [imageAngleTool] : [])] : isVideo ? videoTools : genericTools).map((tool) => tool.id),
]);
const overflowTools = allTools
.filter((tool) => !visibleToolIds.has(tool.id))
.map((tool) => tool.id === "edit" && (isImage || isVideo) ? { ...tool, label: "生成设置" } : tool);
const lockTool: ToolbarTool = {
id: "node-lock",
label: node.metadata?.locked ? "解锁" : "锁定",
icon: node.metadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />,
active: Boolean(node.metadata?.locked),
onClick: () => onToggleLocked(node),
};
const runTemporaryImageTool = (tool: ToolbarTool) => {
imageToolMenuOpenRef.current = false;
setImageToolMenuOpen(false);
tool.onClick();
};
const handleImageToolMenuOpenChange = (open: boolean) => {
imageToolMenuOpenRef.current = open;
setImageToolMenuOpen(open);
if (open) onKeep(activeNode.id);
else if (!imageToolSettingsOpenRef.current) onLeave();
};
const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => {
setDraftImageToolIds((current) => {
const selected = new Set(current);
if (visible && selected.size >= MAX_IMAGE_QUICK_TOOLS) {
message.warning(`最多固定 ${MAX_IMAGE_QUICK_TOOLS} 个快捷工具`);
return current;
}
if (visible) selected.add(id);
else selected.delete(id);
return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id);
});
};
const saveImageToolSettings = () => {
setQuickImageToolIds(draftImageToolIds);
setShowDockLabels(draftShowDockLabels);
window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(draftImageToolIds));
window.localStorage.setItem(NODE_DOCK_LABELS_STORAGE_KEY, draftShowDockLabels ? "1" : "0");
closeImageToolSettings();
};
const dockShellStyle = canvasDockStyle(theme, theme.node.text);
const embeddedDockStyle = { ...dockShellStyle, background: "transparent", borderColor: "transparent", boxShadow: "none" };
const labeledDockStyle = {
...dockShellStyle,
boxShadow: themeName === "dark"
? `0 18px 52px ${theme.spatial.shadow}`
: "0 10px 30px rgba(15,23,42,.12), 0 1px 2px rgba(15,23,42,.05), inset 0 1px 0 rgba(255,255,255,.7)",
const handleMenuOpenChange = (menuId: string, open: boolean) => {
setOpenMenuId((current) => open ? menuId : current === menuId ? null : current);
if (open) onKeep(node.id);
else onLeave();
};
const dockStyle = canvasDockStyle(theme, theme.node.text);
return (
<>
<div
ref={toolbarRef}
className="canvas-node-toolbar absolute z-[var(--z-node-toolbar)] -translate-x-1/2 -translate-y-full"
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: "min(calc(100% - 20px), 960px)", color: theme.node.text }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={() => { if (!openMenuId) onLeave(); }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div
ref={toolbarRef}
className="canvas-node-toolbar absolute z-[var(--z-node-toolbar)] flex -translate-x-1/2 -translate-y-full items-end justify-center overflow-visible"
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: `min(calc(100% - 20px), ${showDockLabels ? 840 : 560}px)`, color: theme.node.text }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={() => {
if (!imageToolSettingsOpenRef.current && !imageToolMenuOpenRef.current) onLeave();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
role="toolbar"
aria-label="节点快捷工具"
className="thin-scrollbar flex h-11 max-w-full items-center gap-0.5 overflow-x-auto rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
style={{ ...dockStyle, border: 0 }}
>
<div className={`aceternity-floating-dock thin-scrollbar relative flex max-w-full overflow-x-auto rounded-[var(--dock-radius)] border backdrop-blur-2xl ${showDockLabels ? "h-11 items-center px-2 py-1" : "h-10 items-end gap-1 px-1.5 pb-1"}`} style={showDockLabels ? labeledDockStyle : dockShellStyle}>
{dockItems.length ? <FloatingDock embedded items={dockItems} size="compact" showLabels={showDockLabels} ariaLabel="节点快捷工具" className={`pointer-events-auto shrink-0 ${showDockLabels ? "" : "max-w-[min(calc(100vw-20px),400px)]"}`} style={embeddedDockStyle} /> : null}
{hasImage && !simpleMode ? (
<Dropdown
open={imageToolMenuOpen}
trigger={["click"]}
placement="topRight"
onOpenChange={handleImageToolMenuOpenChange}
menu={{
items: [
...temporaryImageToolbarTools.map((tool) => ({ key: tool.id, icon: tool.icon, label: tool.label, danger: tool.danger, onClick: () => runTemporaryImageTool(tool) })),
...(temporaryImageToolbarTools.length ? [{ type: "divider" as const }] : []),
{ key: "manage-image-quick-tools", icon: <Settings2 className="size-3.5" />, label: "管理快捷工具", onClick: openImageToolSettings },
],
}}
>
<button
type="button"
className={`aceternity-dock-command pointer-events-auto shrink-0 outline-none focus-visible:ring-2 ${showDockLabels ? "is-labeled inline-flex h-8 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5" : "grid size-8 place-items-center rounded-full"}`}
style={{ color: theme.node.text, "--tw-ring-color": theme.accent.primary } as CSSProperties}
aria-label="更多图片工具"
title="更多图片工具"
>
<Ellipsis className="size-3.5" />
{showDockLabels ? <span className="inline-flex h-4 items-center text-[var(--fs-label)] font-medium leading-none">更多</span> : null}
</button>
</Dropdown>
) : null}
</div>
{isImage ? (
<>
{imageBaseTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{imageEditTools.length ? <NodeDockMenuButton menuId="image-edit" label="编辑" icon={imageEditTools[0].icon} tools={imageEditTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{imagePortraitTools.length ? <NodeDockMenuButton menuId="image-portrait" label="人物调整" icon={imagePortraitTools[0].icon} tools={imagePortraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{imageAngleTool ? <NodeDockToolButton tool={imageAngleTool} /> : null}
</>
) : isVideo ? videoTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />) : genericTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
<span aria-hidden className="aceternity-dock-separator mx-1.5 h-6 w-px shrink-0" />
<NodeDockToolButton tool={lockTool} />
{overflowTools.length ? (
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={overflowTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" />
) : null}
</div>
{hasImage ? (
<ImageToolSettingsModal
open={imageToolSettingsOpen}
tools={selectableImageToolbarTools}
selectedIds={draftImageToolIds}
showLabels={draftShowDockLabels}
onToggle={setDraftImageToolVisible}
onShowLabelsChange={setDraftShowDockLabels}
onCancel={closeImageToolSettings}
onSave={saveImageToolSettings}
/>
) : null}
</>
</div>
);
}
function NodeDockToolButton({ tool }: { tool: ToolbarTool }) {
return (
<button
type="button"
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${tool.active ? "is-active" : ""} ${tool.danger ? "is-danger" : ""}`}
aria-label={tool.label}
aria-pressed={tool.active || undefined}
disabled={tool.disabled}
onClick={tool.onClick}
>
<span className="grid size-3.5 shrink-0 place-items-center">{tool.icon}</span>
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span>
</button>
);
}
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top" }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight" }) {
const open = openMenuId === menuId;
const items: MenuProps["items"] = tools.map((tool) => ({ key: tool.id, icon: tool.icon, label: tool.label, disabled: tool.disabled, onClick: tool.onClick }));
return (
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }}>
<button
type="button"
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${open ? "is-active" : ""}`}
aria-label={label}
aria-expanded={open}
>
<span className="grid size-3.5 shrink-0 place-items-center">{icon}</span>
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span>
<ChevronDown className="size-3 shrink-0 opacity-55" />
</button>
</Dropdown>
);
}