feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选

This commit is contained in:
ddCat
2026-09-08 17:26:34 +08:00
parent 95b004ec0f
commit 068a8daea6
56 changed files with 3316 additions and 274 deletions

View File

@@ -0,0 +1,27 @@
import type { CSSProperties } from "react";
import { AlertCircle, ImageUp, FileText, Film, Music2 } from "lucide-react";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { uploadVeilOpacity } from "@/lib/canvas/canvas-file-upload";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { useCanvasNodeActions } from "./canvas-node-action-context";
export function CanvasFileUploadContent({ node, theme, reduceMotion = false }: { node: CanvasNodeData; theme: CanvasTheme; reduceMotion?: boolean }) {
const { upload } = useCanvasNodeActions();
const failed = node.metadata?.fileUpload === "error";
const percent = node.metadata?.fileUploadProgress;
const label = node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "文本";
const Icon = failed ? AlertCircle : node.type === CanvasNodeType.Video ? Film : node.type === CanvasNodeType.Audio ? Music2 : node.type === CanvasNodeType.Text ? FileText : ImageUp;
return (
<div className="canvas-file-upload" data-static={reduceMotion || failed || undefined} style={{ "--upload-surface": theme.node.fill, "--upload-text": theme.node.text, "--upload-muted": theme.node.muted } as CSSProperties}>
<div className="canvas-file-upload-glow" aria-hidden="true" style={{ opacity: uploadVeilOpacity(percent) }} />
<div className="canvas-file-upload-frost" aria-hidden="true" style={{ opacity: uploadVeilOpacity(percent) }} />
<div className="canvas-file-upload-copy" role="status" aria-live="polite" aria-busy={!failed}>
<Icon className="size-5" aria-hidden="true" />
<span className="canvas-file-upload-label">{failed ? `${label}上传未完成` : `正在上传${label}`}</span>
<span className="canvas-file-upload-detail">{failed ? node.metadata?.errorDetails || "请重新选择文件上传" : percent === 100 ? "文件已传输,正在保存与处理" : percent !== undefined ? `已上传 ${percent}%` : "正在准备文件"}</span>
{!failed && <span role="progressbar" aria-label={`${label}上传进度`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="sr-only" />}
{failed && upload ? <button type="button" data-canvas-no-zoom className="canvas-file-upload-retry" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); upload(node); }}>重新选择文件</button> : null}
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler,
// 通过 Context 注入,避免改动 world-layers。无 Provider 时静默降级为 no-op。
export type CanvasNodeActionContextValue = {
upload?: (node: CanvasNodeData) => void;
download?: (node: CanvasNodeData) => void;
duplicate?: (node: CanvasNodeData) => void;
deleteNode?: (node: CanvasNodeData) => void;

View File

@@ -8,6 +8,7 @@ import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
@@ -26,6 +27,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import { CanvasAudioPlayer } from "./canvas-audio-player";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
import { CanvasFileUploadContent } from "./canvas-file-upload-content";
import { MarkdownNodeContent } from "./nodes/markdown-node";
import { ChartNodeContent } from "./nodes/chart-node";
import { CompareNodeContent } from "./nodes/compare-node";
@@ -62,6 +64,7 @@ export type CanvasNodeContentProps = {
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
const hasCustomContent = props.node.type === CanvasNodeType.Config
|| props.node.type === CanvasNodeType.Script
|| Boolean(props.node.metadata?.directorSceneId)
@@ -496,6 +499,14 @@ function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeContentPro
const updateMetadataRef = useRef(updateMetadata);
const [hydrating, setHydrating] = useState(false);
useEffect(() => {
const element = previewRef.current;
if (!element) return;
const content = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content;
return bindCanvasVideoHoverPreview(element, () => resolveMediaUrl(node.metadata?.storageKey, fallback));
}, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]);
useEffect(() => {
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);

View File

@@ -5,6 +5,7 @@ import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, C
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasConnectionTilt } from "@/lib/canvas/canvas-connection-tilt";
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -28,6 +29,7 @@ type CanvasNodeProps = {
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
connectionApproach?: Position;
forceInputVisible?: boolean;
showImageInfo: boolean;
reduceMediaEffects?: boolean;
@@ -75,6 +77,7 @@ export const CanvasNode = React.memo(function CanvasNode({
isRelated,
isFocusRelated,
isConnectionTarget,
connectionApproach,
forceInputVisible = false,
showImageInfo,
reduceMediaEffects = false,
@@ -265,6 +268,9 @@ export const CanvasNode = React.memo(function CanvasNode({
if (next !== data.title) onTitleChange?.(data.id, next);
};
const connectionTilt = isConnectionTarget && !reduceMediaEffects && !dragOffset
? canvasConnectionTilt(data, connectionApproach) : undefined;
return (
<div
data-node-id={data.id}
@@ -302,13 +308,17 @@ export const CanvasNode = React.memo(function CanvasNode({
<div
className="canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)]"
data-node-state={nodeState}
data-connection-tilt={connectionTilt ? "true" : undefined}
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
style={{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
// 固定占位但不绘制描边,避免聚焦切换时边框宽度变化造成白边跳动。
border: isComposerNode ? "0" : "1px solid transparent",
boxShadow: isComposerNode ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
}}
"--connection-tilt-x": `${connectionTilt?.rotateX || 0}deg`,
"--connection-tilt-y": `${connectionTilt?.rotateY || 0}deg`,
transformOrigin: connectionTilt?.origin,
} as React.CSSProperties}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
if (isBatchRoot) {
@@ -488,6 +498,8 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.isRelated === next.isRelated &&
previous.isFocusRelated === next.isFocusRelated &&
previous.isConnectionTarget === next.isConnectionTarget &&
previous.connectionApproach?.x === next.connectionApproach?.x &&
previous.connectionApproach?.y === next.connectionApproach?.y &&
previous.forceInputVisible === next.forceInputVisible &&
previous.showImageInfo === next.showImageInfo &&
previous.reduceMediaEffects === next.reduceMediaEffects &&

View File

@@ -6,6 +6,7 @@ import { Palette, Info } from "lucide-react";
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { CanvasAppearanceControls } from "@/components/canvas/canvas-appearance-controls";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal";
@@ -89,6 +90,7 @@ export function CanvasToolbar({
onOpenProjectCharacters: () => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)");
const dockRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
@@ -98,6 +100,10 @@ export function CanvasToolbar({
const [panelX, setPanelX] = useState(0);
const [prefs, setPrefs] = useState<ToolbarPrefs | null>(() => readToolbarPrefs("main"));
useEffect(() => {
if (addOpen || appearanceOpen) bringToFront();
}, [addOpen, appearanceOpen, bringToFront]);
// 设置面板关闭后重新读取偏好(用户可能调整了排序/显隐)
useEffect(() => {
if (!settingsOpen) setPrefs(readToolbarPrefs("main"));
@@ -186,7 +192,7 @@ export function CanvasToolbar({
const createCommands = useCanvasCreateCommands(ctx, runAddAction);
return (
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] z-[var(--z-toolbar)] flex justify-center">
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] flex justify-center" style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}>
<AnimatePresence>
{addOpen ? (
<AddNodeMenu

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { App, Button, Modal, Upload, type UploadFile } from "antd";
import { FileImage, Film, Music2, UploadCloud, X } from "lucide-react";
import { FileImage, FileText, Film, Music2, UploadCloud, X } from "lucide-react";
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
const CANVAS_UPLOAD_ACCEPT = "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
import { CANVAS_UPLOAD_ACCEPT, isTextUploadFile, uploadNodeType } from "@/lib/canvas/canvas-file-upload";
type CanvasUploadModalProps = {
open: boolean;
@@ -29,7 +29,9 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
if (!files.length) return;
setUploading(true);
try {
if (await onUpload(files)) onClose();
const pendingUpload = onUpload(files);
onClose();
await pendingUpload;
} catch (error) {
message.error(error instanceof Error ? error.message : "文件上传失败,请稍后重试");
} finally {
@@ -54,7 +56,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border py-0 pl-5 pr-12">
<div className="min-w-0">
<div role="heading" aria-level={2} className="text-sm font-semibold leading-5">上传文件</div>
<div className="mt-0.5 text-[var(--fs-label)] leading-4 text-foreground/45">批量导入图片、视频和音频到当前画布</div>
<div className="mt-0.5 text-[var(--fs-label)] leading-4 text-foreground/45">批量导入图片、视频、音频和文本到当前画布</div>
</div>
<span className="shrink-0 text-[var(--fs-label)] text-foreground/45">已选 {fileList.length} 项</span>
</header>
@@ -67,7 +69,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
fileList={fileList}
beforeUpload={(file) => {
if (isCanvasUploadFile(file)) return false;
message.warning(`“${file.name}”不是支持的图片、视频或音频文件`);
message.warning(`“${file.name}”不是支持的图片、视频、音频或 TXT / Markdown 文件`);
return Upload.LIST_IGNORE;
}}
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
@@ -86,7 +88,8 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
<div className="mt-4 flex flex-wrap items-center justify-center gap-3 text-[var(--fs-label)] text-foreground/45" aria-label="支持的文件类型">
<span className="inline-flex items-center gap-1"><FileImage className="size-3.5" aria-hidden="true" />图片</span>
<span className="inline-flex items-center gap-1"><Film className="size-3.5" aria-hidden="true" />视频</span>
<span className="inline-flex items-center gap-1"><Music2 className="size-3.5" aria-hidden="true" />MP3 / WAV</span>
<span className="inline-flex items-center gap-1"><Music2 className="size-3.5" aria-hidden="true" />音频</span>
<span className="inline-flex items-center gap-1"><FileText className="size-3.5" aria-hidden="true" />TXT / Markdown</span>
</div>
</div>
</Upload.Dragger>
@@ -148,11 +151,11 @@ function CanvasUploadFilePreview({ file }: { file: UploadFile }) {
}
return (
<div className="grid size-full place-items-center text-foreground/45">
{source && isAudioFile(source) ? <Music2 className="size-7" aria-hidden="true" /> : <FileImage className="size-7" aria-hidden="true" />}
{source && isTextUploadFile(source) ? <FileText className="size-7" aria-hidden="true" /> : source && (source.type.startsWith("audio/") || isAudioFile(source)) ? <Music2 className="size-7" aria-hidden="true" /> : <FileImage className="size-7" aria-hidden="true" />}
</div>
);
}
function isCanvasUploadFile(file: File) {
return file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file);
return Boolean(uploadNodeType(file));
}

View File

@@ -397,6 +397,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
return (
<div
ref={containerRef}
data-canvas-viewport
data-canvas-pan-state={isPanning ? "grabbing" : isSpacePressed || !boxSelectEnabled ? "grab" : undefined}
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : isSpacePressed || !boxSelectEnabled ? "cursor-grab" : "canvas-cursor-select"}`}
style={{