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

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

View File

@@ -85,7 +85,7 @@ function AudioSettingsPortal({
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: "var(--z-popover)",
zIndex: "var(--z-dialog-popover)",
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),

View File

@@ -66,9 +66,9 @@ export const ConnectionPath = React.memo(function ConnectionPath({
{showVisual ? <path
d={pathD}
stroke={emphasized ? theme.accent.primary : theme.node.muted}
strokeWidth={emphasized ? 1.6 : 1}
strokeWidth={emphasized ? 1.4 : 1}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.52 : 0.24}
strokeOpacity={emphasized ? 0.42 : 0.16}
fill="none"
strokeLinecap="round"
style={{ pointerEvents: "none" }}
@@ -79,11 +79,11 @@ export const ConnectionPath = React.memo(function ConnectionPath({
stroke={`url(#${gradientId})`}
strokeWidth="1.8"
vectorEffect="non-scaling-stroke"
strokeOpacity="1"
strokeOpacity="0.72"
strokeDasharray="18 26"
fill="none"
strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 3px ${theme.accent.primary}35)`, pointerEvents: "none" }}
style={{ pointerEvents: "none" }}
/> : null}
</g>
);

View File

@@ -145,7 +145,7 @@ export function CanvasNodeContextMenu({
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="aceternity-floating-panel fixed z-[var(--z-popover)] flex w-[224px] max-h-[calc(100vh-56px)] origin-top-left flex-col overflow-hidden rounded-xl border p-1.5 backdrop-blur-2xl"
style={{ left: position.left, top: position.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }}
style={{ left: position.left, top: position.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
>
@@ -288,7 +288,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl"
style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }}
style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
>

View File

@@ -107,7 +107,7 @@ function ImageSettingsPortal({
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: "var(--z-popover)",
zIndex: "var(--z-dialog-popover)",
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),

View File

@@ -1,84 +0,0 @@
import { useMemo, type ReactNode } from "react";
import { Button, Checkbox, Modal, Space, Switch } from "antd";
import { Ellipsis, Settings2, Type } from "lucide-react";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ImageQuickToolId } from "./canvas-image-toolbar-tools";
export type ImageToolbarSettingsTool = {
id: ImageQuickToolId;
title: string;
label: string;
icon: ReactNode;
active?: boolean;
danger?: boolean;
};
export function ImageToolSettingsModal({ open, tools, selectedIds, showLabels, onToggle, onShowLabelsChange, onCancel, onSave }: {
open: boolean;
tools: ImageToolbarSettingsTool[];
selectedIds: ImageQuickToolId[];
showLabels: boolean;
onToggle: (id: ImageQuickToolId, visible: boolean) => void;
onShowLabelsChange: (visible: boolean) => void;
onCancel: () => void;
onSave: () => void;
}) {
const maxSelected = 7;
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const selected = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedTools = tools.filter((tool) => selected.has(tool.id));
const previewItems: FloatingDockEntry[] = [
...selectedTools.map((tool) => ({ id: tool.id, label: tool.title, displayLabel: tool.label, icon: tool.icon, active: tool.active, danger: tool.danger })),
{ id: "more", label: "自定义节点工具", displayLabel: "更多", icon: <Ellipsis className="size-4" /> },
];
const updateSelectedTools = (values: ImageQuickToolId[]) => {
const next = new Set(values);
tools.forEach((tool) => {
const visible = next.has(tool.id);
if (selected.has(tool.id) !== visible) onToggle(tool.id, visible);
});
};
return (
<Modal
className="workspace-modal workspace-modal-compact canvas-image-toolbar-settings-modal"
title={<span className="inline-flex h-6 items-center gap-2 text-sm font-semibold"><Settings2 className="size-3.5" />自定义节点 Dock</span>}
open={open}
centered
onCancel={onCancel}
destroyOnHidden
styles={{ body: { padding: 0 }, footer: { marginTop: 0 } }}
footer={<Space size={8}><Button onClick={onCancel}>取消</Button><Button type="primary" onClick={onSave}>保存设置</Button></Space>}
>
<div className="flex h-11 items-center justify-between gap-3 px-4">
<span className="flex min-w-0 items-center gap-2">
<span className="canvas-image-tool-heading-icon grid size-7 shrink-0 place-items-center rounded-[var(--r-md)]"><Type className="size-3.5" /></span>
<span className="flex h-5 items-center text-xs font-medium leading-none">显示功能名</span>
</span>
<Switch size="small" checked={showLabels} onChange={onShowLabelsChange} aria-label="显示节点 Dock 功能名" />
</div>
<div className="relative grid h-[92px] place-items-center overflow-hidden" style={{ background: theme.canvas.background }}>
<div className="thin-scrollbar relative flex max-w-full overflow-x-auto px-4 py-3">
<FloatingDock items={previewItems} size="compact" showLabels={showLabels} ariaLabel="图片节点工具预览" className="shrink-0" style={canvasDockStyle(theme, theme.node.text)} />
</div>
</div>
<div className="px-4 py-3">
<div className="mb-2 flex h-5 items-center justify-between"><span className="text-xs font-semibold">快捷工具</span><span className="canvas-image-tool-count">{selectedTools.length}/{maxSelected}</span></div>
<Checkbox.Group value={selectedIds} className="grid w-full grid-cols-2 gap-1 sm:grid-cols-4" onChange={(values) => updateSelectedTools(values as ImageQuickToolId[])}>
{tools.map((tool) => (
<label key={tool.id} className={`canvas-image-tool-option flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-[var(--r-md)] px-1.5 transition-colors${selected.has(tool.id) ? " is-selected" : ""}`}>
<Checkbox className="shrink-0" value={tool.id} disabled={!selected.has(tool.id) && selectedTools.length >= maxSelected} />
<span className="canvas-image-tool-option-icon grid size-5 shrink-0 place-items-center rounded-[var(--r-sm)] [&_svg]:size-3">{tool.icon}</span>
<span className="min-w-0 truncate text-[var(--fs-tiny)] font-medium leading-none">{tool.label}</span>
</label>
))}
</Checkbox.Group>
</div>
</Modal>
);
}

View File

@@ -3,10 +3,9 @@ import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Penc
import type { CanvasNodeData } from "@/types/canvas";
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
export type ImageToolHandlers = {
type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onAnnotate: (node: CanvasNodeData) => void;
@@ -23,178 +22,109 @@ export type ImageToolHandlers = {
onReversePrompt: (node: CanvasNodeData) => void;
};
export type ImageToolDefinition = {
type ImageToolDefinition = {
id: ImageNodeActionToolId;
defaultVisible: boolean;
panelLabel: string;
label: string | ((node: CanvasNodeData) => string);
title: string | ((node: CanvasNodeData) => string);
icon: (node: CanvasNodeData) => ReactNode;
active?: (node: CanvasNodeData) => boolean;
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
};
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v13";
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
export const imageToolDefinitions: ImageToolDefinition[] = [
const imageToolDefinitions: ImageToolDefinition[] = [
{
id: "copyPrompt",
defaultVisible: true,
panelLabel: "复制提示词",
label: "复制提示词",
title: "复制生成该图片的提示词",
icon: () => <Copy className="size-3.5" />,
run: (node, handlers) => handlers.onCopyPrompt(node),
},
{
id: "reversePrompt",
defaultVisible: true,
panelLabel: "反推提示词",
label: "反推提示词",
title: "创建反推提示词的文本和配置节点",
icon: () => <FileText className="size-3.5" />,
run: (node, handlers) => handlers.onReversePrompt(node),
},
{
id: "replace",
defaultVisible: true,
panelLabel: "替换图片",
label: "替换图片",
title: "替换图片",
icon: () => <Upload className="size-3.5" />,
run: (node, handlers) => handlers.onUpload(node),
},
{
id: "resize",
defaultVisible: false,
panelLabel: "锁比例",
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"),
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-3.5" /> : <Lock className="size-3.5" />),
active: (node) => Boolean(node.metadata?.freeResize),
run: (node, handlers) => handlers.onToggleFreeResize(node),
},
{
id: "annotation",
defaultVisible: true,
panelLabel: "标注",
label: "标注",
title: "在图片上绘制标记并保存为新节点",
icon: () => <PencilLine className="size-3.5" />,
run: (node, handlers) => handlers.onAnnotate(node),
},
{
id: "maskEdit",
defaultVisible: true,
panelLabel: "局部编辑",
label: "局部编辑",
title: "添加蒙版遮罩后局部修改",
icon: () => <Brush className="size-3.5" />,
run: (node, handlers) => handlers.onMaskEdit(node),
},
{
id: "emotion",
defaultVisible: true,
panelLabel: "表情与情绪",
label: "情绪",
title: "调整人物表情与情绪",
icon: () => <Smile className="size-3.5" />,
run: (node, handlers) => handlers.onEmotion(node),
},
{
id: "portraitTexture",
defaultVisible: true,
panelLabel: "人物质感调节",
label: "人物质感",
title: "调节人景融合、光影、皮肤、纹理与锐度",
icon: () => <SlidersHorizontal className="size-3.5" />,
run: (node, handlers) => handlers.onPortraitTexture(node),
},
{
id: "crop",
defaultVisible: true,
panelLabel: "剪裁",
label: "剪裁",
title: "剪裁并生成新节点",
icon: () => <Scissors className="size-3.5" />,
run: (node, handlers) => handlers.onCrop(node),
},
{
id: "split",
defaultVisible: true,
panelLabel: "切图",
label: "切图",
title: "按行列切分图片",
icon: () => <Grid2x2 className="size-3.5" />,
run: (node, handlers) => handlers.onSplit(node),
},
{
id: "upscale",
defaultVisible: true,
panelLabel: "放大",
label: "放大",
title: "放大图片分辨率",
icon: () => <ZoomIn className="size-3.5" />,
run: (node, handlers) => handlers.onUpscale(node),
},
{
id: "superResolve",
defaultVisible: false,
panelLabel: "超分",
label: "超分",
title: "AI 超分",
icon: () => <Sparkles className="size-3.5" />,
run: (node, handlers) => handlers.onSuperResolve(node),
},
{
id: "angle",
defaultVisible: true,
panelLabel: "多视角",
label: "多视角",
title: "生成不同观察视角",
icon: () => <Camera className="size-3.5" />,
run: (node, handlers) => handlers.onAngle(node),
},
{
id: "view",
defaultVisible: true,
panelLabel: "查看大图",
label: "查看大图",
title: "查看图片详情",
icon: () => <Maximize2 className="size-3.5" />,
run: (node, handlers) => handlers.onViewImage(node),
},
];
export const defaultImageQuickToolIds: ImageQuickToolId[] = ["info", "download", "maskEdit", "emotion", "portraitTexture", "crop", "angle"];
export function isImageQuickToolId(value: string): value is ImageQuickToolId {
return defaultBaseToolIds.some((id) => id === value) || imageToolDefinitions.some((tool) => tool.id === value);
}
export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) {
return imageToolDefinitions.map((tool) => ({
id: tool.id,
label: resolveToolText(tool.label, node),
title: resolveToolText(tool.title, node),
icon: tool.icon(node),
active: tool.active?.(node),
onClick: () => tool.run(node, handlers),
}));
}
export function normalizeImageQuickToolIds(value: unknown[]) {
const allIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.map((tool) => tool.id)];
const ids = new Set(allIds);
return allIds.filter((id) => value.includes(id) && ids.has(id));
}
export function readImageQuickToolsConfig(value: unknown): ImageQuickToolId[] {
return Array.isArray(value) ? normalizeImageQuickToolIds(value).slice(0, 7) : defaultImageQuickToolIds;
}
function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) {
return typeof value === "function" ? value(node) : value;
}

View File

@@ -0,0 +1,513 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Square, Video } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
type CanvasNodeContentProps = {
node: CanvasNodeData;
theme: CanvasTheme;
isEditingContent: boolean;
textareaRef: RefObject<HTMLTextAreaElement | null>;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
drawingProjectId?: string;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
onRetry?: (node: CanvasNodeData) => void;
onCancelTask?: (node: CanvasNodeData) => void;
onOpenTaskDetails?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
const hasCustomContent = props.node.type === CanvasNodeType.Config
|| props.node.type === CanvasNodeType.Script
|| Boolean(props.node.metadata?.directorSceneId)
|| (props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId))
|| (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent)
|| (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content);
if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onCancelTask={props.onCancelTask} onOpenTaskDetails={props.onOpenTaskDetails} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
}
const nodeContentRenderers = {
[CanvasNodeType.Text]: TextContent,
[CanvasNodeType.Script]: UnknownNodeContent,
[CanvasNodeType.Skill]: SkillContent,
[CanvasNodeType.Image]: ImageNodeContent,
[CanvasNodeType.Config]: EmptyImageContent,
[CanvasNodeType.Video]: VideoNodeContent,
[CanvasNodeType.Audio]: AudioNodeContent,
[CanvasNodeType.Drawing]: DrawingContent,
[CanvasNodeType.Frame]: UnknownNodeContent,
} satisfies Record<CanvasNodeType, (props: CanvasNodeContentProps) => ReactNode>;
function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) {
const shapeCount = node.metadata?.drawingShapeCount || 0;
const pageCount = node.metadata?.drawingPageCount || 1;
const [previewUrl, setPreviewUrl] = useState(node.metadata?.drawingPreviewUrl || "");
useEffect(() => {
const drawingId = node.metadata?.drawingId;
const fallbackPreview = node.metadata?.drawingPreviewUrl || "";
setPreviewUrl(fallbackPreview);
if (!drawingProjectId || !drawingId) return;
let active = true;
let objectUrl = "";
void loadCanvasDrawingPreview(drawingProjectId, drawingId).then((preview) => {
if (!active) return;
if (!preview) {
setPreviewUrl(fallbackPreview);
return;
}
objectUrl = URL.createObjectURL(preview);
setPreviewUrl(objectUrl);
}).catch((error) => console.warn("读取绘图节点预览失败", error));
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [drawingProjectId, node.metadata?.drawingId, node.metadata?.drawingPreviewUrl, node.metadata?.drawingRevision]);
return (
<div className="relative h-full w-full overflow-hidden" style={{ background: theme.node.panel, color: theme.node.text }}>
{previewUrl ? (
<img src={previewUrl} alt="绘图预览" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
) : (
<div className="absolute inset-0 grid place-items-center" style={{ backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
<span className="grid size-12 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, color: theme.node.muted }}>
<Pencil className="size-5" />
</span>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>
<div className="min-w-0">
<div className="truncate text-xs font-semibold" title={node.title || "绘图"}>{node.title || "绘图"}</div>
<div className="mt-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{shapeCount} 个图形 · {pageCount} 个页面</div>
</div>
<Pencil className="size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
</div>
</div>
);
}
function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<CanvasNodeContentProps, "node" | "theme" | "onCancelTask" | "onOpenTaskDetails">) {
const taskId = node.metadata?.taskId;
const progress = typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null;
const statusLabel = taskStatusLabel(node.metadata?.taskStatus);
const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt);
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2.5 px-5 text-center" style={{ color: theme.node.activeStroke }}>
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
<span className="text-[var(--fs-tiny)] font-semibold">{node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")}</span>
{taskId ? (
<div className="flex w-full max-w-[210px] flex-col items-center gap-1.5">
<div className="max-w-full truncate text-[var(--fs-label)] font-medium" style={{ color: theme.node.text }}>
{statusLabel}
{progress !== null ? ` · ${progress}%` : ""}
</div>
{progress !== null ? (
<div className="h-1.5 w-full overflow-hidden rounded-full" style={{ background: theme.node.stroke }}>
<div className="h-full rounded-full transition-[width]" style={{ width: `${progress}%`, background: theme.node.activeStroke }} />
</div>
) : null}
<div className="max-w-full truncate text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.muted }}>
<Clock3 className="mr-1 inline size-3" />{elapsed} · {shortTaskId(taskId)}
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors" style={{ background: theme.toolbar.itemHover, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpenTaskDetails?.(node); }}><FileText className="size-3" />详情</button>
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors" style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCancelTask?.(node); }}><Square className="size-2.5 fill-current" />取消</button>
</div>
</div>
) : null}
</div>
);
}
function useTaskElapsed(createdAt?: string) {
const [, setTick] = useState(0);
useEffect(() => {
if (!createdAt) return;
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer);
}, [createdAt]);
if (!createdAt) return "刚刚";
const seconds = Math.max(0, Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000));
if (seconds < 60) return `${seconds}秒`;
const minutes = Math.floor(seconds / 60);
return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`;
}
function taskStatusLabel(status?: string) {
if (status === "queued") return "排队中";
if (status === "running") return "生成中";
if (status === "succeeded") return "任务已完成";
if (status === "failed") return "任务失败";
if (status === "cancelled") return "任务已取消";
return status ? "未知任务状态" : "等待任务状态";
}
function shortTaskId(id: string) {
if (id.length <= 20) return id;
return `${id.slice(0, 14)}...${id.slice(-4)}`;
}
function ErrorContent({ node, theme, onRetry }: Pick<CanvasNodeContentProps, "node" | "theme" | "onRetry">) {
const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{generationErrorMessage(node.metadata?.errorDetails)}</div>
{moderationFailure ? (
<div className="rounded-[var(--r-sm)] px-3 py-2 text-[var(--fs-label)] leading-4" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
修改节点提示词后,可重新点击生成。
</div>
) : (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--r-md)] px-3 text-xs font-medium transition-colors"
style={{ background: theme.toolbar.itemHover, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onRetry?.(node);
}}
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
重试
</button>
)}
</div>
);
}
function UnknownNodeContent({ theme }: Pick<CanvasNodeContentProps, "theme">) {
return <div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>未知节点</div>;
}
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) {
const fontSize = node.metadata?.fontSize || 14;
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as CSSProperties;
const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]);
return (
<div className="flex h-full w-full flex-col overflow-hidden pt-10">
{isEditingContent ? (
<CanvasResourceMentionTextarea
ref={textareaRef}
className="thin-scrollbar m-0 block h-full w-full resize-none appearance-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent px-4 pb-4 pt-0 font-mono outline-none select-text"
style={textStyle}
value={node.metadata?.content || ""}
references={mentionReferences}
highlightLabels={false}
onChange={(value) => onContentChange(node.id, value)}
onBlur={onStopEditing}
onKeyDown={(event) => {
if (event.key === "Escape") onStopEditing();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
/>
) : richTextHTML ? (
<div
className="thin-scrollbar block h-full w-full overflow-y-auto break-words bg-transparent px-4 pb-4 font-mono [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-70 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 dark:[&_code]:bg-white/8 [&_h1]:my-2 [&_h1]:text-[1.55em] [&_h1]:font-semibold [&_h2]:my-2 [&_h2]:text-[1.3em] [&_h2]:font-semibold [&_h3]:my-1.5 [&_h3]:text-[1.12em] [&_h3]:font-semibold [&_hr]:my-3 [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-black/90 [&_pre]:p-2 [&_pre]:text-white [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5"
style={textStyle}
onWheel={(event) => event.stopPropagation()}
dangerouslySetInnerHTML={{ __html: richTextHTML }}
/>
) : (
<div className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-4 pb-4 pt-0 font-mono" style={textStyle} onWheel={(event) => event.stopPropagation()}>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
</div>
)}
</div>
);
}
function SkillContent({ node, theme }: CanvasNodeContentProps) {
const skill = node.metadata?.skillSnapshot;
const tags = skill?.tags?.slice(0, 4) || [];
const template = skill?.template || node.metadata?.content || "";
return (
<div className="flex h-full w-full flex-col overflow-hidden p-4" style={{ color: theme.node.text }}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: `${theme.node.activeStroke}18`, color: theme.node.activeStroke }}>
<BookOpenCheck className="size-4" />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold" title={skill?.name || node.title || "技能"}>{skill?.name || node.title || "技能"}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
<span>{skillCategoryLabel(skill?.category)}</span><span>·</span><span>{skillOutputModeLabel(skill?.outputMode)}</span>
{skill?.version ? <><span>·</span><span>v{skill.version}</span></> : null}
</div>
</div>
</div>
</div>
</div>
{skill?.description ? <div className="mt-3 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>{skill.description}</div> : null}
<div className="thin-scrollbar mt-3 min-h-0 flex-1 overflow-hidden rounded-[var(--r-md)] px-3 py-2 text-xs leading-5" style={{ background: theme.node.panel, color: theme.node.text }}>
<div className="mb-1 font-semibold opacity-55">模板</div>
<div className="line-clamp-4 whitespace-pre-wrap break-words">{template || "未配置技能模板"}</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{tags.length ? tags.map((tag) => <span key={tag} className="rounded-[var(--r-sm)] bg-black/5 px-1.5 py-0.5 text-[var(--fs-tiny)] dark:bg-white/6" style={{ color: theme.node.muted }}>{tag}</span>) : <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>连接到图片、视频、音频或文本节点后生效</span>}
</div>
</div>
);
}
function skillCategoryLabel(category?: string) {
if (category === "writing") return "剧情";
if (category === "storyboard") return "分镜";
if (category === "image") return "生图";
if (category === "video") return "视频";
return "通用";
}
function skillOutputModeLabel(mode?: string) {
if (mode === "json") return "JSON";
if (mode === "image_prompt") return "生图提示词";
if (mode === "workflow") return "工作流";
return "文本";
}
function ImageNodeContent(props: CanvasNodeContentProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const content = props.node.metadata?.status === "loading"
? <LoadingContent node={props.node} theme={props.theme} />
: props.node.metadata?.status === "error"
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
: <EmptyImageContent {...props} isBatchRoot={false} />;
return <BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} theme={props.theme} onToggleBatch={props.onToggleBatch}>{content}</BatchFrame>;
}
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
return <ImageContent node={props.node} theme={props.theme} isBatchRoot={props.isBatchRoot} batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch} />;
}
function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) {
const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi";
const content = (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<div className="flex size-14 items-center justify-center rounded-[var(--r-lg)]" style={{ background: theme.toolbar.activeBg }}><ImageIcon className="size-6 opacity-30" /></div>
{isCharacterReference ? (
<div className="max-w-[80%] text-center">
<div className="truncate text-xs font-medium" title={node.metadata?.characterName || node.title} style={{ color: theme.node.muted }}>{node.metadata?.characterName || node.title}</div>
<div className="mt-1 text-[var(--fs-tiny)] tracking-[0.12em] opacity-50">多视角参考 · 待生成</div>
</div>
) : <span className="text-[var(--fs-tiny)] tracking-[0.18em] opacity-50">空图片节点</span>}
</div>
);
if (isBatchRoot) return <BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>{content}</BatchFrame>;
return content;
}
function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContentProps) {
const playWhenReadyRef = useRef(false);
const playerBoxRef = useRef<HTMLDivElement>(null);
const { url, loading, load } = useNodeResourceUrl(node, false);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const [currentTimeMs, setCurrentTimeMs] = useState(0);
const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
const box = playerBoxRef.current;
const video = box?.querySelector("video");
if (!video) return;
const handleLoadedMetadata = () => {
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight });
};
video.addEventListener("loadedmetadata", handleLoadedMetadata);
handleLoadedMetadata();
if (!subtitleEntries.length) return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata);
const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000));
video.addEventListener("timeupdate", handleTimeUpdate);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
};
}, [subtitleEntries.length, url]);
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
if (!url) return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height);
const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio));
const fitWidth = Math.round(fitHeight * sourceRatio);
const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs);
const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined;
return (
<div ref={playerBoxRef} className="relative flex h-full w-full items-center justify-center overflow-hidden rounded-[var(--node-radius)] bg-black">
<div className="relative" style={{ width: fitWidth, height: Math.round(fitHeight) }}>
<VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} preload={reduceMediaEffects ? "none" : "metadata"} autoPlay={playWhenReadyRef.current} onCanPlay={() => { playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls />
{activeEntry && activeEntry.text.trim() ? <CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} /> : null}
</div>
</div>
);
}
function AudioNodeContent({ node, theme }: CanvasNodeContentProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const playWhenReadyRef = useRef(false);
const { url, loading, load } = useNodeResourceUrl(node, false);
useEffect(() => {
if (!url || !playWhenReadyRef.current) return;
playWhenReadyRef.current = false;
void audioRef.current?.play().catch(() => undefined);
}, [url]);
if (!node.metadata?.content) return <EmptyMediaContent icon={<Music2 className="size-7 opacity-35" />} label="空音频节点" color={theme.node.placeholder} />;
if (!url) return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存音频" : "加载并缓存音频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
return (
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70"><Music2 className="size-4 shrink-0" /><span className="min-w-0 truncate" title={node.title || "音频"}>{node.title || "音频"}</span></div>
<audio ref={audioRef} src={url} controls preload="metadata" className="w-full" data-canvas-no-zoom />
</div>
);
}
function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; label: string; color: string }) {
return <div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color }}>{icon}<span className="text-sm">{label}</span></div>;
}
function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: Pick<CanvasNodeContentProps, "node" | "theme" | "isBatchRoot" | "batchCount" | "batchExpanded" | "batchOpening" | "batchRecovering" | "onToggleBatch">) {
const imageContainerRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(imageContainerRef);
const { url, loading } = useNodeResourceUrl(node, nearViewport);
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
{url ? <img src={url} alt={node.title} loading="lazy" decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
</div>
</BatchFrame>
);
}
function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) {
return <button type="button" data-canvas-no-zoom className="flex size-full flex-col items-center justify-center gap-2 rounded-[var(--node-radius)] bg-black text-white/75 transition-colors hover:text-white disabled:cursor-wait" disabled={disabled} onClick={(event) => { event.stopPropagation(); onClick(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}><span className="grid size-10 place-items-center rounded-full bg-white/10">{icon}</span><span className="text-xs font-medium">{label}</span></button>;
}
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
const [loading, setLoading] = useState(isRemoteResource && eager);
useEffect(() => {
let cancelled = false;
if (!isRemoteResource) {
setUrl(fallback);
setLoading(false);
return;
}
setUrl("");
setLoading(eager);
const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey);
void resolve.then((cached) => {
if (!cancelled) setUrl(cached || (eager ? fallback : ""));
}).catch(() => {
if (!cancelled && eager) setUrl(fallback);
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [eager, fallback, isRemoteResource, storageKey]);
const load = useCallback(async () => {
if (url) return url;
if (!isRemoteResource) return fallback;
setLoading(true);
try {
const next = (await cacheResourceObjectUrl(storageKey)) || fallback;
setUrl(next);
return next;
} catch {
setUrl(fallback);
return fallback;
} finally {
setLoading(false);
}
}, [fallback, isRemoteResource, storageKey, url]);
return { url, loading, load };
}
function useNearViewport(ref: RefObject<Element | null>) {
const [nearViewport, setNearViewport] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element || typeof IntersectionObserver === "undefined") {
setNearViewport(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setNearViewport(true);
observer.disconnect();
}
}, { rootMargin: "600px" });
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return nearViewport;
}
export function CanvasNodeImageInfo({ node }: { node: CanvasNodeData }) {
const width = Math.round(node.metadata?.naturalWidth || node.width);
const height = Math.round(node.metadata?.naturalHeight || node.height);
const size = formatBytes(node.metadata?.bytes || 0);
return <span className="ml-auto max-w-full shrink-0 truncate rounded-[var(--r-sm)] bg-black/55 px-2 py-1 text-[var(--fs-label)] font-medium leading-none text-white backdrop-blur-sm">{width} x {height}{size ? ` · ${size}` : ""}</span>;
}
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) {
const isBatchRoot = batchCount > 1;
return (
<div className="group/batch relative h-full w-full overflow-visible" onDoubleClick={isBatchRoot ? (event) => { event.stopPropagation(); onToggleBatch?.(); } : undefined}>
{isBatchRoot ? (
<div className="pointer-events-none absolute inset-0 overflow-visible">
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
<div
key={index}
className="absolute rounded-[inherit] transition-all duration-300 group-hover/batch:translate-x-2"
style={{
inset: 0,
background: theme.node.panel,
boxShadow: `inset 0 0 0 1px ${theme.node.stroke}`,
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
transform: batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
zIndex: -index - 1,
}}
/>
))}
</div>
) : null}
{children}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react";
import { Button, Image as AntImage, Modal, Tooltip } from "antd";
@@ -38,6 +38,15 @@ type CanvasNodePromptPanelProps = {
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
const PROMPT_REFERENCE_SHELF_HEIGHT = 36;
const PROMPT_EDITOR_MIN_HEIGHT = 44;
const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 76;
const PROMPT_EDITOR_LINE_HEIGHT = 20;
const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24;
const PROMPT_EDITOR_VERTICAL_PADDING = 12;
const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20;
const PROMPT_EDITOR_MAX_LINES = 8;
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const themeName = useThemeStore((state) => state.theme);
@@ -52,7 +61,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const [presetOpen, setPresetOpen] = useState(false);
const [expandedPresetOpen, setExpandedPresetOpen] = useState(false);
const [expandedPromptOpen, setExpandedPromptOpen] = useState(false);
const [promptContentHeight, setPromptContentHeight] = useState(0);
const [promptContentHeight, setPromptContentHeight] = useState(PROMPT_EDITOR_MIN_HEIGHT);
const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(PROMPT_EDITOR_EXPANDED_MIN_HEIGHT);
const [manualPromptHeight, setManualPromptHeight] = useState<number | null>(null);
const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState<number | null>(null);
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
const activeReferences = mentionReferences.filter((item) => item.active && item.kind !== "skill");
const requirements: ModelRequirements = {
@@ -79,36 +91,27 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
});
const activeReferenceCount = activeReferences.length;
const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const darkSurface = themeName === "dark";
const monochromeAccent = theme.node.activeStroke;
const shellBorder = darkSurface ? "rgba(255,255,255,.08)" : "rgba(15,23,42,.08)";
const insetBorder = darkSurface ? "rgba(255,255,255,.06)" : "rgba(15,23,42,.07)";
const shellSurface = darkSurface ? theme.canvas.background : "rgba(250,251,252,.97)";
const composerSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.025)";
const controlSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.045)";
const controlsSurface = darkSurface ? controlSurface : "transparent";
const shellShadow = darkSurface ? `0 22px 60px ${theme.spatial.shadow}, 0 2px 8px rgba(0,0,0,.22)` : "0 12px 32px rgba(15,23,42,.10), 0 1px 2px rgba(15,23,42,.06)";
const composerShadow = darkSurface ? "inset 0 1px 4px rgba(0,0,0,.22)" : "inset 0 0 0 1px rgba(15,23,42,.045)";
const modalShadow = darkSurface ? `0 30px 90px ${theme.spatial.shadow}` : "0 24px 72px rgba(15,23,42,.16)";
const referenceShelfHeight = activeReferenceCount ? 42 : 0;
const composerMinHeight = activeReferenceCount ? 82 : 58;
const composerHeight = Math.min(224, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
const controlSurface = "var(--canvas-composer-control-surface)";
const promptBounds = promptEditorBounds(false, activeReferenceCount > 0);
const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0);
const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds);
const expandedComposerHeight = clampPromptHeight(manualExpandedPromptHeight ?? expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds);
const isSubmitDisabled = !isRunning && !prompt.trim();
const canExpandPrompt = mode === "image" || mode === "video";
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
const updatePromptContentHeight = useCallback((height: number) => {
setPromptContentHeight((current) => (Math.abs(current - height) < 1 ? current : height));
}, []);
useEffect(() => {
setPrompt(node.metadata?.composerContent ?? node.metadata?.prompt ?? "");
}, [node.id, node.metadata?.composerContent, node.metadata?.prompt]);
useEffect(() => setPromptContentHeight(0), [node.id]);
useEffect(() => {
setExpandedPromptOpen(false);
setExpandedPresetOpen(false);
setPromptContentHeight(PROMPT_EDITOR_MIN_HEIGHT);
setExpandedPromptContentHeight(PROMPT_EDITOR_EXPANDED_MIN_HEIGHT);
setManualPromptHeight(null);
setManualExpandedPromptHeight(null);
}, [node.id]);
const skillReferences = useMemo(() => mentionReferences.filter((item) => item.kind === "skill"), [mentionReferences]);
@@ -154,11 +157,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
};
const renderComposerHeader = (expanded: boolean) => (
<div className="flex min-w-0 items-center gap-1 px-0.5">
<div className="canvas-node-composer-header">
{isPortraitTexture ? (
<CanvasPortraitTexturePopover value={node.metadata?.portraitTexture} placement={expanded ? "topRight" : "topLeft"} onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })} />
) : (
<div className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5 transition-colors hover:bg-white/[.04]" style={{ background: controlSurface }}>
<div className="canvas-node-composer-mode">
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: monochromeAccent }}>
<GenerationModeIcon mode={mode} />
</span>
@@ -167,7 +170,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
)}
{!simpleMode ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense /> : null}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
{activeReferenceCount ? <ComposerPill theme={theme} borderColor={insetBorder} icon={<Boxes className="size-2.5" />} label={`已连接 ${activeReferenceCount} 个`} /> : null}
{activeReferenceCount ? <ComposerPill theme={theme} icon={<Boxes className="size-2.5" />} label={`参考 ${activeReferenceCount}`} /> : null}
{!expanded && canExpandPrompt ? (
<Tooltip title="放大编辑">
<button
@@ -185,32 +188,51 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</div>
);
const renderSubmitButton = (expanded: boolean) => {
const showCost = creditsEnabled && credits !== null;
const formattedCredits = credits?.toLocaleString();
const actionLabel = isRunning ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成";
return (
<Button
type="text"
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
danger={isRunning}
disabled={isSubmitDisabled}
style={
{
color: isSubmitDisabled ? theme.node.faint : theme.node.text,
"--canvas-composer-submit-action": isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent,
"--canvas-composer-submit-action-fg": isSubmitDisabled ? theme.node.faint : theme.canvas.background,
} as CSSProperties
}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={actionLabel}
title={actionLabel}
>
{showCost ? (
<span className="canvas-node-composer-submit-cost">
<CreditSymbol />
<span>{formattedCredits}</span>
</span>
) : null}
<span className="canvas-node-composer-submit-action" aria-hidden>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
</span>
</Button>
);
};
const renderComposerControls = (expanded: boolean) =>
simpleMode ? (
<div className="flex min-w-0 items-center justify-between gap-2 p-1" style={{ background: controlsSurface }}>
<div className="canvas-node-composer-footer">
<span className="min-w-0 truncate px-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
</span>
<Button
type="text"
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-full !px-2.5 !text-[var(--fs-tiny)] !font-medium"
danger={isRunning}
disabled={isSubmitDisabled}
style={{
background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent,
color: isSubmitDisabled ? theme.node.faint : isRunning ? "#ffffff" : theme.canvas.background,
boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)`,
}}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
{isRunning ? "停止" : "生成"}
</Button>
{renderSubmitButton(expanded)}
</div>
) : (
<div className="flex min-w-0 items-center justify-between gap-0.5 p-1" style={{ background: controlsSurface }}>
<div className={`${expanded ? "max-w-[var(--panel-width-compact)]" : mode === "image" || mode === "video" ? "max-w-[240px]" : "max-w-[174px]"} min-w-[104px] flex-1`}>
<div className="canvas-node-composer-footer">
<div className={expanded ? "min-w-0 flex-1" : "canvas-node-composer-model"}>
<ModelPicker
className="!h-7 !w-full !min-w-0 !text-[var(--fs-tiny)] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3"
fullWidth
@@ -221,6 +243,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
requirements={requirements}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
showSelectedPrice={false}
variant="creation"
/>
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
@@ -228,7 +251,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<CanvasImageSettingsPopover
config={config}
placement={expanded ? "topRight" : "topLeft"}
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
buttonClassName="canvas-node-composer-settings-trigger [&>span]:min-w-0 [&_.lucide]:!size-3"
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
@@ -236,67 +259,66 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
) : mode === "video" ? (
<CanvasVideoSettingsPopover
config={config}
buttonClassName="!h-7 !w-[144px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
buttonClassName="canvas-node-composer-settings-trigger [&>span]:min-w-0 [&_.lucide]:!size-3"
onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))}
/>
) : mode === "audio" ? (
<CanvasAudioSettingsPopover
config={config}
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
buttonClassName="canvas-node-composer-settings-trigger [&>span]:min-w-0 [&_.lucide]:!size-3"
onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))}
/>
) : null}
{creditsEnabled ? <GenerationCostBadge credits={credits} theme={theme} /> : null}
<Button
type="text"
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-full !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
danger={isRunning}
disabled={isSubmitDisabled}
style={{
background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent,
borderColor: isSubmitDisabled ? insetBorder : monochromeAccent,
color: isSubmitDisabled ? theme.node.faint : theme.canvas.background,
boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)`,
}}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
</Button>
{renderSubmitButton(expanded)}
</div>
</div>
);
const renderPromptEditor = (expanded: boolean) => {
const bounds = expanded ? expandedPromptBounds : promptBounds;
const height = expanded ? expandedComposerHeight : composerHeight;
return (
<>
<div className="canvas-node-composer-editor" style={{ height }}>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight}
containerClassName="min-h-0 flex-1"
className={expanded
? "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[var(--fs-body-lg)] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
: "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-1.5 text-[var(--fs-body)] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"}
style={{ color: theme.node.text, outline: "none", boxShadow: "none" }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
aria-label={`${modeDisplayName(mode)}提示词`}
/>
</div>
<PromptResizeHandle
height={height}
min={bounds.min}
max={bounds.max}
onResize={expanded ? setManualExpandedPromptHeight : setManualPromptHeight}
/>
</>
);
};
return (
<div
className="aceternity-floating-panel relative overflow-hidden rounded-xl border p-2"
style={{ background: shellSurface, borderColor: shellBorder, color: theme.node.text, boxShadow: shellShadow }}
className="canvas-node-composer"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{renderComposerHeader(false)}
<div
className="relative mt-2 flex max-h-[var(--prompt-panel-input-max-height)] flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
style={{ height: composerHeight, background: composerSurface, boxShadow: composerShadow }}
>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
containerClassName="min-h-0 flex-1"
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-2 text-[var(--fs-body)] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
style={{ color: theme.node.text, outline: "none", boxShadow: "none" }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
onContentSizeChange={updatePromptContentHeight}
/>
</div>
{renderPromptEditor(false)}
{/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/}
{mode === "video" && !simpleMode ? (
<div className="mt-1.5 overflow-hidden rounded-md border" style={{ background: controlSurface, borderColor: insetBorder }}>
<div className="canvas-node-composer-parameters overflow-hidden">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1 text-[var(--fs-micro)] font-medium transition-colors hover:bg-white/[.04]"
@@ -310,14 +332,14 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<ChevronDown className={`size-3 transition-transform duration-200 ${paramsExpanded ? "rotate-180" : ""}`} strokeWidth={1.8} />
</button>
{paramsExpanded ? (
<div className="border-t p-0.5" style={{ borderColor: insetBorder }}>
<div className="pt-1">
<CanvasVideoPromptTools metadata={node.metadata} frameOptions={videoFrameOptions} onMetadataChange={(patch) => onConfigChange(node.id, patch)} />
</div>
) : null}
</div>
) : null}
<div className="mt-1.5">{renderComposerControls(false)}</div>
{renderComposerControls(false)}
<Modal
className="canvas-prompt-editor-modal"
@@ -325,34 +347,22 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
title={null}
footer={null}
centered
width={760}
width={920}
destroyOnHidden
onCancel={() => {
setExpandedPresetOpen(false);
setExpandedPromptOpen(false);
}}
styles={{
container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow },
body: { minHeight: 0, flex: 1, padding: 0 },
container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: "var(--canvas-composer-surface)", boxShadow: "var(--canvas-composer-shadow)" },
body: { minHeight: 0, padding: 0 },
}}
>
<div className="flex h-full min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
<div className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-xl outline-none ring-0 focus-within:outline-none focus-within:ring-0" style={{ background: composerSurface, boxShadow: composerShadow }}>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
containerClassName="min-h-0 flex-1"
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[var(--fs-body-lg)] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
style={{ color: theme.node.text, outline: "none", boxShadow: "none" }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
aria-label={`${modeDisplayName(mode)}提示词`}
/>
</div>
{renderPromptEditor(true)}
{mode === "video" && !simpleMode ? (
<div className="shrink-0 rounded-md border p-0.5" style={{ background: controlSurface, borderColor: insetBorder }}>
<div className="canvas-node-composer-parameters shrink-0">
<CanvasVideoPromptTools metadata={node.metadata} frameOptions={videoFrameOptions} onMetadataChange={(patch) => onConfigChange(node.id, patch)} />
</div>
) : null}
@@ -363,9 +373,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
);
}
function ComposerPill({ theme, borderColor, icon, label }: { theme: CanvasTheme; borderColor: string; icon: ReactNode; label: string }) {
function ComposerPill({ theme, icon, label }: { theme: CanvasTheme; icon: ReactNode; label: string }) {
return (
<span className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border px-1.5 text-[var(--fs-micro)] font-medium transition hover:brightness-125" style={{ background: theme.canvas.background, borderColor, color: theme.node.activeStroke }}>
<span className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[var(--r-sm)] px-1.5 text-[var(--fs-micro)] font-medium" style={{ background: "var(--canvas-composer-control-surface)", color: theme.node.activeStroke }}>
{icon}
{label}
</span>
@@ -393,46 +403,25 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references:
return (
<>
<div className="thin-scrollbar flex h-[42px] shrink-0 min-w-0 items-center gap-1.5 overflow-x-auto px-2.5 pt-1.5" role="group" aria-label="已连接素材">
{activeReferences.map((reference, index) => {
<div className="canvas-node-composer-references thin-scrollbar" role="group" aria-label="已连接素材">
{activeReferences.map((reference) => {
const canPreview = (reference.kind === "image" || reference.kind === "character") && Boolean(reference.previewUrl);
return (
<span key={reference.id} className="relative size-[34px] shrink-0">
<span key={reference.id} className="canvas-node-reference-chip">
<button
type="button"
className={`group relative size-full overflow-hidden rounded-md text-left transition hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0${canPreview ? " cursor-zoom-in" : ""}`}
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.node.activeStroke, boxShadow: `0 4px 14px ${theme.spatial.shadow}` }}
className="canvas-node-reference-preview"
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.node.activeStroke }}
title={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
aria-label={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
onClick={() => (canPreview ? setImagePreview(reference) : onInsert(reference))}
>
<span className="block size-full overflow-hidden rounded-md">
<ReferenceThumbnail reference={reference} />
</span>
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[var(--fs-micro)] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
{canPreview ? (
<span className="absolute bottom-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm">
<Maximize2 className="size-2" />
</span>
) : null}
{!canPreview ? (
<span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm">
<AtSign className="size-2" />
</span>
) : null}
<ReferenceThumbnail reference={reference} />
</button>
<button type="button" className="canvas-node-reference-label" title={`插入 @${reference.label}`} onClick={() => onInsert(reference)}>
<AtSign className="size-2.5" />
<span>{reference.label}</span>
</button>
{canPreview ? (
<button
type="button"
className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm transition hover:bg-black/85 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1"
style={{ outlineColor: theme.node.activeStroke }}
title={`插入 @${reference.label}`}
aria-label={`插入 @${reference.label}`}
onClick={() => onInsert(reference)}
>
<AtSign className="size-2" />
</button>
) : null}
</span>
);
})}
@@ -469,21 +458,73 @@ function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference
);
}
function GenerationCostBadge({ credits, theme }: { credits: number | null; theme: CanvasTheme }) {
if (credits === null) return null;
function PromptResizeHandle({ height, min, max, onResize }: { height: number; min: number; max: number; onResize: (height: number) => void }) {
const dragRef = useRef<{ pointerId: number; startY: number; startHeight: number } | null>(null);
const finishResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
dragRef.current = null;
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowUp") {
event.preventDefault();
onResize(Math.max(min, height - 8));
} else if (event.key === "ArrowDown") {
event.preventDefault();
onResize(Math.min(max, height + 8));
} else if (event.key === "Home") {
event.preventDefault();
onResize(min);
} else if (event.key === "End") {
event.preventDefault();
onResize(max);
}
};
return (
<span
className="canvas-generation-cost-badge inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-[var(--fs-micro)] font-semibold tabular-nums"
style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}
title={`预计消耗 ${credits.toLocaleString()} 积分`}
aria-label={`预计消耗 ${credits.toLocaleString()} 积分`}
<button
type="button"
className="canvas-node-composer-resize-handle"
role="separator"
aria-label="调整提示词输入高度"
aria-orientation="horizontal"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={Math.round(height)}
onKeyDown={handleKeyDown}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
dragRef.current = { pointerId: event.pointerId, startY: event.clientY, startHeight: height };
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
onResize(Math.min(max, Math.max(min, drag.startHeight + event.clientY - drag.startY)));
}}
onPointerUp={finishResize}
onPointerCancel={finishResize}
>
<CreditSymbol className="text-[var(--fs-label)]" />
{credits.toLocaleString()}
</span>
<span aria-hidden />
</button>
);
}
function promptEditorBounds(expanded: boolean, hasReferences: boolean) {
const shelfHeight = hasReferences ? PROMPT_REFERENCE_SHELF_HEIGHT : 0;
const min = (expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT) + shelfHeight;
const max = (expanded ? PROMPT_EDITOR_EXPANDED_LINE_HEIGHT * PROMPT_EDITOR_MAX_LINES + PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING : PROMPT_EDITOR_LINE_HEIGHT * PROMPT_EDITOR_MAX_LINES + PROMPT_EDITOR_VERTICAL_PADDING) + shelfHeight;
return { min, max };
}
function clampPromptHeight(height: number, bounds: { min: number; max: number }) {
return Math.min(bounds.max, Math.max(bounds.min, height));
}
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
return type === CanvasNodeType.Text || type === CanvasNodeType.Skill ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image";
}

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>
);
}

View File

@@ -1,25 +1,15 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Clock3, FileText, Image as ImageIcon, LoaderCircle, Lock, Maximize2, Music2, Pencil, Play, Plus, RefreshCw, Replace, Settings2, Square, Star, Type, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { CometCard } from "@/components/ui/aceternity/comet-card";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { formatBytes } from "@/lib/image-utils";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { useThemeStore } from "@/stores/use-theme-store";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { storyboardMinNodeHeight } from "./canvas-script-node";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { VideoPlayer } from "@/components/video-player";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
@@ -68,29 +58,6 @@ type CanvasNodeProps = {
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
};
type NodeContentRendererProps = {
node: CanvasNodeData;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
isEditingContent: boolean;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
drawingProjectId?: string;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
onRetry?: (node: CanvasNodeData) => void;
onCancelTask?: (node: CanvasNodeData) => void;
onOpenTaskDetails?: (node: CanvasNodeData) => void;
onOpenDrawing?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
};
export const CanvasNode = React.memo(function CanvasNode({
data,
dragOffset,
@@ -142,16 +109,14 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
const isGeneratingNode = data.type !== CanvasNodeType.Frame && data.metadata?.status === "loading";
const hasReplaceAction = !readOnly && (data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video);
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const flushMediaContent = hasImageContent || hasVideoContent;
const mediaBorderColor = isActive ? theme.accent.primary : isRelated && !isBatchChild ? theme.accent.primary : "transparent";
const nodeState = isFocusRelated ? "focus" : isConnectionTarget ? "target" : isSelected ? "selected" : isRelated && !isBatchChild ? "related" : "idle";
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null;
const nodeHoverLocked = reduceMediaEffects || Boolean(dragOffset) || isEditingContent || isEditingTitle || isGeneratingNode || scale < 0.32 || batchClosing || batchOpening;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeRef = useRef({
isResizing: false,
@@ -318,23 +283,12 @@ export const CanvasNode = React.memo(function CanvasNode({
onCommit={commitTitle}
onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }}
/>
{/* 画布节点不启用指针跟随 3D 位移,hover 反馈统一走 CSS 静态抬升,避免鼠标移动形成反馈震荡 */}
<CometCard
containerClassName="overflow-visible"
className={`canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)] ${flushMediaContent ? "border-0" : "border"} ${isGeneratingNode ? "canvas-node-shell-generating" : ""}`}
disabled
data-canvas-node-hover-locked={nodeHoverLocked ? "true" : "false"}
<div
className="canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)]"
data-node-state={nodeState}
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
style={{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
borderColor: flushMediaContent ? undefined : isActive ? theme.accent.primary : isRelated ? theme.accent.primary : hovered ? "color-mix(in oklch, var(--workspace-border) 80%, var(--workspace-fg) 12%)" : theme.node.stroke,
boxShadow: isFocusRelated
? `0 0 0 1px ${theme.accent.primary}66, 0 0 0 6px ${theme.accent.primary}1a, 0 28px 80px ${theme.spatial.shadow}` // active:最强强调(外发光环6px + inner glow)
: isConnectionTarget
? `0 0 0 1px ${theme.accent.primary}66, 0 0 0 4px ${theme.accent.primary}1a, 0 24px 72px ${theme.spatial.shadow}` // selected-primary:4px软色环 + resize handle
: isSelected || (isRelated && !isBatchChild)
? `0 0 0 2px ${theme.accent.primary}40, 0 22px 60px ${theme.spatial.shadow}` // selected:2px边框环
: undefined, // idle:无额外阴影
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -372,7 +326,7 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -385,7 +339,7 @@ export const CanvasNode = React.memo(function CanvasNode({
{data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? (
<NodeStatusBadge status={data.metadata.status} />
) : null}
<NodeContent
<CanvasNodeContent
node={data}
theme={theme}
isEditingContent={isEditingContent}
@@ -403,35 +357,26 @@ export const CanvasNode = React.memo(function CanvasNode({
onRetry={onRetry}
onCancelTask={onCancelTask}
onOpenTaskDetails={onOpenTaskDetails}
onOpenDrawing={onOpenDrawing}
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
/>
</div>
{flushMediaContent ? (
{hasReplaceAction ? (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-[var(--node-z-content)] rounded-[inherit]"
style={{ boxShadow: `inset 0 0 0 1px ${mediaBorderColor}` }}
/>
) : null}
{(hasImageContent || hasVideoContent) && !readOnly ? (
<div
className={`absolute bottom-[10%] left-1/2 z-[var(--node-z-overlay)] -translate-x-1/2 motion-safe:transition motion-safe:duration-200 ${isSelected ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"}`}
className={`absolute right-3 top-3 z-[var(--node-z-overlay)] motion-safe:transition motion-safe:duration-200 ${!hasMediaContent || isSelected || hovered ? "opacity-100" : "pointer-events-none opacity-0"}`}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border px-4 text-xs font-semibold shadow-lg backdrop-blur-xl transition hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text, outlineColor: theme.accent.primary }}
className="canvas-node-inline-action grid size-9 place-items-center p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ outlineColor: theme.accent.primary }}
onClick={(event) => { event.stopPropagation(); onReplaceMedia?.(data); }}
aria-label="替换媒体"
aria-label={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
title={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
>
<Replace className="size-3.5" />
替换
{hasMediaContent ? <Replace className="size-3.5" /> : <Upload className="size-3.5" />}
</button>
</div>
) : null}
@@ -444,8 +389,8 @@ export const CanvasNode = React.memo(function CanvasNode({
>
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border px-4 text-xs font-semibold shadow-lg backdrop-blur-xl transition hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text, outlineColor: theme.accent.primary }}
className="canvas-node-inline-action inline-flex h-9 items-center gap-2 px-3 text-xs font-medium backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ outlineColor: theme.accent.primary }}
onClick={(event) => { event.stopPropagation(); onOpenTextEditor?.(data); }}
aria-label="放大编辑文本"
>
@@ -463,7 +408,6 @@ export const CanvasNode = React.memo(function CanvasNode({
background: data.metadata.versionPrimary ? theme.accent.primarySoft : theme.toolbar.panel,
borderColor: data.metadata.versionPrimary ? theme.accent.primary : theme.toolbar.border,
color: data.metadata.versionPrimary ? theme.accent.primary : theme.node.text,
boxShadow: data.metadata.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : "var(--shadow-sm)",
outlineColor: theme.accent.primary,
}}
title={data.metadata.versionLabel + (data.metadata.versionPrimary ? " · 主版本" : "") + ",点击查看版本对比"}
@@ -475,7 +419,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</button>
) : null}
{showStatusTrack ? (
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
<div className={`absolute top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${hasReplaceAction ? "right-14" : "right-3"} ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
@@ -486,19 +430,17 @@ export const CanvasNode = React.memo(function CanvasNode({
{assetTags.length || (showImageInfo && hasImageContent) ? (
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
{showImageInfo && hasImageContent ? <CanvasNodeImageInfo node={data} /> : null}
</div>
) : null}
{!hasImageContent && !hasVideoContent && !hasAudioContent && data.type !== CanvasNodeType.Drawing ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
{!readOnly && !data.metadata?.locked ? <>
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
</> : null}
</CometCard>
</div>
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? <ConnectionSideRail side="right" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
@@ -507,291 +449,9 @@ export const CanvasNode = React.memo(function CanvasNode({
);
});
function NodeContent(props: NodeContentRendererProps) {
const hasCustomContent = props.node.type === CanvasNodeType.Config
|| props.node.type === CanvasNodeType.Script
|| Boolean(props.node.metadata?.directorSceneId)
|| (props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId))
|| (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent)
|| (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content);
if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onCancelTask={props.onCancelTask} onOpenTaskDetails={props.onOpenTaskDetails} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
}
const nodeContentRenderers = {
[CanvasNodeType.Text]: TextContent,
[CanvasNodeType.Script]: UnknownNodeContent,
[CanvasNodeType.Skill]: SkillContent,
[CanvasNodeType.Image]: ImageNodeContent,
[CanvasNodeType.Config]: EmptyImageContent,
[CanvasNodeType.Video]: VideoNodeContent,
[CanvasNodeType.Audio]: AudioNodeContent,
[CanvasNodeType.Drawing]: DrawingContent,
[CanvasNodeType.Frame]: UnknownNodeContent,
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
function DrawingContent({ node, theme, drawingProjectId }: NodeContentRendererProps) {
const shapeCount = node.metadata?.drawingShapeCount || 0;
const pageCount = node.metadata?.drawingPageCount || 1;
const [previewUrl, setPreviewUrl] = useState(node.metadata?.drawingPreviewUrl || "");
useEffect(() => {
const drawingId = node.metadata?.drawingId;
const fallbackPreview = node.metadata?.drawingPreviewUrl || "";
setPreviewUrl(fallbackPreview);
if (!drawingProjectId || !drawingId) return;
let active = true;
let objectUrl = "";
void loadCanvasDrawingPreview(drawingProjectId, drawingId).then((preview) => {
if (!active) return;
if (!preview) {
setPreviewUrl(fallbackPreview);
return;
}
objectUrl = URL.createObjectURL(preview);
setPreviewUrl(objectUrl);
}).catch((error) => console.warn("读取绘图节点预览失败", error));
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [drawingProjectId, node.metadata?.drawingId, node.metadata?.drawingPreviewUrl, node.metadata?.drawingRevision]);
return (
<div className="relative h-full w-full overflow-hidden" style={{ background: theme.node.panel, color: theme.node.text }}>
{previewUrl ? (
<img src={previewUrl} alt="绘图预览" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
) : (
<div className="absolute inset-0 grid place-items-center" style={{ backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
<span className="grid size-12 place-items-center rounded-xl border" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.muted }}>
<Pencil className="size-5" />
</span>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>
<div className="min-w-0">
<div className="truncate text-xs font-semibold" title={node.title || "绘图"}>{node.title || "绘图"}</div>
<div className="mt-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{shapeCount} 个图形 · {pageCount} 个页面</div>
</div>
<Pencil className="size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
</div>
</div>
);
}
function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<NodeContentRendererProps, "node" | "theme" | "onCancelTask" | "onOpenTaskDetails">) {
const taskId = node.metadata?.taskId;
const progress = typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null;
const statusLabel = taskStatusLabel(node.metadata?.taskStatus);
const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt);
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2.5 px-5 text-center" style={{ color: theme.node.activeStroke }}>
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
<span className="text-[var(--fs-tiny)] font-semibold">{node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")}</span>
{taskId ? (
<div className="flex w-full max-w-[210px] flex-col items-center gap-1.5">
<div className="max-w-full truncate text-[var(--fs-label)] font-medium" style={{ color: theme.node.text }}>
{statusLabel}
{progress !== null ? ` · ${progress}%` : ""}
</div>
{progress !== null ? (
<div className="h-1.5 w-full overflow-hidden rounded-full" style={{ background: theme.node.stroke }}>
<div className="h-full rounded-full transition-[width]" style={{ width: `${progress}%`, background: theme.node.activeStroke }} />
</div>
) : null}
<div className="max-w-full truncate text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.muted }}>
<Clock3 className="mr-1 inline size-3" />{elapsed} · {shortTaskId(taskId)}
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[var(--fs-tiny)] font-medium transition hover:brightness-110" style={{ background: theme.toolbar.itemHover, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpenTaskDetails?.(node); }}><FileText className="size-3" />详情</button>
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[var(--fs-tiny)] font-medium transition hover:brightness-110" style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCancelTask?.(node); }}><Square className="size-2.5 fill-current" />取消</button>
</div>
</div>
) : null}
</div>
);
}
function useTaskElapsed(createdAt?: string) {
const [, setTick] = useState(0);
useEffect(() => {
if (!createdAt) return;
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer);
}, [createdAt]);
if (!createdAt) return "刚刚";
const seconds = Math.max(0, Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000));
if (seconds < 60) return `${seconds}秒`;
const minutes = Math.floor(seconds / 60);
return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`;
}
function taskStatusLabel(status?: string) {
if (status === "queued") return "排队中";
if (status === "running") return "生成中";
if (status === "succeeded") return "任务已完成";
if (status === "failed") return "任务失败";
if (status === "cancelled") return "任务已取消";
return status ? "未知任务状态" : "等待任务状态";
}
function shortTaskId(id: string) {
if (id.length <= 20) return id;
return `${id.slice(0, 14)}...${id.slice(-4)}`;
}
function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{generationErrorMessage(node.metadata?.errorDetails)}</div>
{moderationFailure ? (
<div className="rounded-md border px-3 py-2 text-[var(--fs-label)] leading-4" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.node.muted }}>
修改节点提示词后,可重新点击生成。
</div>
) : (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onRetry?.(node);
}}
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
重试
</button>
)}
</div>
);
}
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
return (
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
未知节点
</div>
);
}
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: NodeContentRendererProps) {
const fontSize = node.metadata?.fontSize || 14;
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties;
const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]);
return (
<div className="flex h-full w-full flex-col overflow-hidden pt-10">
{isEditingContent ? (
<CanvasResourceMentionTextarea
ref={textareaRef}
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent px-4 pt-0 pb-4 m-0 font-mono outline-none select-text appearance-none"
style={textStyle}
value={node.metadata?.content || ""}
references={mentionReferences}
highlightLabels={false}
onChange={(value) => onContentChange(node.id, value)}
onBlur={onStopEditing}
onKeyDown={(event) => {
if (event.key === "Escape") onStopEditing();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
/>
) : richTextHTML ? (
<div
className="thin-scrollbar block h-full w-full overflow-y-auto break-words bg-transparent px-4 pb-4 font-mono [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-70 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 dark:[&_code]:bg-white/8 [&_h1]:my-2 [&_h1]:text-[1.55em] [&_h1]:font-semibold [&_h2]:my-2 [&_h2]:text-[1.3em] [&_h2]:font-semibold [&_h3]:my-1.5 [&_h3]:text-[1.12em] [&_h3]:font-semibold [&_hr]:my-3 [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-black/90 [&_pre]:p-2 [&_pre]:text-white [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5"
style={textStyle}
onWheel={(event) => event.stopPropagation()}
dangerouslySetInnerHTML={{ __html: richTextHTML }}
/>
) : (
<div
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-4 pt-0 pb-4 font-mono"
style={textStyle}
onWheel={(event) => event.stopPropagation()}
>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
</div>
)}
</div>
);
}
function SkillContent({ node, theme }: NodeContentRendererProps) {
const skill = node.metadata?.skillSnapshot;
const tags = skill?.tags?.slice(0, 4) || [];
const template = skill?.template || node.metadata?.content || "";
return (
<div className="flex h-full w-full flex-col overflow-hidden p-4" style={{ color: theme.node.text }}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-xl" style={{ background: `${theme.node.activeStroke}18`, color: theme.node.activeStroke }}>
<BookOpenCheck className="size-4" />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold" title={skill?.name || node.title || "技能"}>{skill?.name || node.title || "技能"}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
<span>{skillCategoryLabel(skill?.category)}</span>
<span>·</span>
<span>{skillOutputModeLabel(skill?.outputMode)}</span>
{skill?.version ? (
<>
<span>·</span>
<span>v{skill.version}</span>
</>
) : null}
</div>
</div>
</div>
</div>
</div>
{skill?.description ? <div className="mt-3 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>{skill.description}</div> : null}
<div className="thin-scrollbar mt-3 min-h-0 flex-1 overflow-hidden rounded-xl border px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.text }}>
<div className="mb-1 font-semibold opacity-55">模板</div>
<div className="line-clamp-4 whitespace-pre-wrap break-words">{template || "未配置技能模板"}</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{tags.length ? tags.map((tag) => (
<span key={tag} className="rounded-md border px-1.5 py-0.5 text-[var(--fs-tiny)]" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
{tag}
</span>
)) : <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>连接到图片、视频、音频或文本节点后生效</span>}
</div>
</div>
);
}
function skillCategoryLabel(category?: string) {
if (category === "writing") return "剧情";
if (category === "storyboard") return "分镜";
if (category === "image") return "生图";
if (category === "video") return "视频";
return "通用";
}
function skillOutputModeLabel(mode?: string) {
if (mode === "json") return "JSON";
if (mode === "image_prompt") return "生图提示词";
if (mode === "workflow") return "工作流";
return "文本";
}
function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) {
return (
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none shadow-sm" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", color: reference.active ? theme.accent.onPrimary : "#ffffff", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", color: reference.active ? theme.accent.onPrimary : "#ffffff", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
{reference.label}
</span>
);
@@ -801,7 +461,7 @@ function ResourceStorageBadge({ storageKey, active, theme }: { storageKey?: stri
const location = resourceStorageLocation(storageKey);
const background = active ? (location === "local" ? "rgba(245,158,11,.9)" : theme.accent.primary) : "rgba(0,0,0,.35)";
return (
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none shadow-sm" style={{ background, color: active && location !== "local" ? theme.accent.onPrimary : "#ffffff", opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none" style={{ background, color: active && location !== "local" ? theme.accent.onPrimary : "#ffffff", opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
{resourceStorageLabel(storageKey)}
</span>
);
@@ -845,339 +505,6 @@ function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvas
);
}
function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const content =
props.node.metadata?.status === "loading" ? (
<LoadingContent node={props.node} theme={props.theme} />
) : props.node.metadata?.status === "error" ? (
<ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
) : (
<EmptyImageContent {...props} isBatchRoot={false} />
);
return (
<BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} theme={props.theme} onToggleBatch={props.onToggleBatch}>
{content}
</BatchFrame>
);
}
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
return (
<ImageContent
node={props.node}
theme={props.theme}
isBatchRoot={props.isBatchRoot}
batchCount={props.batchCount}
batchExpanded={props.batchExpanded}
batchOpening={props.batchOpening}
batchRecovering={props.batchRecovering}
onToggleBatch={props.onToggleBatch}
/>
);
}
function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi";
const content = (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
<ImageIcon className="size-6 opacity-30" />
</div>
{isCharacterReference ? (
<div className="max-w-[80%] text-center">
<div className="truncate text-xs font-medium" title={node.metadata?.characterName || node.title} style={{ color: theme.node.muted }}>{node.metadata?.characterName || node.title}</div>
<div className="mt-1 text-[var(--fs-tiny)] tracking-[0.12em] opacity-50">多视角参考 · 待生成</div>
</div>
) : <span className="text-[var(--fs-tiny)] tracking-[0.18em] opacity-50">空图片节点</span>}
</div>
);
if (isBatchRoot)
return (
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
{content}
</BatchFrame>
);
return content;
}
function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRendererProps) {
const playWhenReadyRef = useRef(false);
const playerBoxRef = useRef<HTMLDivElement>(null);
const { url, loading, load } = useNodeResourceUrl(node, false);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const [currentTimeMs, setCurrentTimeMs] = useState(0);
const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null);
// 视频元素由 vidstack 内部创建,直接监听原生事件取分辨率;只有存在字幕时才跟踪播放时间,避免无字幕节点频繁重渲染。
useEffect(() => {
const box = playerBoxRef.current;
const video = box?.querySelector("video");
if (!video) return;
const handleLoadedMetadata = () => {
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight });
};
video.addEventListener("loadedmetadata", handleLoadedMetadata);
handleLoadedMetadata();
if (!subtitleEntries.length) {
return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata);
}
const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000));
video.addEventListener("timeupdate", handleTimeUpdate);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
};
}, [subtitleEntries.length, url]);
if (!node.metadata?.content)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<Video className="size-7 opacity-35" />
<span className="text-sm">空视频节点</span>
</div>
);
if (!url) {
return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
}
// 视频画面按实际分辨率等比适配节点盒子,字幕叠加层与画面同框,不在黑边上错位。
const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height);
const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio));
const fitWidth = Math.round(fitHeight * sourceRatio);
const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs);
const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined;
return (
<div ref={playerBoxRef} className="relative flex h-full w-full items-center justify-center overflow-hidden rounded-[var(--node-radius)] bg-black">
<div className="relative" style={{ width: fitWidth, height: Math.round(fitHeight) }}>
<VideoPlayer
src={url}
mimeType={node.metadata?.mimeType}
title={node.title || "视频"}
preload={reduceMediaEffects ? "none" : "metadata"}
autoPlay={playWhenReadyRef.current}
onCanPlay={() => { playWhenReadyRef.current = false; }}
brandColor={theme.accent.primary}
className="h-full w-full rounded-[var(--node-radius)] bg-black"
dataCanvasNoZoom
compactControls
/>
{activeEntry && activeEntry.text.trim() ? (
<CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} />
) : null}
</div>
</div>
);
}
function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const playWhenReadyRef = useRef(false);
const { url, loading, load } = useNodeResourceUrl(node, false);
useEffect(() => {
if (!url || !playWhenReadyRef.current) return;
playWhenReadyRef.current = false;
void audioRef.current?.play().catch(() => undefined);
}, [url]);
if (!node.metadata?.content)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2" style={{ color: theme.node.placeholder }}>
<Music2 className="size-7 opacity-35" />
<span className="text-sm">空音频节点</span>
</div>
);
if (!url) {
return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存音频" : "加载并缓存音频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
}
return (
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
<Music2 className="size-4 shrink-0" />
<span className="min-w-0 truncate" title={node.title || "音频"}>{node.title || "音频"}</span>
</div>
<audio ref={audioRef} src={url} controls preload="metadata" className="w-full" data-canvas-no-zoom />
</div>
);
}
function ImageContent({
node,
theme,
isBatchRoot,
batchCount,
batchExpanded,
batchOpening,
batchRecovering,
onToggleBatch,
}: {
node: CanvasNodeData;
theme: CanvasTheme;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
onToggleBatch?: () => void;
}) {
const imageContainerRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(imageContainerRef);
const { url, loading } = useNodeResourceUrl(node, nearViewport);
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
{url ? (
<img
src={url}
alt={node.title}
loading="lazy"
decoding="async"
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
/>
) : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
</div>
</BatchFrame>
);
}
function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) {
return (
<button type="button" data-canvas-no-zoom className="flex size-full flex-col items-center justify-center gap-2 rounded-[var(--r-2xl)] bg-black text-white/75 transition hover:text-white disabled:cursor-wait" disabled={disabled} onClick={(event) => { event.stopPropagation(); onClick(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<span className="grid size-10 place-items-center rounded-full bg-white/10">{icon}</span>
<span className="text-xs font-medium">{label}</span>
</button>
);
}
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
const [loading, setLoading] = useState(isRemoteResource && eager);
useEffect(() => {
let cancelled = false;
if (!isRemoteResource) {
setUrl(fallback);
setLoading(false);
return;
}
setUrl("");
setLoading(eager);
const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey);
void resolve
.then((cached) => {
if (!cancelled) setUrl(cached || (eager ? fallback : ""));
})
.catch(() => {
if (!cancelled && eager) setUrl(fallback);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [eager, fallback, isRemoteResource, storageKey]);
const load = useCallback(async () => {
if (url) return url;
if (!isRemoteResource) return fallback;
setLoading(true);
try {
const next = (await cacheResourceObjectUrl(storageKey)) || fallback;
setUrl(next);
return next;
} catch {
setUrl(fallback);
return fallback;
} finally {
setLoading(false);
}
}, [fallback, isRemoteResource, storageKey, url]);
return { url, loading, load };
}
function useNearViewport(ref: React.RefObject<Element | null>) {
const [nearViewport, setNearViewport] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element || typeof IntersectionObserver === "undefined") {
setNearViewport(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setNearViewport(true);
observer.disconnect();
}
},
{ rootMargin: "600px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return nearViewport;
}
function ImageInfoBar({ node }: { node: CanvasNodeData }) {
const width = Math.round(node.metadata?.naturalWidth || node.width);
const height = Math.round(node.metadata?.naturalHeight || node.height);
const size = formatBytes(node.metadata?.bytes || 0);
return (
<span className="ml-auto max-w-full shrink-0 truncate rounded-md bg-black/55 px-2 py-1 text-[var(--fs-label)] font-medium leading-none text-white backdrop-blur-sm">
{width} x {height}
{size ? ` · ${size}` : ""}
</span>
);
}
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) {
const isBatchRoot = batchCount > 1;
return (
<div
className="group/batch relative h-full w-full overflow-visible"
onDoubleClick={
isBatchRoot
? (event) => {
event.stopPropagation();
onToggleBatch?.();
}
: undefined
}
>
{isBatchRoot ? (
<div className="pointer-events-none absolute inset-0 overflow-visible">
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
<div
key={index}
className="absolute rounded-[inherit] border shadow-[0_14px_34px_rgba(68,64,60,.16)] transition-all duration-300 group-hover/batch:translate-x-2"
style={{
inset: 0,
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
borderColor: theme.node.stroke,
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
transform:
batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
zIndex: -index - 1,
}}
/>
))}
</div>
) : null}
{children}
</div>
);
}
function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) {
const positionClass = {
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
@@ -1223,7 +550,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
autoFocus
value={draft}
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded border bg-transparent px-1.5 text-xs font-medium outline-none"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 10px 28px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.06)` }}
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: "var(--elevation-card)" }}
onChange={(event) => onDraftChange(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
onBlur={onCommit}
@@ -1342,7 +669,7 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si
background: theme.spatial.elevated,
borderColor: theme.node.activeStroke,
color: theme.node.activeStroke,
boxShadow: `0 4px 12px ${theme.spatial.shadow}`,
boxShadow: "none",
}}
>
<Plus style={{ width: 10 * inverseScale, height: 10 * inverseScale }} strokeWidth={2} />

View File

@@ -193,7 +193,7 @@ export function CanvasToolbar({
<AnimatePresence>
{appearanceOpen ? (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[224px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: panelX || "50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: `0 24px 64px ${theme.spatial.shadow}` }} onWheel={(event) => event.stopPropagation()}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
@@ -232,7 +232,7 @@ function AddNodeMenu({ x, theme, commands }: {
}) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: x || "50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 24px 64px ${theme.spatial.shadow}` }} onWheel={(event) => event.stopPropagation()}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<CanvasCreateMenu commands={commands} />
</SpotlightSurface>
</motion.div>

View File

@@ -86,7 +86,7 @@ function VideoSettingsPortal({
const placeAbove = topPlacement ? topSpace >= estimatedHeight || topSpace >= bottomSpace : bottomSpace < estimatedHeight && topSpace > bottomSpace;
const style = {
position: "fixed",
zIndex: "var(--z-popover)",
zIndex: "var(--z-dialog-popover)",
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(placeAbove ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, topSpace) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, bottomSpace) }),

View File

@@ -79,23 +79,26 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
onPointerDown={(event) => event.stopPropagation()}
>
<motion.div initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: anchor.placement === "above" ? 8 : -8 }} animate={{ opacity: 1, scale: 1, y: 0 }} transition={aceternityMotion.spring.panel} className="flex items-center gap-2">
<span className="aceternity-floating-panel shrink-0 rounded-full border px-2.5 py-1.5 text-[var(--fs-tiny)] font-semibold tabular-nums backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.spatial.glowStrong, color: theme.accent.primary, boxShadow: `0 14px 36px ${theme.spatial.shadow}` }}>已选 {count}</span>
<span className="aceternity-floating-panel shrink-0 rounded-full border px-2.5 py-1.5 text-[var(--fs-tiny)] font-semibold tabular-nums backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.accent.primary }}>已选 {count}</span>
<div className="max-w-[min(560px,calc(100vw-90px))]">{children}</div>
</motion.div>
</div>
);
}
export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth = 520, panelHeight = 420, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; children: ReactNode }) {
export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; children: ReactNode }) {
const panelRef = useRef<HTMLDivElement>(null);
const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, panelWidth, panelHeight);
const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth);
const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, initialWidth, panelHeight);
useLayoutEffect(() => {
const container = containerRef.current;
const panel = panelRef.current;
if (!container || !panel) return;
const update = (nextViewport: ViewportTransform) => {
const position = getNodePanelPosition(node, nextViewport, { width: container.clientWidth, height: container.clientHeight }, panel.offsetWidth || panelWidth, panel.offsetHeight || panelHeight);
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
panel.style.width = `${nextWidth}px`;
const position = getNodePanelPosition(node, nextViewport, { width: container.clientWidth, height: container.clientHeight }, nextWidth, panel.offsetHeight || panelHeight);
panel.style.left = `${position.left}px`;
panel.style.top = `${position.top}px`;
};
@@ -115,7 +118,7 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
ref={panelRef}
data-canvas-no-zoom
className="thin-scrollbar absolute z-[var(--z-modal-overlay)] max-w-[calc(100%_-_24px)] overflow-y-auto"
style={{ left: initialPosition.left, top: initialPosition.top, width: panelWidth, maxHeight: "calc(100% - 84px)" }}
style={{ left: initialPosition.left, top: initialPosition.top, width: initialWidth, maxHeight: "calc(100% - 84px)" }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
@@ -124,6 +127,11 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
);
}
function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform, requestedWidth?: number) {
if (requestedWidth) return requestedWidth;
return clamp(Math.round(node.width * viewport.k * 1.5), 680, 920);
}
export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject<HTMLDivElement | null>; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
@@ -157,7 +165,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] w-[248px] origin-top-left overflow-hidden rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl"
data-canvas-no-zoom
data-connection-create-menu
style={{ left: initialPosition.left, top: initialPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }}
style={{ left: initialPosition.left, top: initialPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>

View File

@@ -426,7 +426,7 @@ function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) {
backgroundImage,
backgroundSize: "var(--canvas-grid-size) var(--canvas-grid-size)",
transform: "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)",
opacity: mode === "dots" ? 0.52 : 0.36,
opacity: mode === "dots" ? 0.28 : 0.2,
willChange: "transform",
}}
/>

View File

@@ -42,7 +42,7 @@ export function ListToolbar({ children, trailing, active, onReset, className }:
}
export function TableSurface({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg border border-border bg-surface", className)}>{children}</div>;
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg bg-surface", className)}>{children}</div>;
}
export function CollectionGrid({ children, className }: { children: ReactNode; className?: string }) {

View File

@@ -27,7 +27,7 @@ export function WorkspaceLoadingState({ label = "正在加载内容", detail, ro
<div><div className="text-sm font-medium">{label}</div>{detail ? <div className="mt-0.5 text-xs text-foreground/50">{detail}</div> : null}</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: rows }, (_, index) => <div key={index} className="rounded-md border border-border/75 p-4"><Skeleton active title={{ width: `${48 + index * 8}%` }} paragraph={{ rows: 3 }} /></div>)}
{Array.from({ length: rows }, (_, index) => <div key={index} className="rounded-md bg-surface-active p-4"><Skeleton active title={{ width: `${48 + index * 8}%` }} paragraph={{ rows: 3 }} /></div>)}
</div>
</section>
);

View File

@@ -157,7 +157,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
disabled={Boolean(disabledReason)}
title={disabledReason || modelOptionLabel(config, displayModel)}
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
style={{ background: "transparent", color: theme.node.text }}
onClick={() => {
if (!model) return;
onChange(model);

View File

@@ -9,13 +9,13 @@ type CanvasNodeSpec = {
};
export const NODE_DEFAULT_SIZE = {
[CanvasNodeType.Image]: { width: 420, height: 300, title: "New Generation" },
[CanvasNodeType.Image]: { width: 720, height: 405, title: "图片" },
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
[CanvasNodeType.Drawing]: { width: 440, height: 300, title: "绘图" },
[CanvasNodeType.Script]: { width: 920, height: 360, title: "分镜脚本" },
[CanvasNodeType.Skill]: { width: 360, height: 220, title: "技能" },
[CanvasNodeType.Config]: { width: 340, height: 300, title: "生成配置" },
[CanvasNodeType.Video]: { width: 480, height: 270, title: "Video" },
[CanvasNodeType.Video]: { width: 720, height: 405, title: "视频" },
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
[CanvasNodeType.Frame]: { width: 760, height: 520, title: "未命名背板" },
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;

View File

@@ -4,10 +4,10 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
export function canvasDockStyle(theme: CanvasTheme, color: string = theme.toolbar.item): CSSProperties {
return {
background: theme.spatial.elevated,
borderColor: theme.toolbar.border,
background: "var(--dock-surface)",
borderColor: "var(--dock-border)",
color,
boxShadow: `0 18px 52px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.14)`,
boxShadow: "var(--elevation-overlay)",
"--dock-command-bg": theme.spatial.surface,
"--dock-command-hover": theme.toolbar.itemHover,
"--dock-command-active": theme.toolbar.activeBg,

View File

@@ -1,9 +1,10 @@
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export const MEDIA_NODE_MIN_SIZE = { width: 300, height: 220 } as const;
export const VIDEO_NODE_MAX_SIZE = { width: 480, height: 480 } as const;
export const MEDIA_NODE_MIN_SIZE = { width: 420, height: 236 } as const;
export const VIDEO_NODE_MAX_SIZE = { width: 720, height: 520 } as const;
export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHeight = 640, minWidth = MEDIA_NODE_MIN_SIZE.width, minHeight = MEDIA_NODE_MIN_SIZE.height) {
export function fitNodeSize(width: number, height: number, maxWidth = 720, maxHeight = 520, minWidth = MEDIA_NODE_MIN_SIZE.width, minHeight = MEDIA_NODE_MIN_SIZE.height) {
const w = Math.max(1, width);
const h = Math.max(1, height);
// 媒体节点既要保留原始比例,也要给生成状态、操作按钮留下稳定的可读空间。
@@ -30,8 +31,18 @@ export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: n
export function ensureMediaNodeMinimumSize(node: CanvasNodeData) {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return node;
const title = node.title === "New Generation" ? "图片" : node.title === "Video" ? "视频" : node.title;
let width = node.width;
let height = node.height;
const emptyStage = NODE_DEFAULT_SIZE[node.type];
const shouldPromoteEmptyStage = !node.metadata?.content
&& !node.metadata?.freeResize
&& !node.metadata?.locked
&& width * height < emptyStage.width * emptyStage.height;
if (shouldPromoteEmptyStage) {
width = emptyStage.width;
height = emptyStage.height;
}
const naturalWidth = node.metadata?.naturalWidth || 0;
const naturalHeight = node.metadata?.naturalHeight || 0;
const requestedSize = node.type === CanvasNodeType.Image && node.metadata?.generationType === "edit"
@@ -50,9 +61,10 @@ export function ensureMediaNodeMinimumSize(node: CanvasNodeData) {
width *= scale;
height *= scale;
}
if (width === node.width && height === node.height) return node;
if (width === node.width && height === node.height && title === node.title) return node;
return {
...node,
title,
position: {
x: node.position.x + node.width / 2 - width / 2,
y: node.position.y + node.height / 2 - height / 2,

View File

@@ -1786,7 +1786,7 @@ function InfiniteCanvasPage() {
) : null}
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef} panelWidth={624}>
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef}>
{renderCanvasNodePanel(dialogNode)}
</CanvasNodePanelOverlay>
) : null}

View File

@@ -0,0 +1,334 @@
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Tooltip } from "antd";
import { ChevronDown, ChevronUp, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useState } from "react";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
import {
createModelChannel,
defaultBaseUrlForApiFormat,
filterModelsByCapability,
modelOptionsFromChannels,
useConfigStore,
type AiConfig,
type ModelChannel,
} from "@/stores/use-config-store";
import { ChannelModelSettings } from "./channel-video-pricing";
type UserChannelConnection = "openai" | "gemini";
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) {
const { message } = App.useApp();
const config = useConfigStore((state) => state.config);
const replaceConfig = useConfigStore((state) => state.replaceConfig);
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
const [collapsedChannelIds, setCollapsedChannelIds] = useState<Set<string>>(new Set());
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
replaceConfig(withChannels(baseConfig, channels));
};
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
updateChannels(config.channels.map((channel) => {
if (channel.id !== id) return channel;
const models = patch.models ? uniqueModels(patch.models) : channel.models;
return {
...channel,
...patch,
models,
modelCosts: patch.modelCosts !== undefined ? patch.modelCosts : (patch.models ? channel.modelCosts?.filter((item) => models.includes(item.model)) : channel.modelCosts),
};
}));
};
const updateChannelConnection = (channel: ModelChannel, connection: UserChannelConnection) => {
const apiFormat = connection;
const defaultBaseUrl = defaultBaseUrlForApiFormat(apiFormat);
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
// 渠道只负责连接类型;具体模型能力和请求协议由下方共享能力卡片维护。
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
};
const addChannel = () => {
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
updateChannels([...config.channels, channel]);
requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus());
};
const deleteChannel = (id: string) => {
const channel = config.channels.find((item) => item.id === id);
if (channel?.scope === "system") {
message.warning("系统渠道由管理员维护");
return;
}
updateChannels(config.channels.filter((item) => item.id !== id));
};
const setChannelLoading = (id: string, loading: boolean) => {
setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id)));
};
const toggleChannelCollapsed = (id: string) => {
setCollapsedChannelIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const refreshChannelModels = async (channel: ModelChannel) => {
const connectionError = channelConnectionError(channel);
if (connectionError) {
message.error(`${channel.name || "当前渠道"}:${connectionError}`);
return;
}
setChannelLoading(channel.id, true);
try {
const result = await fetchChannelModels(channel, true);
if (!result.models.length) {
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
return;
}
const latestConfig = useConfigStore.getState().config;
const latestChannel = latestConfig.channels.find((item) => item.id === channel.id);
if (!latestChannel) return;
if (channelConnectionSignature(latestChannel) !== channelConnectionSignature(channel)) {
message.warning(`${latestChannel.name || "当前渠道"}的连接配置已改变,已忽略旧的拉取结果`);
return;
}
updateChannels(
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)),
latestConfig,
);
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
} catch (error) {
message.error(channelModelFetchErrorMessage(error));
} finally {
setChannelLoading(channel.id, false);
}
};
const refreshAllModels = async () => {
const runnable = userChannels.filter((channel) => !channelConnectionError(channel));
const skipped = userChannels.filter((channel) => channelConnectionError(channel));
if (!runnable.length) {
const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";");
message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key");
return;
}
setChannelLoading("all", true);
try {
const results = await Promise.all(
runnable.map(async (channel) => {
try {
const result = await fetchChannelModels(channel, true);
return { channel, result, error: "" };
} catch (error) {
return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" };
}
}),
);
const latestConfig = useConfigStore.getState().config;
const successful = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.result.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
});
const stale = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.result.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
});
const failed = results.filter((item) => !item.result.models.length);
if (successful.length) {
const resultMap = new Map(successful.map((item) => [item.channel.id, item.result] as const));
updateChannels(
latestConfig.channels.map((channel) => {
const fetched = resultMap.get(channel.id);
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel;
}),
latestConfig,
);
message.success(`已更新 ${successful.length} 个渠道的模型`);
}
const warnings = [
...failed.map((item) => `${item.channel.name || "未命名渠道"}:${item.error || "未返回模型"}`),
...stale.map((item) => `${item.channel.name || "未命名渠道"}:连接配置已改变,已忽略旧结果`),
...skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`),
];
if (warnings.length) message.warning(`${warnings.join(";")}。未更新的渠道已保留原有模型列表`);
} catch (error) {
message.error(error instanceof Error ? error.message : "批量读取模型失败,原有模型列表未改动");
} finally {
setChannelLoading("all", false);
}
};
return (
<Form layout="vertical" requiredMark={false}>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>自定义渠道</h2>
<p>渠道只保存连接类型;拉取模型后,请在“模型与能力”中配置协议。<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={onOpenModels}>打开模型选择</Button></p>
</div>
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" icon={<RefreshCw className="size-4" />} loading={loadingChannelIds.includes("all")} disabled={loadingChannelIds.some((id) => id !== "all")} onClick={() => void refreshAllModels()}>拉取全部</Button>
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>新增渠道</Button>
</div>
</div>
{userChannels.length ? (
<div className="settings-channel-list space-y-2">
{userChannels.map((channel) => {
const collapsed = collapsedChannelIds.has(channel.id);
return (
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="settings-channel p-2.5 sm:p-3">
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-2.5">
<div className="min-w-0 flex-1 basis-52">
<h3 id={`channel-${channel.id}-title`} className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-foreground/55">
{channelProtocolLabel(channel)} · 已保存 {channel.models.length} 个模型
<ChannelStatus channel={channel} />
</div>
</div>
<div className="flex w-full justify-end gap-2 sm:w-auto sm:shrink-0">
<Button className="h-10 sm:h-8" size="small" icon={<RefreshCw className="size-3.5" />} loading={loadingChannelIds.includes(channel.id)} disabled={loadingChannelIds.includes("all")} onClick={() => void refreshChannelModels(channel)}>拉取模型</Button>
<Tooltip title={collapsed ? "展开渠道配置" : "收起渠道配置"}>
<Button className="size-10 p-0 sm:size-8" size="small" type="text" aria-label={`${collapsed ? "展开" : "收起"}渠道配置 ${channel.name || "未命名渠道"}`} aria-expanded={!collapsed} aria-controls={`channel-${channel.id}-details`} icon={collapsed ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />} onClick={() => toggleChannelCollapsed(channel.id)} />
</Tooltip>
<Popconfirm title="删除自定义渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
<Tooltip title="删除渠道"><Button className="size-10 p-0 sm:size-8" aria-label={`删除渠道 ${channel.name || "未命名渠道"}`} size="small" type="text" danger disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")} icon={<Trash2 className="size-3.5" />} /></Tooltip>
</Popconfirm>
</div>
</div>
<div id={`channel-${channel.id}-details`} hidden={collapsed}>
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3"><Input id={`channel-${channel.id}-name`} value={channel.name} placeholder="例如:我的 NewAPI" onChange={(event) => updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /></Form.Item>
<Form.Item label="渠道连接类型" className="mb-0 lg:col-span-3" extra="仅用于拉取模型目录;模型能力和请求协议在下方统一配置。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI 兼容", value: "openai" }, { label: "Gemini 原生", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6"><Input id={`channel-${channel.id}-base-url`} inputMode="url" value={channel.baseUrl} placeholder="填写渠道 Base URL" onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })} /></Form.Item>
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5"><Input.Password id={`channel-${channel.id}-api-key`} autoComplete="new-password" value={channel.apiKey} placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })} /></Form.Item>
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 lg:col-span-5" extra="即梦等 AK/SK 协议需要;其他协议留空。"><Input.Password id={`channel-${channel.id}-secret-key`} autoComplete="new-password" value={channel.secretKey || ""} placeholder="填写 Secret Key" onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })} /></Form.Item>
<div className="settings-field-group-label lg:col-span-12">模型与能力</div>
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7"><Select id={`channel-${channel.id}-models`} mode="tags" showSearch allowClear maxTagCount="responsive" tokenSeparators={[",", "\n"]} placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })} /></Form.Item>
<div className="lg:col-span-12"><ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} /></div>
</div>
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
</div>
</section>
);
})}
</div>
) : <WorkspaceState icon="settings" compact title="当前没有自定义渠道" description="管理员配置的系统渠道会出现在模型选择中;也可以添加自己的模型服务。" action={<Button icon={<Plus className="size-4" />} onClick={addChannel}>新增自定义渠道</Button>} />}
</Form>
);
}
export function channelValidationError(channel: ModelChannel) {
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
}
export function isChannelReady(channel: ModelChannel) {
return !channelValidationError(channel);
}
export function focusInvalidChannelField(channel: ModelChannel) {
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid", secretKey: "valid" });
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : requiresSecretKey(channel) && !channel.secretKey?.trim() ? "secret-key" : "models";
requestAnimationFrame(() => {
const element = document.getElementById(`channel-${channel.id}-${field}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
element?.focus({ preventScroll: true });
});
}
function ChannelStatus({ channel }: { channel: ModelChannel }) {
const error = channelValidationError(channel);
return (
<span className={`settings-channel-status ${error ? "is-warning" : "is-ready"}`}>
<i aria-hidden="true" />
{error || "可用"}
</span>
);
}
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
const models = modelOptionsFromChannels(channels);
const imageModels = filterModelsByCapability(models, "image", channels);
const videoModels = filterModelsByCapability(models, "video", channels);
const textModels = filterModelsByCapability(models, "text", channels);
const audioModels = filterModelsByCapability(models, "audio", channels);
return { ...config, channels, models, baseUrl: channels[0]?.baseUrl || config.baseUrl, apiKey: channels[0]?.apiKey || config.apiKey, apiFormat: channels[0]?.apiFormat || config.apiFormat, imageModels, videoModels, textModels, audioModels, imageModel: normalizeDefaultModel(config.imageModel, imageModels), videoModel: normalizeDefaultModel(config.videoModel, videoModels), textModel: normalizeDefaultModel(config.textModel, textModels), audioModel: normalizeDefaultModel(config.audioModel, audioModels) };
}
function normalizeDefaultModel(value: string, options: string[]) {
return options.includes(value) ? value : options[0] || "";
}
function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
const next: ChannelModelCost[] = [];
for (const item of catalog) {
const existing = existingByModel.get(item.id);
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
if (existing) {
const inferredCapability = modelProtocolCapability(inferredProtocol);
next.push(inferredProtocol && existing.protocol !== inferredProtocol ? { ...existing, protocol: inferredProtocol, capability: inferredCapability || existing.capability, capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined } : existing);
continue;
}
const catalogProtocol = inferredProtocol || channel.interfaceType;
const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined;
if (!catalogProtocol || !catalogCapability) continue;
next.push({ model: item.id, capability: catalogCapability, protocol: catalogProtocol, billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined });
}
return next;
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function channelModelFetchErrorMessage(error: unknown) {
const detail = error instanceof Error ? error.message : "读取模型失败";
if (detail.includes("不允许访问本机") || detail.includes("不允许访问保留地址")) return `${detail};可信私网服务需由部署管理员配置 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS`;
return `${detail};也可以直接在模型列表中手动输入模型名`;
}
function channelConnectionMode(channel: ModelChannel): UserChannelConnection {
return channel.apiFormat === "gemini" ? "gemini" : "openai";
}
function channelConnectionError(channel: ModelChannel) {
const baseUrl = channel.baseUrl.trim();
if (!baseUrl) return "请填写 Base URL";
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Base URL 只支持 HTTP 或 HTTPS";
} catch {
return "Base URL 格式不正确";
}
if (!channel.apiKey.trim()) return "请填写 API Key / Access Key";
if (requiresSecretKey(channel) && !channel.secretKey?.trim()) return "当前协议需要填写 Secret Key";
return "";
}
function channelConnectionSignature(channel: ModelChannel) {
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
}
function channelProtocolLabel(channel: ModelChannel) {
return channelConnectionMode(channel) === "gemini" ? "Gemini 原生" : "OpenAI 兼容";
}
function isKnownDefaultBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/, "");
if (!normalized) return true;
return [defaultBaseUrlForApiFormat("openai"), defaultBaseUrlForApiFormat("gemini")].some((candidate) => candidate.replace(/\/+$/, "") === normalized);
}
function requiresSecretKey(channel: ModelChannel) {
return channel.interfaceType?.startsWith("volcengine-jimeng-") === true || channel.modelCosts?.some((item) => item.protocol?.startsWith("volcengine-jimeng-")) === true;
}

View File

@@ -48,7 +48,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
const activeBillingMode = activeModelCost?.billingMode || "fixed_request";
return (
<div className="mt-3 border-t border-border/70 pt-3">
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium">模型能力与请求协议</div>
@@ -64,8 +64,8 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
const billingMode = cost?.billingMode || "fixed_request";
return (
<div key={model} className="flex min-w-0 items-center gap-3 rounded-md border border-border/70 bg-background/45 px-3 py-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35 text-foreground/65">
<div key={model} className="flex min-w-0 items-center gap-3 rounded-md bg-surface-active px-3 py-2.5 transition-colors hover:bg-surface-hover">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/65">
<Settings2 className="size-4" />
</span>
<div className="min-w-0 flex-1">
@@ -110,7 +110,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
>
{activeModel && activeCapability && activeProtocol ? (
<div className="space-y-4">
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<div className="rounded-md bg-surface-active px-3 py-2.5">
<div className="text-xs font-medium">模型能力与请求协议</div>
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/45">这些设置只影响当前渠道的这个模型,保存后会同步到生成校验。</div>
</div>

View File

@@ -1,28 +1,14 @@
import { App, Button, Form, Input, InputNumber, Popconfirm, Segmented, Select, Tag, Tooltip } from "antd";
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
import { App, Button, Form, Input, InputNumber, Select } from "antd";
import { ArrowLeft, Boxes, Cloud, MessageSquareText, RadioTower, SlidersHorizontal } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { refreshSystemChannels } from "@/lib/user-session";
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import {
createModelChannel,
defaultBaseUrlForApiFormat,
defaultConfig,
filterModelsByCapability,
modelOptionsFromChannels,
useConfigStore,
type AiConfig,
type ModelChannel,
} from "@/stores/use-config-store";
import { refreshSystemChannels } from "@/lib/user-session";
import { defaultConfig, useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { ChannelModelSettings } from "./channel-video-pricing";
import { ChannelSettingsPane, channelValidationError, focusInvalidChannelField, isChannelReady } from "./channel-settings-pane";
import { ModelDefaultGrid } from "./model-default-grid";
import { PromptPreferencesPane } from "./prompt-preferences-pane";
@@ -36,32 +22,18 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
{ key: "storage", label: "我的对象存储", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
];
type UserChannelConnection = "openai" | "gemini";
function isConfigSection(value: string | null): value is ConfigSectionKey {
return configSections.some((section) => section.key === value);
}
function channelModelFetchErrorMessage(error: unknown) {
const detail = error instanceof Error ? error.message : "读取模型失败";
// 私网地址会在实际生成时继续被 SSRF 防护拦截,不能提示用户靠手填模型绕过。
if (detail.includes("不允许访问本机") || detail.includes("不允许访问保留地址")) {
return `${detail};可信私网服务需由部署管理员配置 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS`;
}
return `${detail};也可以直接在模型列表中手动输入模型名`;
}
export default function SettingsPage() {
const { message } = App.useApp();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section");
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(isConfigSection(requestedSection) ? requestedSection : "channels");
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
const [collapsedChannelIds, setCollapsedChannelIds] = useState<Set<string>>(new Set());
const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const replaceConfig = useConfigStore((state) => state.replaceConfig);
const shouldPromptContinue = searchParams.get("continue") === "1";
const userId = useUserStore((state) => state.user?.id);
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
@@ -96,8 +68,7 @@ export default function SettingsPage() {
focusInvalidChannelField(invalidChannel);
return;
}
const ready = config.channels.some(isChannelReady);
if (!ready) {
if (!config.channels.some(isChannelReady)) {
selectSection("channels");
message.error(shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置");
return;
@@ -106,145 +77,95 @@ export default function SettingsPage() {
navigate(-1);
};
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
replaceConfig(withChannels(baseConfig, channels));
};
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
updateChannels(config.channels.map((channel) => {
if (channel.id !== id) return channel;
const models = patch.models ? uniqueModels(patch.models) : channel.models;
return {
...channel,
...patch,
models,
modelCosts: patch.modelCosts !== undefined ? patch.modelCosts : (patch.models ? channel.modelCosts?.filter((item) => models.includes(item.model)) : channel.modelCosts),
};
}));
};
const updateChannelConnection = (channel: ModelChannel, connection: UserChannelConnection) => {
const apiFormat = connection;
const defaultBaseUrl = defaultBaseUrlForApiFormat(apiFormat);
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
// 渠道只负责连接类型;具体模型能力和请求协议由下方共享能力卡片维护。
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
};
const addChannel = () => {
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
updateChannels([...config.channels, channel]);
requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus());
};
const deleteChannel = (id: string) => {
const channel = config.channels.find((item) => item.id === id);
if (channel?.scope === "system") {
message.warning("系统渠道由管理员维护");
return;
}
updateChannels(config.channels.filter((item) => item.id !== id));
};
const setChannelLoading = (id: string, loading: boolean) => {
setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id)));
};
const toggleChannelCollapsed = (id: string) => {
setCollapsedChannelIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const refreshChannelModels = async (channel: ModelChannel) => {
const connectionError = channelConnectionError(channel);
if (connectionError) {
message.error(`${channel.name || "当前渠道"}:${connectionError}`);
return;
}
setChannelLoading(channel.id, true);
try {
const result = await fetchChannelModels(channel, true);
if (!result.models.length) {
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
return;
}
const latestConfig = useConfigStore.getState().config;
const latestChannel = latestConfig.channels.find((item) => item.id === channel.id);
if (!latestChannel) return;
if (channelConnectionSignature(latestChannel) !== channelConnectionSignature(channel)) {
message.warning(`${latestChannel.name || "当前渠道"}的连接配置已改变,已忽略旧的拉取结果`);
return;
}
updateChannels(
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)),
latestConfig,
);
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
} catch (error) {
message.error(channelModelFetchErrorMessage(error));
} finally {
setChannelLoading(channel.id, false);
}
};
const refreshAllModels = async () => {
const runnable = userChannels.filter((channel) => !channelConnectionError(channel));
const skipped = userChannels.filter((channel) => channelConnectionError(channel));
if (!runnable.length) {
const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";");
message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key");
return;
}
setChannelLoading("all", true);
try {
const results = await Promise.all(
runnable.map(async (channel) => {
try {
const result = await fetchChannelModels(channel, true);
return { channel, result, error: "" };
} catch (error) {
return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" };
}
}),
);
const latestConfig = useConfigStore.getState().config;
const successful = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.result.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
});
const stale = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.result.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
});
const failed = results.filter((item) => !item.result.models.length);
if (successful.length) {
const resultMap = new Map(successful.map((item) => [item.channel.id, item.result] as const));
updateChannels(
latestConfig.channels.map((channel) => {
const fetched = resultMap.get(channel.id);
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel;
}),
latestConfig,
);
message.success(`已更新 ${successful.length} 个渠道的模型`);
}
const warnings = [
...failed.map((item) => `${item.channel.name || "未命名渠道"}:${item.error || "未返回模型"}`),
...stale.map((item) => `${item.channel.name || "未命名渠道"}:连接配置已改变,已忽略旧结果`),
...skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`),
];
if (warnings.length) {
message.warning(`${warnings.join(";")}。未更新的渠道已保留原有模型列表`);
}
} catch (error) {
message.error(error instanceof Error ? error.message : "批量读取模型失败,原有模型列表未改动");
} finally {
setChannelLoading("all", false);
}
const panes: Record<ConfigSectionKey, ReactNode> = {
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} /></SettingsPane>,
models: (
<SettingsPane>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>模型选择</h2>
<p>按领域选择默认模型;模型能力与请求协议在渠道“模型与能力”中配置。</p>
</div>
</div>
<div className="settings-section">
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
</div>
</SettingsPane>
),
preferences: (
<SettingsPane>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>生成偏好</h2>
<p>画布、视频与音频默认值,节点内仍可单独覆盖。</p>
</div>
</div>
<div className="settings-section">
<Form layout="vertical" requiredMark={false}>
<section className="settings-preference-block pb-6">
<div className="mb-4">
<h3 className="text-sm font-semibold">画布生成</h3>
<p className="mt-1 text-xs text-foreground/55">设置新建生成任务时使用的初始值,节点内仍可单独覆盖。</p>
</div>
<Form.Item label="默认生图张数" className="mb-0 max-w-xs">
<InputNumber
min={1}
max={15}
precision={0}
className="w-full"
value={Number(config.canvasImageCount)}
onChange={(value) => updateConfig("canvasImageCount", normalizeImageCount(String(value ?? defaultConfig.canvasImageCount)))}
/>
</Form.Item>
</section>
<section className="settings-preference-block py-6">
<div className="mb-4">
<h3 className="text-sm font-semibold">音频默认值</h3>
<p className="mt-1 text-xs text-foreground/55">用于新建音频节点和未单独设置参数的生成任务。</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Form.Item label="默认声音" className="mb-0">
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
</Form.Item>
<Form.Item label="文件格式" className="mb-0">
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
</Form.Item>
<Form.Item label="语速" className="mb-0">
<InputNumber
min={0.25}
max={4}
step={0.05}
precision={2}
className="w-full"
value={Number(config.audioSpeed)}
onChange={(value) => updateConfig("audioSpeed", normalizeAudioSpeedValue(String(value ?? defaultConfig.audioSpeed)))}
/>
</Form.Item>
</div>
</section>
<section className="settings-preference-block pt-6">
<div className="mb-4">
<h3 className="text-sm font-semibold">音频指令</h3>
<p className="mt-1 text-xs text-foreground/55">在音频节点没有单独填写时使用。</p>
</div>
<div className="max-w-2xl">
<Form.Item label="默认音频指令" className="mb-0">
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
</div>
</section>
</Form>
</div>
</SettingsPane>
),
prompts: <SettingsPane fill><PromptPreferencesPane /></SettingsPane>,
storage: (
<SettingsPane>
<div className="settings-section">
<UserOSSSettingsForm />
</div>
</SettingsPane>
),
};
return (
@@ -252,7 +173,9 @@ export default function SettingsPage() {
<header className="settings-topbar shrink-0">
<div className="flex min-w-0 items-center gap-2.5">
{shouldPromptContinue ? (
<button type="button" className="app-workspace-icon-button shrink-0" onClick={() => navigate(-1)} aria-label="返回创作页面" title="返回创作页面"><ArrowLeft className="size-4" /></button>
<button type="button" className="app-workspace-icon-button shrink-0" onClick={() => navigate(-1)} aria-label="返回创作页面" title="返回创作页面">
<ArrowLeft className="size-4" />
</button>
) : null}
<h1 className="truncate text-sm font-semibold">设置</h1>
</div>
@@ -272,281 +195,19 @@ export default function SettingsPage() {
aria-current={selected ? "page" : undefined}
>
<span className={`shrink-0 md:mt-0.5 ${selected ? "text-[var(--workspace-accent)]" : ""}`}>{item.icon}</span>
<span className="min-w-0"><span className="block whitespace-nowrap text-sm font-medium">{item.label}</span><span className="mt-1 hidden text-[var(--fs-label)] leading-4 text-current opacity-65 md:block">{item.description}</span></span>
<span className="min-w-0">
<span className="block whitespace-nowrap text-sm font-medium">{item.label}</span>
<span className="mt-1 hidden text-[var(--fs-label)] leading-4 text-current opacity-65 md:block">{item.description}</span>
</span>
</button>
);
})}
</nav>
</aside>
<section className="settings-content flex min-h-0 min-w-0 flex-1 flex-col">
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 md:px-6 md:py-5">
<div className={`settings-surface-card ${activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}`}>
{([
{
key: "channels",
label: "渠道",
children: (
<SettingsPane>
<Form layout="vertical" requiredMark={false}>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>自定义渠道</h2>
<p>渠道只保存连接类型;拉取模型后,请在“模型与能力”中配置协议。<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>打开模型选择</Button></p>
</div>
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
<Button
className="h-10 flex-1 sm:h-8 sm:flex-none"
icon={<RefreshCw className="size-4" />}
loading={loadingChannelIds.includes("all")}
disabled={loadingChannelIds.some((id) => id !== "all")}
onClick={() => void refreshAllModels()}
>
拉取全部
</Button>
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
新增渠道
</Button>
</div>
</div>
{userChannels.length ? (
<div className="settings-channel-list space-y-2">
{userChannels.map((channel) => (
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="rounded-md border border-border bg-background p-2.5 sm:p-3">
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-2.5">
<div className="min-w-0 flex-1 basis-52">
<h3 id={`channel-${channel.id}-title`} className="truncate text-sm font-semibold">
{channel.name || "未命名渠道"}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-foreground/55">
{channelProtocolLabel(channel)} · 已保存 {channel.models.length} 个模型
<ChannelStatus channel={channel} />
</div>
</div>
<div className="flex w-full justify-end gap-2 sm:w-auto sm:shrink-0">
<Button
className="h-10 sm:h-8"
size="small"
icon={<RefreshCw className="size-3.5" />}
loading={loadingChannelIds.includes(channel.id)}
disabled={loadingChannelIds.includes("all")}
onClick={() => void refreshChannelModels(channel)}
>
拉取模型
</Button>
<Tooltip title={collapsedChannelIds.has(channel.id) ? "\u5c55\u5f00\u6e20\u9053\u914d\u7f6e" : "\u6536\u8d77\u6e20\u9053\u914d\u7f6e"}>
<Button
className="size-10 p-0 sm:size-8"
size="small"
aria-label={`${collapsedChannelIds.has(channel.id) ? "\u5c55\u5f00" : "\u6536\u8d77"}\u6e20\u9053\u914d\u7f6e ${channel.name || "\u672a\u547d\u540d\u6e20\u9053"}`}
aria-expanded={!collapsedChannelIds.has(channel.id)}
aria-controls={`channel-${channel.id}-details`}
icon={collapsedChannelIds.has(channel.id) ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
onClick={() => toggleChannelCollapsed(channel.id)}
/>
</Tooltip>
<Popconfirm title="删除自定义渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
<Tooltip title="删除渠道">
<Button
className="size-10 p-0 sm:size-8"
aria-label={`删除渠道 ${channel.name || "未命名渠道"}`}
size="small"
danger
disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")}
icon={<Trash2 className="size-3.5" />}
/>
</Tooltip>
</Popconfirm>
</div>
</div>
<div id={`channel-${channel.id}-details`} hidden={collapsedChannelIds.has(channel.id)}>
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3">
<Input
id={`channel-${channel.id}-name`}
value={channel.name}
placeholder="例如:我的 NewAPI"
onChange={(event) => updateChannel(channel.id, { name: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })}
/>
</Form.Item>
<Form.Item label="渠道连接类型" className="mb-0 lg:col-span-3" extra="仅用于拉取模型目录;模型能力和请求协议在下方统一配置。">
<Segmented<UserChannelConnection>
block
value={channelConnectionMode(channel)}
options={[{ label: "OpenAI 兼容", value: "openai" }, { label: "Gemini 原生", value: "gemini" }]}
onChange={(value) => updateChannelConnection(channel, value)}
/>
</Form.Item>
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6">
<Input
id={`channel-${channel.id}-base-url`}
inputMode="url"
value={channel.baseUrl}
placeholder="填写渠道 Base URL"
onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })}
/>
</Form.Item>
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5">
<Input.Password
id={`channel-${channel.id}-api-key`}
autoComplete="new-password"
value={channel.apiKey}
placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"}
onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })}
/>
</Form.Item>
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 lg:col-span-5" extra="即梦等 AK/SK 协议需要;其他协议留空。">
<Input.Password
id={`channel-${channel.id}-secret-key`}
autoComplete="new-password"
value={channel.secretKey || ""}
placeholder="填写 Secret Key"
onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })}
/>
</Form.Item>
<div className="settings-field-group-label lg:col-span-12">模型与能力</div>
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7">
<Select
id={`channel-${channel.id}-models`}
mode="tags"
showSearch
allowClear
maxTagCount="responsive"
tokenSeparators={[",", "\n"]}
placeholder="输入模型名,或点击拉取模型"
value={channel.models}
onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })}
/>
</Form.Item>
<div className="lg:col-span-12">
<ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} />
</div>
</div>
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
</div>
</section>
))}
</div>
) : (
<WorkspaceState
icon="settings"
compact
title="当前没有自定义渠道"
description="管理员配置的系统渠道会出现在模型选择中;也可以添加自己的模型服务。"
action={<Button icon={<Plus className="size-4" />} onClick={addChannel}>新增自定义渠道</Button>}
/>
)}
</Form>
</SettingsPane>
),
},
{
key: "models",
label: "模型",
children: (
<SettingsPane>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>模型选择</h2>
<p>按领域选择默认模型;模型能力与请求协议在渠道“模型与能力”中配置。</p>
</div>
</div>
<div className="settings-section-card">
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
</div>
</SettingsPane>
),
},
{
key: "preferences",
label: "生成偏好",
children: (
<SettingsPane>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>生成偏好</h2>
<p>画布、视频与音频默认值,节点内仍可单独覆盖。</p>
</div>
</div>
<div className="settings-section-card">
<Form layout="vertical" requiredMark={false}>
<section className="border-b border-border pb-6">
<div className="mb-4"><h3 className="text-sm font-semibold">画布生成</h3><p className="mt-1 text-xs text-foreground/55">设置新建生成任务时使用的初始值,节点内仍可单独覆盖。</p></div>
<Form.Item label="默认生图张数" className="mb-0 max-w-xs">
<InputNumber
min={1}
max={15}
precision={0}
className="w-full"
value={Number(config.canvasImageCount)}
onChange={(value) => updateConfig("canvasImageCount", normalizeImageCount(String(value ?? defaultConfig.canvasImageCount)))}
/>
</Form.Item>
</section>
<section className="border-b border-border py-6">
<div className="mb-4"><h3 className="text-sm font-semibold">音频默认值</h3><p className="mt-1 text-xs text-foreground/55">用于新建音频节点和未单独设置参数的生成任务。</p></div>
<div className="grid gap-4 md:grid-cols-3">
<Form.Item label="默认声音" className="mb-0">
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
</Form.Item>
<Form.Item label="文件格式" className="mb-0">
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
</Form.Item>
<Form.Item label="语速" className="mb-0">
<InputNumber
min={0.25}
max={4}
step={0.05}
precision={2}
className="w-full"
value={Number(config.audioSpeed)}
onChange={(value) => updateConfig("audioSpeed", normalizeAudioSpeedValue(String(value ?? defaultConfig.audioSpeed)))}
/>
</Form.Item>
</div>
</section>
<section className="pt-6">
<div className="mb-4"><h3 className="text-sm font-semibold">音频指令</h3><p className="mt-1 text-xs text-foreground/55">在音频节点没有单独填写时使用。</p></div>
<div className="max-w-2xl">
<Form.Item label="默认音频指令" className="mb-0">
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
</div>
</section>
</Form>
</div>
</SettingsPane>
),
},
{
key: "prompts",
label: "提示词偏好",
children: <SettingsPane fill><PromptPreferencesPane /></SettingsPane>,
},
{
key: "storage",
label: (
<span className="inline-flex items-center gap-2">
<Cloud className="size-4" />
我的对象存储
</span>
),
children: (
<SettingsPane>
<div className="settings-section-card">
<UserOSSSettingsForm />
</div>
</SettingsPane>
),
},
] as Array<{ key: ConfigSectionKey; label: ReactNode; children: ReactNode }>).find((item) => item.key === activeTab)?.children}
<div className={`settings-pane-root ${activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}`}>
{panes[activeTab]}
</div>
</div>
</section>
@@ -556,148 +217,9 @@ export default function SettingsPage() {
}
function SettingsPane({ children, fill = false }: { children: ReactNode; fill?: boolean }) {
return <div className={fill ? "h-full" : undefined}>{children}</div>;
}
function ChannelStatus({ channel }: { channel: ModelChannel }) {
const error = channelValidationError(channel);
return error ? (
<Tag variant="filled" color="warning" className="m-0">
{error}
</Tag>
) : (
<Tag variant="filled" color="success" icon={<CircleCheck className="size-3" />} className="m-0">
可用
</Tag>
);
}
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
const models = modelOptionsFromChannels(channels);
const imageModels = filterModelsByCapability(models, "image", channels);
const videoModels = filterModelsByCapability(models, "video", channels);
const textModels = filterModelsByCapability(models, "text", channels);
const audioModels = filterModelsByCapability(models, "audio", channels);
return {
...config,
channels,
models,
baseUrl: channels[0]?.baseUrl || config.baseUrl,
apiKey: channels[0]?.apiKey || config.apiKey,
apiFormat: channels[0]?.apiFormat || config.apiFormat,
imageModels,
videoModels,
textModels,
audioModels,
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
textModel: normalizeDefaultModel(config.textModel, textModels),
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
};
}
function normalizeDefaultModel(value: string, options: string[]) {
if (options.includes(value)) return value;
return options[0] || "";
return <div className={fill ? "settings-pane h-full" : "settings-pane"}>{children}</div>;
}
function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || Number(defaultConfig.canvasImageCount)))));
}
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
// 拉取模型目录时按上游 supported_endpoint_types 推导协议和能力;
// 保留已有定价,但目录端点类型与旧配置冲突时自动纠正协议,避免模型名猜测和 multipart 错配。
function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
const next: ChannelModelCost[] = [];
for (const item of catalog) {
const existing = existingByModel.get(item.id);
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
if (existing) {
if (inferredProtocol && existing.protocol !== inferredProtocol) {
const inferredCapability = modelProtocolCapability(inferredProtocol);
next.push({
...existing,
protocol: inferredProtocol,
capability: inferredCapability || existing.capability,
capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined,
});
continue;
}
next.push(existing);
continue;
}
const catalogProtocol = inferredProtocol || channel.interfaceType;
const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined;
if (!catalogProtocol || !catalogCapability) continue;
next.push({
model: item.id,
capability: catalogCapability,
protocol: catalogProtocol,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined,
});
}
return next;
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function channelConnectionMode(channel: ModelChannel): UserChannelConnection {
return channel.apiFormat === "gemini" ? "gemini" : "openai";
}
function channelConnectionError(channel: ModelChannel) {
const baseUrl = channel.baseUrl.trim();
if (!baseUrl) return "请填写 Base URL";
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Base URL 只支持 HTTP 或 HTTPS";
} catch {
return "Base URL 格式不正确";
}
if (!channel.apiKey.trim()) return "请填写 API Key / Access Key";
if (requiresSecretKey(channel) && !channel.secretKey?.trim()) return "当前协议需要填写 Secret Key";
return "";
}
function channelConnectionSignature(channel: ModelChannel) {
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
}
function channelValidationError(channel: ModelChannel) {
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
}
function isChannelReady(channel: ModelChannel) {
return !channelValidationError(channel);
}
function focusInvalidChannelField(channel: ModelChannel) {
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid", secretKey: "valid" });
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : requiresSecretKey(channel) && !channel.secretKey?.trim() ? "secret-key" : "models";
requestAnimationFrame(() => {
const element = document.getElementById(`channel-${channel.id}-${field}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
element?.focus({ preventScroll: true });
});
}
function channelProtocolLabel(channel: ModelChannel) {
return channelConnectionMode(channel) === "gemini" ? "Gemini 原生" : "OpenAI 兼容";
}
function isKnownDefaultBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/, "");
if (!normalized) return true;
return [defaultBaseUrlForApiFormat("openai"), defaultBaseUrlForApiFormat("gemini")].some((candidate) => candidate.replace(/\/+$/, "") === normalized);
}
function requiresSecretKey(channel: ModelChannel) {
return channel.interfaceType?.startsWith("volcengine-jimeng-") === true || channel.modelCosts?.some((item) => item.protocol?.startsWith("volcengine-jimeng-")) === true;
}

View File

@@ -1,4 +1,3 @@
import { Tag } from "antd";
import { AudioLines, Check, Film, Image, MessageSquareText } from "lucide-react";
import { ModelIcon } from "@/components/model-picker";
@@ -31,14 +30,14 @@ const groups: Array<{
export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onChange: (key: DefaultModelKey, model: string) => void }) {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
return (
<div className="divide-y divide-border/70">
<div className="space-y-1">
{groups.map((group) => {
const models = filterModelsByCapability(config.models, group.capability, config.channels);
const Icon = group.icon;
return (
<section key={group.capability} className="py-5 first:pt-0 last:pb-0" aria-labelledby={`default-${group.capability}-title`}>
<div className="mb-3 flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-muted/35 text-foreground/65"><Icon className="size-4" /></span>
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-surface-active text-foreground/65"><Icon className="size-4" /></span>
<div className="min-w-0">
<h3 id={`default-${group.capability}-title`} className="text-sm font-semibold">{group.title}</h3>
<p className="mt-0.5 text-xs text-foreground/48">{group.description}</p>
@@ -58,26 +57,24 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
role="radio"
aria-checked={selected}
className={cn(
"group relative min-h-[76px] overflow-hidden rounded-md border px-3 py-2.5 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
selected
? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)] shadow-[inset_0_1px_0_color-mix(in_srgb,var(--workspace-accent)_28%,transparent)]"
: "border-border/85 bg-background hover:border-foreground/25 hover:bg-muted/25",
"model-default-option group relative overflow-hidden rounded-md px-3 py-2.5 text-left transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
selected && "is-selected",
)}
onClick={() => onChange(group.modelKey, model)}
>
<span className="flex min-w-0 items-start gap-2.5">
<span className={cn("grid size-8 shrink-0 place-items-center rounded-md border", selected ? "border-[color-mix(in_srgb,var(--workspace-accent)_28%,transparent)] bg-background/70" : "border-border/70 bg-muted/35")}>
<span className="model-default-option-icon grid size-8 shrink-0 place-items-center rounded-md">
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold">{modelDisplayName(config, model)}</span>
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
<span className="rounded border border-border/70 px-1 py-px text-[var(--fs-micro)]">{channel.scope === "system" ? "系统" : "自定义"}</span>
{creditsEnabled && cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[var(--fs-micro)] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
<span className="model-default-option-scope">{channel.scope === "system" ? "系统" : "自定义"}</span>
{creditsEnabled && cost ? <span className="model-default-price">{formatPrice(cost.unitPriceMicrocredits)} /{cost.billingMode === "per_second" ? "秒" : "次"}</span> : null}
</span>
</span>
<span className={cn("grid size-5 shrink-0 place-items-center rounded-full border", selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-border text-transparent group-hover:border-foreground/30")}>
<span className={cn("model-default-option-check grid size-5 shrink-0 place-items-center rounded-full", selected ? "is-selected" : "text-transparent")}>
<Check className="size-3" strokeWidth={2.5} />
</span>
</span>
@@ -86,7 +83,7 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
})}
</div>
) : (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-center text-xs text-foreground/45">当前渠道中没有可用的{capabilityLabel(group.capability)}模型</div>
<div className="rounded-md bg-surface-active px-4 py-6 text-center text-xs text-foreground/45">当前渠道中没有可用的{capabilityLabel(group.capability)}模型</div>
)}
</section>
);

View File

@@ -1,4 +1,4 @@
import { App, Button, Drawer, Form, Input, Modal, Segmented, Select, Switch, Tooltip, Typography } from "antd";
import { App, Button, Drawer, Form, Input, Modal, Select, Switch, Tooltip, Typography } from "antd";
import { LayoutGrid, List, Plus, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
@@ -19,7 +19,7 @@ import { TaskGridCard } from "./task-grid-card";
import { TaskGroupHeader, type TaskGroup } from "./task-group-header";
import { TaskListRow } from "./task-list-row";
import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared";
import { TaskStatPills, type TaskStatusFilter } from "./task-stat-pills";
import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter";
type TaskKindFilter = "all" | "text" | "image" | "video";
type TaskViewMode = "list" | "grid";
@@ -125,7 +125,7 @@ export default function TasksPage() {
else if (task.status === "succeeded") succeeded += 1;
else if (task.status === "failed" || task.status === "cancelled") failed += 1;
}
return { today, active, succeeded, failed };
return { total: tasks.length, today, active, succeeded, failed };
}, [tasks]);
const groupingActive = viewMode === "list" && groupEnabled;
const visibleTaskGroups = useMemo(
@@ -397,7 +397,6 @@ export default function TasksPage() {
</>
)}
/>
<TaskStatPills stats={taskStats} statusFilter={statusFilter} onFilterChange={setStatusFilter} />
<ListToolbar
className="library-toolbar task-library-toolbar"
active={Boolean(keyword || projectFilter !== "all" || kindFilter !== "all" || modelFilter !== "all" || statusFilter !== "all")}
@@ -421,21 +420,11 @@ export default function TasksPage() {
</div>
)}
>
<TaskStatusFilterBar stats={taskStats} value={statusFilter} onChange={(value) => { setStatusFilter(value); setPage(1); }} />
<Input id="task-search" name="taskSearch" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索任务、模型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-full sm:w-48" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部画布", value: "all" }, ...projectOptions]} />
<Select className="w-full sm:w-32" value={kindFilter} onChange={(value) => { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} />
<Select className="w-full sm:w-44" value={modelFilter} onChange={(value) => { setModelFilter(value); setPage(1); }} options={[{ label: "全部模型", value: "all" }, ...modelOptions.map((model) => ({ label: model, value: model }))]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: "全部", value: "all" },
{ label: "失败/取消", value: "failed" },
{ label: "运行中", value: "active" },
{ label: "已完成", value: "succeeded" },
]}
/>
</ListToolbar>
</div>
@@ -492,7 +481,7 @@ export default function TasksPage() {
<Drawer className="library-drawer" title="任务详情" open={Boolean(detailTask)} onClose={() => setDetailTask(null)} size="large" destroyOnHidden>
{detailTask ? (
<div className="space-y-5">
<div className="grid border-y border-border text-sm sm:grid-cols-2">
<div className="task-detail-facts grid text-sm sm:grid-cols-2">
<InfoItem label="状态" value={statusLabel[detailTask.status]} />
<InfoItem label="画布名称" value={getTaskCanvasContext(detailTask, canvasById, domainProjectNameById).canvasName} />
<InfoItem label="任务类型" value={formatTaskKind(detailTask)} />
@@ -503,7 +492,7 @@ export default function TasksPage() {
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
</div>
{canQueryProviderTask(detailTask) ? <div className="flex justify-end"><Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button></div> : null}
{detailTask.error ? <pre className="max-h-28 overflow-auto whitespace-pre-wrap border-l-2 border-red-500 bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950/30 dark:text-red-300">{generationErrorMessage(detailTask.error)}</pre> : null}
{detailTask.error ? <pre className="task-detail-error max-h-28 overflow-auto whitespace-pre-wrap px-3 py-2 text-xs">{generationErrorMessage(detailTask.error)}</pre> : null}
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
<DetailBlock title="输入" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.inputJson)} />
<DetailBlock title="结果" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.resultJson)} />
@@ -626,7 +615,7 @@ function formatDate(value?: string) {
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 border-b border-border px-0 py-2.5">
<div className="task-detail-fact min-w-0 px-3 py-2.5">
<Typography.Text type="secondary" className="block text-xs">
{label}
</Typography.Text>

View File

@@ -1,36 +0,0 @@
export type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
export type TaskStats = { today: number; active: number; succeeded: number; failed: number };
export function TaskStatPills({ stats, statusFilter, onFilterChange }: { stats: TaskStats; statusFilter: TaskStatusFilter; onFilterChange: (value: TaskStatusFilter) => void }) {
return (
<div className="task-stat-pills">
<span className="task-stat-pill">
<span className="task-stat-dot" />
今日生成<b>{stats.today}</b>
</span>
<button type="button" aria-pressed={statusFilter === "active"} className={`task-stat-pill is-clickable${statusFilter === "active" ? " is-active-filter" : ""}`} onClick={() => onFilterChange(statusFilter === "active" ? "all" : "active")}>
<span className="task-stat-dot is-run" />
运行中<b>{stats.active}</b>
</button>
<button
type="button"
aria-pressed={statusFilter === "succeeded"}
className={`task-stat-pill is-clickable${statusFilter === "succeeded" ? " is-active-filter" : ""}`}
onClick={() => onFilterChange(statusFilter === "succeeded" ? "all" : "succeeded")}
>
<span className="task-stat-dot is-ok" />
已完成<b>{stats.succeeded}</b>
</button>
<button
type="button"
aria-pressed={statusFilter === "failed"}
className={`task-stat-pill is-clickable is-fail${statusFilter === "failed" ? " is-active-filter" : ""}`}
onClick={() => onFilterChange(statusFilter === "failed" ? "all" : "failed")}
>
<span className="task-stat-dot is-bad" />
失败<b>{stats.failed}</b>
</button>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Segmented } from "antd";
export type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
export type TaskStats = { total: number; today: number; active: number; succeeded: number; failed: number };
export function TaskStatusFilterBar({ stats, value, onChange }: { stats: TaskStats; value: TaskStatusFilter; onChange: (value: TaskStatusFilter) => void }) {
const options = [
{ value: "all", label: "全部", count: stats.total },
{ value: "active", label: "运行中", count: stats.active },
{ value: "succeeded", label: "已完成", count: stats.succeeded },
{ value: "failed", label: "失败/取消", count: stats.failed },
] satisfies Array<{ value: TaskStatusFilter; label: string; count: number }>;
return (
<div className="task-status-filter">
<span className="task-status-today">今日生成 <strong>{stats.today}</strong></span>
<Segmented<TaskStatusFilter>
size="small"
value={value}
options={options.map((option) => ({
value: option.value,
label: <span>{option.label}<b>{option.count}</b></span>,
}))}
onChange={onChange}
/>
</div>
);
}

View File

@@ -189,6 +189,7 @@
--z-popover: 200; /* L10 弹出层 */
--z-toast: 500; /* L11 通知 */
--z-tooltip: 1000; /* L12 提示 */
--z-dialog-popover: 1100; /* AntD Modal 之上的设置浮层 */
--z-loader: 1200; /* 全屏加载遮罩 */
--z-max: 2000; /* L13 最高(紧急通知)*/
@@ -259,7 +260,6 @@
@media (prefers-reduced-motion: reduce) {
.canvas-connection-flow,
.canvas-connection-draft,
.canvas-generation-border-flow,
.canvas-task-progress-shimmer,
.ai-title-aurora,
.creation-intro-mark,
@@ -271,7 +271,6 @@
}
.no-motion .canvas-connection-flow,
.no-motion .canvas-connection-draft,
.no-motion .canvas-generation-border-flow,
.no-motion .canvas-task-progress-shimmer,
.no-motion .ai-title-aurora,
.no-motion .creation-intro-mark,
@@ -675,7 +674,6 @@
--dock-blur: 24px;
--dock-surface: var(--cn-spatial-surface);
--dock-border: var(--cn-spatial-toolbar-border);
--dock-shadow: var(--shadow-lg);
--dock-item-radius: var(--r-md); /* 8px */
--dock-item-radius-labeled: var(--r-lg); /* 12px */
--dock-radius-tight: var(--r-tight);
@@ -696,20 +694,8 @@
--canvas-create-node-height: var(--space-12);
--canvas-create-resource-height: var(--space-8);
/* Node 组件(画布节点,对应 #97 节点视觉规范)*/
--node-radius: var(--r-2xl); /* 16px */
--node-border-width: var(--stroke-1); /* 1px */
--node-border: var(--cn-stroke);
--node-active-border-width: var(--stroke-2); /* 2px */
--node-active-border: var(--cn-active-stroke);
--node-active-glow: 0 0 0 4px var(--cn-active-stroke-soft);
--node-active-glow-strong: 0 0 0 6px var(--cn-active-stroke-soft);
--node-shadow: var(--shadow-sm);
--node-shadow-hover: var(--shadow-md);
--node-hover-lift: var(--space-1); /* 4px 节点 hover 静态抬升 */
--node-shadow-selected: var(--shadow-lg);
--node-shadow-selected-primary: var(--shadow-xl);
--node-shadow-active: var(--shadow-2xl);
/* Node 组件:节点首先是内容容器,常态不使用卡片投影或发光。 */
--node-radius: var(--r-lg); /* 12px */
--node-header-height: 24px;
--node-title-fs: var(--fs-label); /* 11px */
--node-badge-fs: var(--fs-tiny); /* 10px */
@@ -719,6 +705,14 @@
--node-z-overlay: 40; /* 节点角标/按钮层 */
--node-z-handle: 50; /* 节点 resize handle 层 */
/* 节点上下文生成器:一层浮动表面,内部仅靠明度和间距组织信息。 */
--canvas-composer-radius: var(--r-lg);
--canvas-composer-surface: var(--workspace-surface-strong);
--canvas-composer-control-surface: color-mix(in srgb, var(--foreground) 5%, transparent);
--canvas-composer-control-hover: color-mix(in srgb, var(--foreground) 8%, transparent);
--canvas-composer-shadow: var(--elevation-card);
--canvas-composer-settings-width: 138px;
/* Modal 组件(对话框,对应 #98 面板层级)*/
--modal-radius: var(--r-lg);
--modal-width-compact: 480px;
@@ -761,11 +755,10 @@
/* Prompt 面板(对应 #98 决策2 渐进式三区)*/
--prompt-panel-max-height: min(70vh, 640px);
--prompt-panel-input-min-height: 72px;
--prompt-panel-input-max-height: 224px;
--prompt-panel-section-fs: var(--fs-body); /* 14px A区 */
--prompt-panel-caption-fs: var(--fs-caption); /* 12px B区 */
--prompt-panel-label-fs: var(--fs-label); /* 11px 标签 */
--settings-model-option-min-height: 76px;
--prompt-panel-heading-fs: var(--fs-heading); /* 16px 区标题 */
/* 进度条(对应 #98 决策3 任务进度)*/
@@ -3629,7 +3622,7 @@
padding: 0;
}
/* 设置页方案 A:桌面偏好面板 */
/* 设置页:导航是稳定区域,内容使用连续 section,不再用卡片套卡片表达层级。 */
.settings-page .settings-topbar {
display: flex;
min-height: 40px;
@@ -3637,7 +3630,7 @@
justify-content: space-between;
gap: 12px;
padding: 0 14px;
background: color-mix(in srgb, var(--foreground) 2%, transparent);
background: transparent;
}
.settings-nav-panel {
@@ -3653,39 +3646,16 @@
color: var(--foreground);
}
.settings-surface-card {
overflow: hidden;
border-radius: var(--r-lg);
background: var(--library-surface);
padding: 16px;
box-shadow: none;
}
.settings-section-card {
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
border-radius: var(--r-lg);
background: color-mix(in srgb, var(--foreground) 2.5%, transparent);
padding: 16px;
box-shadow: var(--elevation-card), var(--elevation-hairline);
}
.settings-section-card + .settings-section-card {
margin-top: 12px;
}
.settings-page section[aria-labelledby^="channel-"] {
border-color: color-mix(in srgb, var(--foreground) 8%, transparent) !important;
border-radius: var(--r-lg);
background: color-mix(in srgb, var(--foreground) 2.5%, transparent);
box-shadow: var(--elevation-card), var(--elevation-hairline);
transition: border-color 180ms ease-out, box-shadow 180ms ease-out;
}
.settings-page section[aria-labelledby^="channel-"]:hover {
border-color: color-mix(in srgb, var(--foreground) 16%, transparent) !important;
box-shadow: var(--elevation-card-hover), var(--elevation-hairline);
}
.settings-pane-root { min-height: 100%; }
.settings-pane { min-width: 0; }
.settings-section { min-width: 0; }
.settings-channel { border: 0; border-radius: var(--r-lg); background: var(--library-surface); transition: background-color var(--motion-state) ease-out; }
.settings-channel:hover, .settings-channel:focus-within { background: var(--library-surface-hover); }
.settings-channel-status { display: inline-flex; min-width: 0; align-items: center; gap: var(--space-1-half); color: color-mix(in srgb, var(--foreground) 52%, transparent); }
.settings-channel-status i { width: var(--space-1-half); height: var(--space-1-half); flex: 0 0 auto; border-radius: var(--r-full); background: currentColor; }
.settings-channel-status.is-ready { color: color-mix(in srgb, var(--palette-status-success) 74%, var(--foreground)); }
.settings-channel-status.is-warning { color: color-mix(in srgb, var(--palette-status-warning) 78%, var(--foreground)); }
.settings-preference-block + .settings-preference-block { margin-top: var(--space-2); }
.settings-pane-header {
display: flex;
@@ -3722,17 +3692,19 @@
letter-spacing: 0.02em;
}
.settings-field-group-label::after {
content: "";
height: 1px;
flex: 1;
background: color-mix(in srgb, var(--foreground) 7%, transparent);
}
.settings-channel-list {
padding-top: 2px;
}
.model-default-option { min-height: var(--settings-model-option-min-height); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); color: var(--foreground); }
.model-default-option:hover { background: color-mix(in srgb, var(--foreground) 5%, transparent); }
.model-default-option.is-selected { background: var(--workspace-accent-soft); }
.model-default-option-icon { background: color-mix(in srgb, var(--foreground) 5%, transparent); }
.model-default-option.is-selected .model-default-option-icon { background: color-mix(in srgb, var(--workspace-surface-strong) 72%, transparent); }
.model-default-option-scope, .model-default-price { color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; }
.model-default-option-check { background: color-mix(in srgb, var(--foreground) 7%, transparent); }
.model-default-option-check.is-selected { background: var(--control-check-bg); color: var(--control-check-fg); }
.settings-page :where(.ant-input, .ant-input-affix-wrapper, .ant-select-selector, .ant-input-number, .ant-picker) {
min-height: 34px;
border-radius: var(--r-md) !important;
@@ -3904,27 +3876,28 @@
/* 任务列表:整屏几十条长 prompt 极易糊成一片,靠三件事拉开密度——
行距(min-height + gap)、标题只给一行且宽度封顶、次要信息降到 42% 前景色。 */
.task-record-list { display: grid; gap: 2px; margin-top: 10px; }
.task-record-row { position: relative; display: grid; min-width: 0; grid-template-columns: 84px minmax(0, 1fr) 120px 104px 104px; align-items: center; gap: 18px; min-height: 92px; margin-bottom: 6px; border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 1.5%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 5%, transparent); padding: 12px 14px; transition: background-color 180ms ease-out, box-shadow 180ms ease-out; }
.task-record-row:hover, .task-record-row:focus-within { background: color-mix(in srgb, var(--foreground) 4%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent); }
.task-record-row { position: relative; display: grid; min-width: 0; grid-template-columns: 84px minmax(0, 1fr) 120px 104px 104px; align-items: center; gap: 18px; min-height: 92px; border-radius: var(--r-md); background: transparent; padding: 12px 14px; transition: background-color var(--motion-state) ease-out; }
.task-record-row:hover, .task-record-row:focus-within { background: color-mix(in srgb, var(--foreground) 4%, transparent); }
.task-record-row.is-attention { background: color-mix(in srgb, var(--palette-status-error) 4%, transparent); }
.task-record-main { min-width: 0; }
.task-record-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; overflow: hidden; }
/* 标题封顶 68ch:超宽屏下一行能塞 200+ 汉字,读者反而找不到落点。 */
.task-record-title { display: block; min-width: 0; max-width: 68ch; flex: 0 1 auto; overflow: hidden; border: 0; background: transparent; padding: 0; color: color-mix(in srgb, var(--foreground) 92%, transparent); text-align: left; font-size: var(--fs-body); font-weight: 560; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.task-record-title:hover { color: var(--foreground); }
.task-record-status { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 5%, transparent); padding: 3px 9px 3px 7px; color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-tiny); line-height: 1.4; white-space: nowrap; }
.task-record-status { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-tiny); line-height: 1.4; white-space: nowrap; }
.task-record-dot { position: relative; display: block; width: 7px; height: 7px; flex: 0 0 auto; border-radius: var(--r-full); }
.task-record-dot.is-pulsing::after { position: absolute; inset: -4px; border-radius: var(--r-full); background: inherit; opacity: 0.45; content: ""; animation: task-record-dot-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite; }
@keyframes task-record-dot-ping { 75%, 100% { transform: scale(2.1); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .task-record-dot.is-pulsing::after { animation: none; } }
.task-record-status.is-failed { background: color-mix(in srgb, var(--palette-status-error) 12%, transparent); color: color-mix(in srgb, var(--palette-status-error) 74%, var(--foreground)); }
.task-record-status.is-active { background: color-mix(in srgb, var(--palette-status-warning) 12%, transparent); color: color-mix(in srgb, var(--palette-status-warning) 74%, var(--foreground)); }
.task-record-status.is-success { background: color-mix(in srgb, var(--palette-status-success) 10%, transparent); color: color-mix(in srgb, var(--palette-status-success) 74%, var(--foreground)); }
.task-record-status.is-failed { color: color-mix(in srgb, var(--palette-status-error) 74%, var(--foreground)); }
.task-record-status.is-active { color: color-mix(in srgb, var(--palette-status-warning) 74%, var(--foreground)); }
.task-record-status.is-success { color: color-mix(in srgb, var(--palette-status-success) 74%, var(--foreground)); }
.task-record-dot.is-success { background: var(--palette-status-success); }
.task-record-dot.is-active { background: var(--palette-status-warning); }
.task-record-dot.is-queued { background: var(--palette-status-loading); }
.task-record-dot.is-failed { background: var(--palette-status-error); }
.task-record-dot.is-idle { background: color-mix(in srgb, var(--foreground) 30%, transparent); }
.task-record-thumb { position: relative; display: grid; width: 84px; height: 60px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 28%, transparent); }
.task-record-thumb { position: relative; display: grid; width: 84px; height: 60px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 28%, transparent); }
.task-record-thumb img, .task-record-thumb video { width: 100%; height: 100%; object-fit: cover; }
.task-record-meta { display: flex; min-width: 0; align-items: center; gap: 8px; margin-top: 7px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); white-space: nowrap; }
.task-record-meta-canvas { display: inline-flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; text-overflow: ellipsis; }
@@ -3950,28 +3923,11 @@
.task-record-actions .ant-btn { width: 32px; height: 32px; padding: 0; border-radius: var(--r-md); }
.task-record-row:hover .task-record-actions, .task-record-row:focus-within .task-record-actions { opacity: 1; transform: translateX(0); }
/* open-vetta 语言补充:渐变标题、统计胶囊、视图切换、分组视图、网格卡片。
全部走既有 token(--foreground / --workspace-accent / --palette-status-* / --r-*),不引入孤立字面值。 */
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.task-library-page .app-page-header-title {
background: linear-gradient(120deg, var(--foreground) 18%, color-mix(in srgb, var(--workspace-accent) 72%, var(--foreground)) 55%, var(--workspace-accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.task-stat-pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 14px; }
.task-stat-pill { display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 12px; border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 3%, transparent); color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-label); line-height: 1; transition: border-color 160ms ease-out, color 160ms ease-out, background-color 160ms ease-out; }
.task-stat-pill b { color: var(--foreground); font-weight: 620; font-variant-numeric: tabular-nums; }
.task-stat-pill.is-clickable { cursor: pointer; }
.task-stat-pill.is-clickable:hover { border-color: color-mix(in srgb, var(--foreground) 18%, transparent); background: color-mix(in srgb, var(--foreground) 5%, transparent); color: var(--foreground); }
.task-stat-pill.is-fail { color: color-mix(in srgb, var(--palette-status-error) 80%, var(--foreground)); }
.task-stat-pill.is-fail b { color: color-mix(in srgb, var(--palette-status-error) 88%, var(--foreground)); }
.task-stat-pill.is-active-filter { border-color: color-mix(in srgb, var(--workspace-accent) 34%, transparent); background: color-mix(in srgb, var(--workspace-accent) 8%, transparent); color: var(--foreground); }
.task-stat-dot { display: block; width: 7px; height: 7px; flex: 0 0 auto; border-radius: var(--r-full); background: currentColor; }
.task-stat-dot.is-run { color: var(--palette-status-warning); }
.task-stat-dot.is-ok { color: var(--palette-status-success); }
.task-stat-dot.is-bad { color: var(--palette-status-error); }
.task-status-filter { display: inline-flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 10px; }
.task-status-today { color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-label); white-space: nowrap; }
.task-status-today strong { margin-left: 4px; color: var(--foreground); font-weight: 620; font-variant-numeric: tabular-nums; }
.task-status-filter .ant-segmented-item-label > span { display: inline-flex; align-items: center; gap: 6px; }
.task-status-filter .ant-segmented-item-label b { color: inherit; font-size: var(--fs-tiny); font-weight: 620; font-variant-numeric: tabular-nums; opacity: .62; }
.task-view-switch { display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 5%, transparent); }
/* AntD 6 按钮样式经 CSS-in-JS 运行时注入(晚于本文件),hover 组合特异性达 .ant-btn:not(:disabled):not(.ant-btn-disabled):hover (0,4,0),必须用 !important 才能稳定覆盖;限定在本组件作用域内,不污染全局。 */
.task-view-switch .ant-btn { border: 0 !important; box-shadow: none !important; }
@@ -3980,26 +3936,26 @@
.task-group { margin-bottom: 22px; }
.task-group:last-child { margin-bottom: 0; }
.task-group-head { display: flex; align-items: center; gap: 12px; padding: 0 2px 10px; }
.task-group-ic { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: var(--r-md); background: color-mix(in srgb, var(--workspace-accent) 12%, transparent); color: var(--workspace-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 22%, transparent); }
.task-group-ic { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 5%, transparent); color: var(--workspace-accent); }
.task-group-ic svg { width: 17px; height: 17px; }
.task-group-name { display: flex; min-width: 0; align-items: center; gap: 9px; font-size: var(--fs-heading-lg); font-weight: 620; letter-spacing: -.01em; }
.task-group-name > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-group-count { flex: 0 0 auto; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 7%, transparent); padding: 3px 9px; color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-tiny); font-weight: 500; font-variant-numeric: tabular-nums; }
.task-group-count { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-tiny); font-weight: 500; font-variant-numeric: tabular-nums; }
.task-group-sub { display: block; margin-top: 4px; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); }
.task-group-sub b.is-bad { color: color-mix(in srgb, var(--palette-status-error) 80%, var(--foreground)); font-weight: 560; }
.task-group-reset { display: inline-flex; align-items: center; gap: 5px; height: 28px; margin-left: auto; flex: 0 0 auto; border: 0; border-radius: var(--r-full); background: color-mix(in srgb, var(--palette-status-error) 10%, transparent); padding: 0 11px; color: color-mix(in srgb, var(--palette-status-error) 82%, var(--foreground)); font-size: var(--fs-label); font-weight: 550; cursor: pointer; transition: background-color 160ms ease-out, color 160ms ease-out; }
.task-group-reset:hover { background: color-mix(in srgb, var(--palette-status-error) 18%, transparent); color: color-mix(in srgb, var(--palette-status-error) 92%, var(--foreground)); }
.task-group-reset { display: inline-flex; align-items: center; gap: 5px; height: 28px; margin-left: auto; flex: 0 0 auto; border: 0; border-radius: var(--r-md); background: transparent; padding: 0 8px; color: color-mix(in srgb, var(--palette-status-error) 78%, var(--foreground)); font-size: var(--fs-label); font-weight: 550; cursor: pointer; transition: background-color 160ms ease-out, color 160ms ease-out; }
.task-group-reset:hover { background: color-mix(in srgb, var(--palette-status-error) 8%, transparent); color: color-mix(in srgb, var(--palette-status-error) 92%, var(--foreground)); }
.task-group-reset svg { width: 13px; height: 13px; }
.task-grid-view { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 12px; margin-top: 10px; }
.task-grid-card { min-width: 0; overflow: hidden; border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 1.5%, transparent); box-shadow: var(--elevation-hairline); transition: border-color 200ms ease-out, background-color 200ms ease-out; }
.task-grid-card:hover, .task-grid-card:focus-within { border-color: color-mix(in srgb, var(--foreground) 13%, transparent); background: color-mix(in srgb, var(--foreground) 3.5%, transparent); }
.task-grid-card.is-attention { border-color: color-mix(in srgb, var(--palette-status-error) 22%, transparent); }
.task-grid-view { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 16px; margin-top: 10px; }
.task-grid-card { min-width: 0; overflow: hidden; border: 0; border-radius: var(--r-lg); background: var(--library-surface); box-shadow: none; transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease-out; }
.task-grid-card:hover, .task-grid-card:focus-within { background: var(--library-surface-hover); transform: translateY(var(--library-hover-offset)); }
.task-grid-card.is-attention .task-grid-body { background: color-mix(in srgb, var(--palette-status-error) 5%, transparent); }
.task-grid-thumb { position: relative; display: grid; aspect-ratio: 16 / 9; place-items: center; overflow: hidden; background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 30%, transparent); }
.task-grid-thumb > svg { width: 26px; height: 26px; }
.task-grid-thumb img, .task-grid-thumb video { width: 100%; height: 100%; object-fit: cover; }
.task-grid-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 6px; background: color-mix(in srgb, var(--background) 58%, transparent); opacity: 0; backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); transition: opacity 200ms ease-out; }
.task-grid-card:hover .task-grid-overlay, .task-grid-card:focus-within .task-grid-overlay { opacity: 1; }
.task-grid-overlay .ant-btn { width: 32px; height: 32px; border-radius: var(--r-md); background: color-mix(in srgb, var(--background) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 14%, transparent); color: var(--foreground); }
.task-grid-overlay .ant-btn { width: 32px; height: 32px; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--background) 86%, transparent); box-shadow: none; color: var(--foreground); }
.task-grid-overlay .ant-btn:hover { color: var(--foreground); background: var(--background); }
.task-grid-body { padding: 10px 12px 11px; }
.task-grid-title { display: block; width: 100%; min-width: 0; overflow: hidden; border: 0; background: transparent; padding: 0; color: color-mix(in srgb, var(--foreground) 90%, transparent); text-align: left; font-size: var(--fs-body); font-weight: 560; line-height: 1.45; cursor: pointer; text-overflow: ellipsis; white-space: nowrap; }
@@ -4013,10 +3969,14 @@
@media (max-width: 640px) {
.task-group-name { flex-wrap: wrap; }
.task-group-reset { margin-left: 0; }
.task-stat-pills { gap: 6px; }
.task-stat-pill { height: 28px; padding: 0 10px; }
.task-status-filter { width: 100%; }
.task-status-filter .ant-segmented { width: 100%; }
}
.task-detail-facts { gap: 2px; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); padding: 4px; }
.task-detail-fact { border-radius: var(--r-sm); }
.task-detail-error { border-radius: var(--r-md); background: color-mix(in srgb, var(--palette-status-error) 8%, transparent); color: color-mix(in srgb, var(--palette-status-error) 78%, var(--foreground)); }
/* 项目概览:原本是「有边框的浅色盒子 + 一堆等重小字」,层级读不出来。
改成:主区留白撑开、标题字阶拉高、右栏用背景色而非边框分隔、流水线行给足行高。 */
.project-overview-focus { overflow: hidden; border-radius: var(--r-xl); background: var(--library-surface); box-shadow: none; }
@@ -4750,15 +4710,11 @@
}
:where(.ant-select-dropdown, .ant-dropdown-menu, .ant-popover-inner) {
border: 1px solid var(--border);
box-shadow: 0 20px 56px rgba(15, 23, 42, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
border: 1px solid var(--workspace-border);
box-shadow: var(--elevation-overlay) !important;
backdrop-filter: blur(22px);
}
.dark :where(.ant-select-dropdown, .ant-dropdown-menu, .ant-popover-inner) {
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.44), inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
}
:where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-select-content) {
transition:
border-color var(--motion-instant) ease-out,
@@ -4976,38 +4932,6 @@
box-shadow: none;
}
.canvas-image-tool-heading-icon,
.canvas-image-tool-option-icon {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
color: color-mix(in srgb, var(--foreground) 52%, transparent);
}
.canvas-image-tool-count {
border-radius: var(--r-xs);
background: var(--control-selected-bg);
padding: 2px 7px;
color: var(--control-selected-fg);
font-size: var(--fs-tiny);
line-height: 1.5;
}
.canvas-image-tool-option {
border: 0;
background: transparent;
color: var(--foreground);
}
.canvas-image-tool-option:hover,
.canvas-image-tool-option.is-selected {
background: var(--control-selected-bg);
color: var(--control-selected-fg);
}
.canvas-image-tool-option.is-selected .canvas-image-tool-option-icon {
background: var(--control-selected-hover-bg);
color: var(--control-selected-fg);
}
.aceternity-dock-separator {
background: color-mix(in srgb, currentColor 13%, transparent);
}
@@ -5020,7 +4944,7 @@
.aceternity-floating-panel {
transform-origin: bottom center;
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.12);
box-shadow: var(--elevation-overlay);
}
.app-workspace-canvas {
@@ -5496,7 +5420,7 @@
display: flex;
gap: 8px;
max-width: 100%;
min-height: 44px;
min-height: 40px;
min-width: 0;
outline: none;
overflow: hidden;
@@ -5510,6 +5434,10 @@
background: color-mix(in srgb, var(--workspace-accent) 9%, transparent) !important;
}
.canvas-model-picker-option[aria-selected="true"] {
background: color-mix(in srgb, var(--workspace-accent) 6%, transparent) !important;
}
.canvas-model-picker-option:focus-visible {
background: color-mix(in srgb, var(--workspace-accent) 13%, transparent) !important;
outline: none;
@@ -5582,11 +5510,6 @@
background: rgba(15, 23, 42, 0.075) !important;
}
html:not(.dark) .canvas-generation-cost-badge {
background: rgba(79, 110, 232, 0.09) !important;
color: #4f6ee8 !important;
}
.canvas-preset-picker-menu {
padding: 6px;
}
@@ -5743,13 +5666,12 @@
animation: canvas-panel-in var(--motion-dur-fast-calc) var(--motion-ease-out) both;
}
/* 覆盖工具栏与节点之间的视觉留白,避免鼠标途中被相邻节点抢走悬浮状态。 */
.canvas-node-toolbar::after {
position: absolute;
top: 100%;
right: -8px;
left: -8px;
height: 14px;
right: 0;
left: 0;
height: 30px;
content: "";
}
@@ -5834,43 +5756,239 @@
.canvas-node-shell,
.canvas-frame-shell {
transition:
border-color var(--motion-state) var(--ease-aceternity),
box-shadow var(--motion-state) var(--ease-aceternity),
background-color var(--motion-state) var(--ease-aceternity),
transform var(--motion-panel) var(--ease-aceternity);
background-color var(--motion-state) var(--ease-aceternity);
}
.canvas-node-shell.aceternity-comet-card {
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.1), 0 2px 6px rgba(15, 23, 42, 0.06);
.canvas-node-shell {
box-shadow: none;
}
.canvas-node-shell.aceternity-comet-card:hover,
.canvas-frame-shell:hover {
transform: translateY(var(--node-hover-lift));
.canvas-node-inline-action {
border: 0;
border-radius: var(--r-md);
background: var(--canvas-composer-surface);
color: var(--cn-text);
box-shadow: var(--elevation-card);
}
.canvas-node-shell[data-canvas-node-hover-locked="true"],
.canvas-frame-shell[data-canvas-frame-hover-locked="true"] {
transform: none !important;
.canvas-node-inline-action:hover {
background: var(--canvas-composer-control-hover);
}
.canvas-node-shell.aceternity-comet-card:hover {
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.18), 0 6px 20px rgba(15, 23, 42, 0.1);
.canvas-node-composer {
position: relative;
overflow: hidden;
border: 0;
border-radius: var(--canvas-composer-radius);
background: var(--canvas-composer-surface);
color: var(--cn-text);
box-shadow: var(--canvas-composer-shadow);
padding: var(--space-3);
}
.dark .canvas-node-shell.aceternity-comet-card {
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.34), 0 2px 8px rgba(0, 0, 0, 0.3);
.canvas-node-composer-header {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
min-height: 28px;
}
.dark .canvas-node-shell.aceternity-comet-card:hover {
box-shadow: 0 34px 90px rgba(0, 0, 0, 0.52), 0 8px 26px rgba(0, 0, 0, 0.36);
.canvas-node-composer-mode {
display: flex;
min-width: 0;
height: 28px;
align-items: center;
gap: var(--space-1);
border-radius: var(--r-md);
background: var(--canvas-composer-control-surface);
padding-inline: var(--space-2);
}
.canvas-node-composer-editor {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: var(--r-md);
background: transparent;
margin-top: var(--space-1);
}
.canvas-node-composer-editor:focus-within {
background: transparent;
}
.canvas-node-composer-references {
display: flex;
min-width: 0;
height: 36px;
flex-shrink: 0;
align-items: center;
gap: var(--space-1);
overflow-x: auto;
padding-inline: var(--space-2);
}
.canvas-node-reference-chip {
display: inline-flex;
min-width: 0;
height: 30px;
flex: 0 0 auto;
align-items: center;
overflow: hidden;
border-radius: var(--r-md);
background: var(--canvas-composer-control-surface);
}
.canvas-node-reference-preview {
width: 30px;
height: 30px;
flex: 0 0 30px;
overflow: hidden;
border: 0;
background: transparent;
padding: 0;
}
.canvas-node-reference-label {
display: inline-flex;
max-width: 112px;
height: 30px;
align-items: center;
gap: var(--space-1);
overflow: hidden;
border: 0;
background: transparent;
padding: 0 var(--space-2);
color: color-mix(in srgb, var(--foreground) 64%, transparent);
font-size: var(--fs-tiny);
}
.canvas-node-reference-label span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-node-composer-parameters {
margin-top: var(--space-1);
padding-top: var(--space-2);
}
.canvas-node-composer-footer {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-1);
margin-top: var(--space-1);
padding-top: var(--space-2);
}
.canvas-node-composer-model {
min-width: 148px;
max-width: 220px;
flex: 1 1 220px;
}
.canvas-node-composer-settings-trigger {
height: 28px !important;
width: var(--canvas-composer-settings-width) !important;
justify-content: flex-start !important;
border: 0 !important;
border-radius: var(--r-md) !important;
padding-inline: var(--space-2) !important;
box-shadow: none !important;
font-size: var(--fs-tiny) !important;
font-weight: 400 !important;
}
.canvas-node-composer-submit {
display: inline-flex !important;
min-width: var(--space-8) !important;
height: var(--space-8) !important;
flex: 0 0 auto;
align-items: center !important;
justify-content: flex-end !important;
gap: var(--space-1) !important;
border: 0 !important;
border-radius: var(--r-full) !important;
background: var(--canvas-composer-control-surface) !important;
padding: calc(var(--space-1) / 2) !important;
box-shadow: none !important;
font-size: var(--fs-tiny) !important;
font-weight: 600 !important;
line-height: 1 !important;
}
.canvas-node-composer-submit.has-cost {
padding-left: var(--space-2) !important;
}
.canvas-node-composer-submit:not(:disabled):hover {
background: var(--canvas-composer-control-hover) !important;
}
.canvas-node-composer-submit-cost {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.canvas-node-composer-submit-cost > :first-child {
font-size: var(--fs-label);
}
.canvas-node-composer-submit-action {
display: grid;
width: calc(var(--space-8) - var(--space-1));
height: calc(var(--space-8) - var(--space-1));
flex: 0 0 calc(var(--space-8) - var(--space-1));
place-items: center;
border-radius: var(--r-full);
background: var(--canvas-composer-submit-action);
color: var(--canvas-composer-submit-action-fg);
}
.canvas-node-composer-resize-handle {
display: grid;
width: 100%;
height: var(--space-4);
place-items: center;
border: 0;
background: transparent;
padding: 0;
cursor: row-resize;
touch-action: none;
}
.canvas-node-composer-resize-handle > span {
width: var(--space-12);
height: var(--space-1);
border-radius: var(--r-full);
background: color-mix(in srgb, var(--foreground) 22%, transparent);
transition: background-color var(--motion-instant) ease-out;
}
.canvas-node-composer-resize-handle:hover > span,
.canvas-node-composer-resize-handle:active > span,
.canvas-node-composer-resize-handle:focus-visible > span {
background: color-mix(in srgb, var(--foreground) 42%, transparent);
}
.canvas-node-composer-resize-handle:focus-visible {
outline: none;
}
[data-canvas-viewport-interacting="true"] .canvas-node-shell,
[data-canvas-viewport-interacting="true"] .canvas-frame-shell,
[data-canvas-viewport-interacting="true"] .aceternity-comet-card {
transition: none !important;
transform: none !important;
}
/* 静止期用最终倍率重新排版 DOM,避免 Windows Chromium 复用交互期的低分辨率合成纹理。 */
@@ -5888,12 +6006,7 @@
will-change: transform;
}
[data-canvas-viewport-interacting="true"] .canvas-node-shell.aceternity-comet-card,
[data-canvas-viewport-interacting="true"] .canvas-node-shell.aceternity-comet-card:hover {
box-shadow: none !important;
}
[data-canvas-viewport-interacting="true"] :where(.canvas-node-shell-generating::before, .canvas-connection-flow, .canvas-connection-draft) {
[data-canvas-viewport-interacting="true"] :where(.canvas-connection-flow, .canvas-connection-draft) {
animation: none !important;
}
@@ -5913,29 +6026,6 @@
animation: canvas-task-progress-shimmer 1.4s linear infinite;
}
.canvas-node-shell-generating::before {
position: absolute;
z-index: 45;
inset: -1.5px;
padding: 1px;
border-radius: 19px;
background: linear-gradient(110deg, rgba(91, 110, 225, 0.66), rgba(96, 165, 250, 0.62), rgba(167, 139, 250, 0.58), rgba(244, 114, 182, 0.54), rgba(250, 204, 21, 0.5), rgba(52, 211, 153, 0.58), rgba(91, 110, 225, 0.66));
background-size: 300% 100%;
content: "";
filter: drop-shadow(0 0 3px rgba(96, 165, 250, 0.2));
pointer-events: none;
animation: canvas-generation-border-flow 2.4s var(--motion-ease-linear) infinite;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.dark .canvas-node-shell-generating::before {
filter: drop-shadow(0 0 4px rgba(91, 110, 225, 0.22));
}
.canvas-connection-flow {
animation: canvas-connection-flow 1.25s var(--motion-ease-linear) infinite;
}
@@ -5962,11 +6052,6 @@
}
}
@keyframes canvas-generation-border-flow {
to {
background-position: 300% 0;
}
}
.canvas-node-info-modal .ant-modal-header {
padding-right: 0;
@@ -6069,15 +6154,10 @@
transition-duration: 0.01ms !important;
}
.canvas-node-shell-generating::before,
.canvas-connection-flow,
.canvas-connection-draft {
animation: none;
}
.canvas-node-shell-generating::before {
background-position: 50% 0;
}
}
/* 章节编辑区用清晰边界区分工具栏与正文,同时保留连续写作空间。 */