feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填

选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。

补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。

验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。

Refs #547

Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
ddCat
2026-09-20 16:40:08 +08:00
parent 0ea9f7d94b
commit 9148ceab66
66 changed files with 6127 additions and 97 deletions

View File

@@ -0,0 +1,181 @@
import { useUserStore } from "@/stores/use-user-store";
import { useEffect, useState } from "react";
import { Dropdown } from "antd";
import { useInfiniteQuery } from "@tanstack/react-query";
import { LoaderCircle, Sparkles, Wrench, X, XCircle } from "lucide-react";
import { listTools, type ToolScope, type ToolSummary } from "@/services/api/tools";
import { FEED_TABS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation";
const EFFECT_TOOL_PAGE_SIZE = 40;
const SEARCH_DEBOUNCE_MS = 250;
export function CanvasChooseEffectPicker({
open,
onOpenChange,
activeToolId,
activeLabel,
scope = "public",
onSelect,
onClear,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 当前提示词中 effect 工具标签的 toolId,未选择时为 undefined。 */
activeToolId?: number;
/** 当前 effect 标签的 label 段,列表未加载或查不到时用于按钮回显。 */
activeLabel?: string;
/** 工具范围初始值(public/favorites/custom),弹层内可切换,语义与侧边栏工具面板一致。 */
scope?: ToolScope;
onSelect: (toolId: number, label: string) => void;
/** 关闭已选特效:从提示词移除 effect 工具标签。 */
onClear?: () => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchText, setSearchText] = useState("");
const [scopeTab, setScopeTab] = useState(scope);
const actualOpen = open ?? internalOpen;
const setOpen = (next: boolean) => {
setInternalOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
const timer = window.setTimeout(() => setSearchText(searchInput.trim()), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [searchInput]);
const userId = useUserStore((s) => s.user?.id);
const toolsQuery = useInfiniteQuery({
queryKey: ["canvas-tools", userId, "effect-picker", scopeTab, searchText],
queryFn: ({ signal, pageParam }) => listTools({ page: pageParam, pageSize: EFFECT_TOOL_PAGE_SIZE, scope: scopeTab, type: "effect", search: searchText || undefined }, { signal }),
initialPageParam: 1,
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined),
enabled: actualOpen && Boolean(userId),
});
const tools = toolsQuery.data?.pages.flatMap((page) => page.tools) ?? [];
const activeTool = tools.find((tool) => tool.id === activeToolId);
const resolvedLabel = activeTool?.label || activeLabel;
const handleSelect = (tool: ToolSummary) => {
setOpen(false);
onSelect(tool.id, tool.label);
};
return (
<Dropdown
open={actualOpen}
onOpenChange={setOpen}
trigger={["click"]}
autoAdjustOverflow
menu={{ items: [] }}
popupRender={() => (
<div
className="canvas-node-toolbar-menu canvas-node-toolbar-menu-style"
data-canvas-no-zoom
data-canvas-wheel-scroll
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<div className="canvas-node-toolbar-menu-stack">
<div className="asset-filters border-b border-border/70 px-2 py-1.5">
<div className="asset-filter-row">
<div className="col-feed-tabs shrink-0">
{FEED_TABS.map((tab) => (
<button key={tab.id} type="button" className={`col-feed-tab${scopeTab === tab.id ? " on" : ""}`} aria-pressed={scopeTab === tab.id} onClick={() => setScopeTab(tab.id)}>
{tab.label}
</button>
))}
</div>
<input className="asset-search min-w-0 flex-1" placeholder="搜索名称/标签" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} aria-label="搜索特效" />
{searchInput ? (
<button type="button" className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover hover:text-foreground" onClick={() => setSearchInput("")} aria-label="清空搜索">
<X className="size-3" />
</button>
) : null}
</div>
</div>
<div className="thin-scrollbar grid max-h-80 grid-cols-5 gap-1.5 overflow-y-auto p-2" role="listbox" aria-label="特效工具">
{toolsQuery.isPending ? (
<div className="col-span-5 grid h-24 place-items-center text-foreground/40">
<LoaderCircle className="size-4 animate-spin" />
</div>
) : toolsQuery.isError ? (
<div className="col-span-5 grid h-24 place-items-center text-xs text-foreground/40">特效工具加载失败,请稍后重试</div>
) : tools.length === 0 ? (
<div className="col-span-5 grid h-24 place-items-center text-xs text-foreground/40">{searchText ? "没有匹配的特效" : "暂无特效工具"}</div>
) : (
tools.map((tool) => <EffectToolCard key={tool.id} tool={tool} selected={tool.id === activeToolId} onSelect={handleSelect} />)
)}
</div>
{toolsQuery.hasNextPage && (
<button type="button" disabled={toolsQuery.isFetchingNextPage} onClick={() => void toolsQuery.fetchNextPage()}>
加载更多
</button>
)}
</div>
</div>
)}
>
<div
role="button"
tabIndex={0}
className="canvas-node-composer-header-action canvas-node-fixed-chip relative overflow-hidden cursor-pointer"
title={resolvedLabel ? `特效:${resolvedLabel}` : "选择特效"}
aria-expanded={actualOpen}
aria-haspopup="menu"
onClick={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
}}
onPointerDown={(event) => event.stopPropagation()}
>
<Sparkles className="size-3 shrink-0" />
<span className="truncate">{resolvedLabel || "特效"}</span>
{activeToolId != null && onClear ? (
<button
type="button"
aria-label="移除特效"
onClick={(event) => {
event.stopPropagation();
onClear();
}}
>
<XCircle className="size-3" />
</button>
) : null}
</div>
</Dropdown>
);
}
function EffectToolCard({ tool, selected, onSelect }: { tool: ToolSummary; selected: boolean; onSelect: (tool: ToolSummary) => void }) {
const coverUrl = toAbsoluteUrl(tool.cover);
return (
<button
type="button"
role="option"
aria-selected={selected}
title={tool.label}
className={`group flex flex-col gap-1 rounded-[var(--r-md)] border p-1.5 text-left transition-colors hover:bg-[var(--surface-hover)] focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:outline-none ${selected ? "border-primary/60 ring-1 ring-primary/40" : "border-transparent"}`}
onClick={() => onSelect(tool)}
>
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-[var(--r-sm)] border border-border/60">
{coverUrl ? (
<img src={coverUrl} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center text-foreground/30">
<Wrench className="size-4" />
</div>
)}
</div>
<span className="truncate text-center text-[11px] font-medium leading-4 text-foreground">{tool.label}</span>
</button>
);
}

View File

@@ -0,0 +1,192 @@
import { useUserStore } from "@/stores/use-user-store";
import { useEffect, useState } from "react";
import { Dropdown } from "antd";
import { useInfiniteQuery } from "@tanstack/react-query";
import { LoaderCircle, Palette, Wrench, X } from "lucide-react";
import { listTools, type ToolScope, type ToolSummary } from "@/services/api/tools";
import { FEED_TABS, SUB_TAB_TAGS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation";
const STYLE_TOOL_PAGE_SIZE = 40;
const SEARCH_DEBOUNCE_MS = 250;
export function CanvasChooseImageStylePicker({
open,
onOpenChange,
activeToolId,
activeLabel,
scope = "public",
tag,
onSelect,
onClear,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 当前提示词中 style 工具标签的 toolId,未选择时为 undefined。 */
activeToolId?: number;
/** 当前 style 标签的 label 段,列表未加载或查不到时用于按钮回显。 */
activeLabel?: string;
/** 工具范围初始值(public/favorites/custom),弹层内可切换,语义与侧边栏工具面板一致。 */
scope?: ToolScope;
/** 标签过滤初始值,弹层内可切换。 */
tag?: string;
onSelect: (toolId: number, label: string) => void;
/** 关闭已选风格:从提示词移除 style 工具标签。 */
onClear?: () => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchText, setSearchText] = useState("");
const [scopeTab, setScopeTab] = useState(scope);
const [tagFilter, setTagFilter] = useState(tag ?? "");
const actualOpen = open ?? internalOpen;
const setOpen = (next: boolean) => {
setInternalOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
const timer = window.setTimeout(() => setSearchText(searchInput.trim()), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [searchInput]);
const userId = useUserStore((s) => s.user?.id);
const toolsQuery = useInfiniteQuery({
queryKey: ["canvas-tools", userId, "style-picker", scopeTab, tagFilter, searchText],
queryFn: ({ signal, pageParam }) => listTools({ page: pageParam, pageSize: STYLE_TOOL_PAGE_SIZE, scope: scopeTab, type: "style", tag: tagFilter || undefined, search: searchText || undefined }, { signal }),
initialPageParam: 1,
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined),
enabled: actualOpen && Boolean(userId),
});
const tools = toolsQuery.data?.pages.flatMap((page) => page.tools) ?? [];
const activeTool = tools.find((tool) => tool.id === activeToolId);
const resolvedLabel = activeTool?.label || activeLabel;
const handleSelect = (tool: ToolSummary) => {
setOpen(false);
onSelect(tool.id, tool.label);
};
return (
<Dropdown
open={actualOpen}
onOpenChange={setOpen}
trigger={["click"]}
autoAdjustOverflow
menu={{ items: [] }}
popupRender={() => (
<div
className="canvas-node-toolbar-menu canvas-node-toolbar-menu-style"
data-canvas-no-zoom
data-canvas-wheel-scroll
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<div className="canvas-node-toolbar-menu-stack">
<div className="asset-filters border-b border-border/70 px-2 py-1.5">
<div className="asset-filter-row">
<div className="col-feed-tabs shrink-0">
{FEED_TABS.map((tab) => (
<button key={tab.id} type="button" className={`col-feed-tab${scopeTab === tab.id ? " on" : ""}`} aria-pressed={scopeTab === tab.id} onClick={() => setScopeTab(tab.id)}>
{tab.label}
</button>
))}
</div>
<input className="asset-search min-w-0 flex-1" placeholder="搜索名称/标签" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} aria-label="搜索风格" />
{searchInput ? (
<button type="button" className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover hover:text-foreground" onClick={() => setSearchInput("")} aria-label="清空搜索">
<X className="size-3" />
</button>
) : null}
</div>
<div className="col-tag-filter mt-1.5">
{[{ id: "", label: "全部" }, ...SUB_TAB_TAGS.style].map((chip) => (
<button key={chip.id || "all"} type="button" className={`col-tag-chip${tagFilter === chip.id ? " on" : ""}`} aria-pressed={tagFilter === chip.id} onClick={() => setTagFilter(chip.id)}>
{chip.label}
</button>
))}
</div>
</div>
<div className="thin-scrollbar grid max-h-80 grid-cols-5 gap-1.5 overflow-y-auto p-2" role="listbox" aria-label="风格工具">
{toolsQuery.isPending ? (
<div className="col-span-3 grid h-24 place-items-center text-foreground/40">
<LoaderCircle className="size-4 animate-spin" />
</div>
) : toolsQuery.isError ? (
<div className="col-span-3 grid h-24 place-items-center text-xs text-foreground/40">风格工具加载失败,请稍后重试</div>
) : tools.length === 0 ? (
<div className="col-span-3 grid h-24 place-items-center text-xs text-foreground/40">{searchText ? "没有匹配的风格" : "暂无风格工具"}</div>
) : (
tools.map((tool) => <StyleToolCard key={tool.id} tool={tool} selected={tool.id === activeToolId} onSelect={handleSelect} />)
)}
</div>
{toolsQuery.hasNextPage && (
<button type="button" disabled={toolsQuery.isFetchingNextPage} onClick={() => void toolsQuery.fetchNextPage()}>
加载更多
</button>
)}
</div>
</div>
)}
>
<div
role="button"
tabIndex={0}
className="canvas-node-composer-header-action canvas-node-fixed-chip relative overflow-hidden cursor-pointer"
title={resolvedLabel ? `风格:${resolvedLabel}` : "选择风格"}
aria-expanded={actualOpen}
aria-haspopup="menu"
onClick={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
}}
onPointerDown={(event) => event.stopPropagation()}
>
<Palette className="size-3 shrink-0" />
<span className="truncate">{resolvedLabel || "风格"}</span>
{activeToolId != null && onClear ? (
<button
type="button"
aria-label="移除风格"
onClick={(event) => {
event.stopPropagation();
onClear();
}}
>
<X className="size-3" />
</button>
) : null}
</div>
</Dropdown>
);
}
function StyleToolCard({ tool, selected, onSelect }: { tool: ToolSummary; selected: boolean; onSelect: (tool: ToolSummary) => void }) {
const coverUrl = toAbsoluteUrl(tool.cover);
return (
<button
type="button"
role="option"
aria-selected={selected}
title={tool.label}
className={`group flex flex-col gap-1 rounded-[var(--r-md)] border p-1.5 text-left transition-colors hover:bg-[var(--surface-hover)] focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:outline-none ${selected ? "border-primary/60 ring-1 ring-primary/40" : "border-transparent"}`}
onClick={() => onSelect(tool)}
>
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-[var(--r-sm)] border border-border/60">
{coverUrl ? (
<img src={coverUrl} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center text-foreground/30">
<Wrench className="size-4" />
</div>
)}
</div>
<span className="truncate text-center text-[11px] font-medium leading-4 text-foreground">{tool.label}</span>
</button>
);
}

View File

@@ -0,0 +1,212 @@
import { useUserStore } from "@/stores/use-user-store";
import { useEffect, useRef, useState } from "react";
import { Dropdown } from "antd";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Camera, LoaderCircle, Wrench, X } from "lucide-react";
import { listTools, type ToolScope, type ToolSummary } from "@/services/api/tools";
import { FEED_TABS, SUB_TAB_TAGS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation";
const MOTION_TOOL_PAGE_SIZE = 40;
const SEARCH_DEBOUNCE_MS = 250;
export function CanvasChooseMotionPicker({
open,
onOpenChange,
activeToolIds,
activeLabel,
scope = "public",
tag,
onSelect,
onClear,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 当前提示词中所有 motion 工具标签的 toolId 列表,未选择时为空。 */
activeToolIds?: number[];
/** 当前 motion 标签的 label 段,列表未加载或查不到时用于按钮回显。 */
activeLabel?: string;
/** 工具范围初始值(public/favorites/custom),弹层内可切换,语义与侧边栏工具面板一致。 */
scope?: ToolScope;
/** 标签过滤初始值,弹层内可切换。 */
tag?: string;
onSelect: (toolId: number, label: string) => void;
/** 关闭已选运镜:从提示词移除 motion 工具标签。 */
onClear?: () => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchText, setSearchText] = useState("");
const [scopeTab, setScopeTab] = useState(scope);
const [tagFilter, setTagFilter] = useState(tag ?? "");
const actualOpen = open ?? internalOpen;
const setOpen = (next: boolean) => {
setInternalOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
const timer = window.setTimeout(() => setSearchText(searchInput.trim()), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [searchInput]);
const userId = useUserStore((s) => s.user?.id);
const toolsQuery = useInfiniteQuery({
queryKey: ["canvas-tools", userId, "motion-picker", scopeTab, tagFilter, searchText],
queryFn: ({ signal, pageParam }) => listTools({ page: pageParam, pageSize: MOTION_TOOL_PAGE_SIZE, scope: scopeTab, type: "motion", tag: tagFilter || undefined, search: searchText || undefined }, { signal }),
initialPageParam: 1,
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined),
enabled: actualOpen && Boolean(userId),
});
const tools = toolsQuery.data?.pages.flatMap((page) => page.tools) ?? [];
const activeIdSet = new Set(activeToolIds ?? []);
const activeTool = tools.find((tool) => activeIdSet.has(tool.id));
const resolvedLabel = activeTool?.label || activeLabel;
const handleSelect = (tool: ToolSummary) => {
setOpen(false);
onSelect(tool.id, tool.label);
};
return (
<Dropdown
open={actualOpen}
onOpenChange={setOpen}
trigger={["click"]}
autoAdjustOverflow
menu={{ items: [] }}
popupRender={() => (
<div
className="canvas-node-toolbar-menu canvas-node-toolbar-menu-style"
data-canvas-no-zoom
data-canvas-wheel-scroll
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<div className="canvas-node-toolbar-menu-stack">
<div className="asset-filters border-b border-border/70 px-2 py-1.5">
<div className="asset-filter-row">
<div className="col-feed-tabs shrink-0">
{FEED_TABS.map((tab) => (
<button key={tab.id} type="button" className={`col-feed-tab${scopeTab === tab.id ? " on" : ""}`} aria-pressed={scopeTab === tab.id} onClick={() => setScopeTab(tab.id)}>
{tab.label}
</button>
))}
</div>
<input className="asset-search min-w-0 flex-1" placeholder="搜索名称/标签" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} aria-label="搜索运镜" />
{searchInput ? (
<button type="button" className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover hover:text-foreground" onClick={() => setSearchInput("")} aria-label="清空搜索">
<X className="size-3" />
</button>
) : null}
</div>
<div className="col-tag-filter mt-1.5">
{[{ id: "", label: "全部" }, ...SUB_TAB_TAGS.motion].map((chip) => (
<button key={chip.id || "all"} type="button" className={`col-tag-chip${tagFilter === chip.id ? " on" : ""}`} aria-pressed={tagFilter === chip.id} onClick={() => setTagFilter(chip.id)}>
{chip.label}
</button>
))}
</div>
</div>
<div className="thin-scrollbar grid max-h-80 grid-cols-5 gap-1.5 overflow-y-auto p-2" role="listbox" aria-label="运镜工具">
{toolsQuery.isPending ? (
<div className="col-span-5 grid h-24 place-items-center text-foreground/40">
<LoaderCircle className="size-4 animate-spin" />
</div>
) : toolsQuery.isError ? (
<div className="col-span-5 grid h-24 place-items-center text-xs text-foreground/40">运镜工具加载失败,请稍后重试</div>
) : tools.length === 0 ? (
<div className="col-span-5 grid h-24 place-items-center text-xs text-foreground/40">{searchText ? "没有匹配的运镜" : "暂无运镜工具"}</div>
) : (
tools.map((tool) => <MotionToolCard key={tool.id} tool={tool} selected={activeIdSet.has(tool.id)} onSelect={handleSelect} />)
)}
</div>
{toolsQuery.hasNextPage && (
<button type="button" disabled={toolsQuery.isFetchingNextPage} onClick={() => void toolsQuery.fetchNextPage()}>
加载更多
</button>
)}
</div>
</div>
)}
>
<div
role="button"
tabIndex={0}
className="canvas-node-composer-header-action canvas-node-fixed-chip relative overflow-hidden cursor-pointer"
title={resolvedLabel ? `运镜:${resolvedLabel}` : "选择运镜"}
aria-expanded={actualOpen}
aria-haspopup="menu"
onClick={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
}}
onPointerDown={(event) => event.stopPropagation()}
>
<Camera className="size-3 shrink-0" />
<span className="truncate">{resolvedLabel || "运镜"}</span>
{activeIdSet.size > 0 && onClear ? (
<button
type="button"
aria-label="移除运镜"
onClick={(event) => {
event.stopPropagation();
onClear();
}}
>
<X className="size-3" />
</button>
) : null}
</div>
</Dropdown>
);
}
function MotionToolCard({ tool, selected, onSelect }: { tool: ToolSummary; selected: boolean; onSelect: (tool: ToolSummary) => void }) {
const coverUrl = toAbsoluteUrl(tool.cover);
const mediaUrl = toAbsoluteUrl(tool.mediaUrl);
const videoRef = useRef<HTMLVideoElement>(null);
const [hovered, setHovered] = useState(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (hovered && mediaUrl) {
video.currentTime = 0;
void video.play().catch(() => {});
} else {
video.pause();
}
}, [hovered, mediaUrl]);
return (
<button
type="button"
role="option"
aria-selected={selected}
title={tool.desc}
className={`group flex flex-col gap-1 rounded-[var(--r-md)] border p-1.5 text-left transition-colors hover:bg-[var(--surface-hover)] focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:outline-none ${selected ? "border-primary/60 ring-1 ring-primary/40" : "border-transparent"}`}
onClick={() => onSelect(tool)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-[var(--r-sm)] border border-border/60">
{mediaUrl ? (
<video ref={videoRef} src={mediaUrl} poster={coverUrl ?? undefined} muted loop playsInline preload="none" className="h-full w-full object-cover" />
) : coverUrl ? (
<img src={coverUrl} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center text-foreground/30">
<Wrench className="size-4" />
</div>
)}
</div>
<span className="truncate text-center text-[11px] font-medium leading-4 text-foreground">{tool.label}</span>
</button>
);
}

View File

@@ -13,7 +13,7 @@
min-width: 13.5rem;
overflow: hidden;
border: 1px solid var(--workspace-overlay-border, var(--dock-border));
border-radius: 8px;
border-radius: var(--r-md);
background: var(--workspace-overlay-bg-strong, var(--dock-surface));
}
@@ -73,6 +73,30 @@
height: 14px;
}
/* 九宫格菜单按 section 分两列,缩短纵向长度 */
.canvas-node-toolbar-menu-nine-grid .ant-dropdown-menu {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--space-2);
}
.canvas-node-toolbar-menu-nine-grid .ant-dropdown-menu-item-group-title {
white-space: nowrap;
}
/* 风格工具弹层外壳:cover 卡片网格自定义渲染,视觉与 split 弹层一致;
固定宽度避免 truncate 标签的 max-content 把弹层撑宽;
背景对齐九宫格菜单:spatial 页用 --workspace-overlay-bg-strong(antd 菜单
被 body.app-spatial-overlays 全局覆盖为同一 token),画布编辑器等非 spatial
页回退 --popover(皮肤注入,与 antd colorBgElevated 同源) */
.canvas-node-toolbar-menu-style .canvas-node-toolbar-menu-stack {
width: min(40rem, calc(100vw - 32px));
overflow: hidden;
border: 1px solid var(--workspace-overlay-border, var(--dock-border));
border-radius: var(--r-md);
background: var(--workspace-overlay-bg-strong, var(--popover));
}
.canvas-grid-split-menu-item.is-open {
background: var(--workspace-overlay-hover, color-mix(in srgb, var(--popover-foreground) 6%, var(--popover)));
}

View File

@@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Globe2, Grid2x2, Layers3, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Sun, Upload, Scaling, WandSparkles } from "lucide-react";
import { isValidElement, type ReactNode } from "react";
import { Brush, Camera, Clapperboard, Contrast, Copy, FastForward, FileText, Globe2, Grid2x2, Grid3x3, Layers3, Lock, LockOpen, Maximize2, Package, PencilLine, PersonStanding, Crop, Rewind, ScanFace, SlidersHorizontal, Smile, Sun, Upload, Scaling, WandSparkles } from "lucide-react";
import type { CanvasNodeData } from "@/types/canvas";
import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view" | "multi_camera_nine_grid" | "story_pitch_four_grid" | "character_face_three_view" | "product_three_view" | "storyboard_25_grid" | "character_three_view_generation" | "cinematic_light_correction" | "image_projection_after_3s" | "image_projection_before_5s";
type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
@@ -26,6 +26,7 @@ type ImageToolHandlers = {
onViewImage: (node: CanvasNodeData) => void;
onCopyPrompt: (node: CanvasNodeData) => void;
onReversePrompt: (node: CanvasNodeData) => void;
onNineGrid: (node: CanvasNodeData, toolId: number, label: string, icon: string) => void;
};
type ImageToolDefinition = {
@@ -37,9 +38,25 @@ type ImageToolDefinition = {
section?: string;
description?: string;
active?: (node: CanvasNodeData) => boolean;
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
run: (node: CanvasNodeData, handlers: ImageToolHandlers, tool: ImageToolDefinition) => void;
/** 仅 group === "nine_grid" 时使用,对应后端工具 ID */
toolId?: number;
};
/** lucide 组件把 PascalCase 图标名写入 displayName,从 icon 渲染结果反查图标名,未命中时回退 Grid3x3。 */
function resolveToolIconName(tool: ImageToolDefinition, node: CanvasNodeData) {
const element = tool.icon(node);
const icon_type = isValidElement(element) ? element.type : null;
const name = icon_type && typeof icon_type !== "string" ? (icon_type as { displayName?: string }).displayName : undefined;
return name || "Grid3x3";
}
/** 九宫格工具共用行为:run 收到自身定义后直接取 toolId/label,图标名从 icon 反查。 */
function nineGridRun(node: CanvasNodeData, handlers: ImageToolHandlers, tool: ImageToolDefinition) {
if (tool.toolId == null) return;
handlers.onNineGrid(node, tool.toolId, resolveToolText(tool.label, node), resolveToolIconName(tool, node));
}
const imageToolDefinitions: ImageToolDefinition[] = [
{
id: "copyPrompt",
@@ -222,6 +239,106 @@ const imageToolDefinitions: ImageToolDefinition[] = [
order: 80,
run: (node, handlers) => handlers.onPanorama(node),
},
// 九宫格工具组——对应后端 nine_grid 种子数据
{
id: "multi_camera_nine_grid",
label: "多机位九宫格",
section: "宫格生成",
description: "生成 3x3 多机位联系表",
icon: () => <Grid3x3 className="size-3.5" />,
group: "nine_grid",
order: 10,
toolId: 79,
run: nineGridRun,
},
{
id: "story_pitch_four_grid",
label: "剧情推演四宫格",
section: "宫格生成",
description: "生成 2x2 剧情推演联系表",
icon: () => <Grid2x2 className="size-3.5" />,
group: "nine_grid",
order: 20,
toolId: 80,
run: nineGridRun,
},
{
id: "character_face_three_view",
label: "角色脸部三视图",
section: "设定图",
description: "生成 3x2 角色脸部联系表",
icon: () => <ScanFace className="size-3.5" />,
group: "nine_grid",
order: 30,
toolId: 81,
run: nineGridRun,
},
{
id: "product_three_view",
label: "产品三视图",
section: "设定图",
description: "生成 3x2 产品联系表",
icon: () => <Package className="size-3.5" />,
group: "nine_grid",
order: 40,
toolId: 82,
run: nineGridRun,
},
{
id: "storyboard_25_grid",
label: "25宫格连贯分镜",
section: "宫格生成",
description: "生成 5x5 连贯分镜联系表",
icon: () => <Clapperboard className="size-3.5" />,
group: "nine_grid",
order: 50,
toolId: 83,
run: nineGridRun,
},
{
id: "character_three_view_generation",
label: "角色三视图",
section: "设定图",
description: "生成 16:9 角色三视图联系表",
icon: () => <PersonStanding className="size-3.5" />,
group: "nine_grid",
order: 60,
toolId: 85,
run: nineGridRun,
},
{
id: "cinematic_light_correction",
label: "电影级光影校正",
section: "光影",
description: "修正电影灯光,使场景更真实",
icon: () => <Contrast className="size-3.5" />,
group: "nine_grid",
order: 70,
toolId: 84,
run: nineGridRun,
},
{
id: "image_projection_after_3s",
label: "画面推演-3秒后",
section: "画面推演",
description: "生成 3 秒后的画面帧",
icon: () => <FastForward className="size-3.5" />,
group: "nine_grid",
order: 80,
toolId: 86,
run: nineGridRun,
},
{
id: "image_projection_before_5s",
label: "画面推演-5秒前",
section: "画面推演",
description: "生成 5 秒前的画面帧",
icon: () => <Rewind className="size-3.5" />,
group: "nine_grid",
order: 90,
toolId: 87,
run: nineGridRun,
},
];
export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) {
@@ -234,10 +351,34 @@ export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageTool
section: tool.section,
description: tool.description,
active: tool.active?.(node),
onClick: () => tool.run(node, handlers),
onClick: () => tool.run(node, handlers, tool),
}));
}
function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) {
return typeof value === "function" ? value(node) : value;
}
export type NineGridMenuItem = {
id: string;
label: string;
section: string;
description: string;
toolId: number;
toolIconName: string;
};
/** 从 imageToolDefinitions 中筛选 nine_grid 组,返回九宫格菜单项数据 */
export function getNineGridMenuItems(): NineGridMenuItem[] {
return imageToolDefinitions
.filter((tool) => tool.group === "nine_grid" && tool.toolId != null)
.sort((a, b) => a.order - b.order)
.map((tool) => ({
id: tool.id,
label: typeof tool.label === "function" ? tool.label({} as CanvasNodeData) : tool.label,
section: tool.section || "常用操作",
description: tool.description || "",
toolId: tool.toolId!,
toolIconName: resolveToolIconName(tool, {} as CanvasNodeData),
}));
}

View File

@@ -0,0 +1,92 @@
import { useState } from "react";
import { Dropdown, type MenuProps } from "antd";
import { Grid3x3 } from "lucide-react";
import { TOOL_ICON_MAP } from "./canvas-resource-mention-textarea";
import { getNineGridMenuItems } from "./canvas-image-toolbar-tools";
function NineGridToolIcon({ iconName, className = "size-3.5 shrink-0" }: { iconName: string; className?: string }) {
const Icon = TOOL_ICON_MAP[iconName] ?? Grid3x3;
return <Icon className={className} />;
}
function buildNineGridMenuItems(onSelect: (toolId: number, label: string, icon: string) => void): MenuProps["items"] {
const items = getNineGridMenuItems();
const sections = new Map<string, typeof items>();
for (const tool of items) {
const list = sections.get(tool.section) || [];
list.push(tool);
sections.set(tool.section, list);
}
return [...sections].map(([section, entries]) => ({
type: "group" as const,
key: section,
label: section,
children: entries.map((tool) => ({
key: tool.id,
label: (
<div>
<span className="inline-flex items-center gap-2">
<NineGridToolIcon iconName={tool.toolIconName} />
{tool.label}
</span>
{tool.description ? <div className="text-[var(--fs-tiny)] opacity-60">{tool.description}</div> : null}
</div>
),
onClick: () => {
onSelect(tool.toolId, tool.label, tool.toolIconName);
},
})),
}));
}
export function CanvasNineGridPicker({
open,
onOpenChange,
icon = "Grid3x3",
onSelect,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 当前已选九宫格工具的 lucide 图标名,无选择时回退 Grid3x3。 */
icon?: string;
onSelect: (toolId: number, label: string, icon: string) => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const actualOpen = open ?? internalOpen;
const setOpen = (next: boolean) => {
setInternalOpen(next);
onOpenChange?.(next);
};
return (
<Dropdown
open={actualOpen}
onOpenChange={setOpen}
trigger={["click"]}
menu={{
items: buildNineGridMenuItems((toolId, label, toolIcon) => {
setOpen(false);
onSelect(toolId, label, toolIcon);
}),
}}
popupRender={(menu) => (
<div
className="canvas-node-toolbar-menu canvas-node-toolbar-menu-nine-grid"
data-canvas-no-zoom
data-canvas-wheel-scroll
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<div className="canvas-node-toolbar-menu-stack">{menu}</div>
</div>
)}
>
<button type="button" className="canvas-node-composer-header-action canvas-node-fixed-chip" aria-expanded={actualOpen} aria-haspopup="menu" onClick={() => setOpen(true)} onPointerDown={(event) => event.stopPropagation()}>
<NineGridToolIcon iconName={icon} className="size-4 shrink-0" />
<span className="truncate">预设</span>
</button>
</Dropdown>
);
}

View File

@@ -1,7 +1,7 @@
import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { ArrowLeftRight, ArrowUp, AtSign, Boxes, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -23,10 +23,14 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
import { CanvasNineGridPicker } from "./canvas-nine-grid-picker";
import { CanvasChooseImageStylePicker } from "./canvas-choose-image-style-picker";
import { CanvasChooseEffectPicker } from "./canvas-choose-effect-picker";
import { CanvasChooseMotionPicker } from "./canvas-choose-motion-picker";
import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover";
import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas";
import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { applyToolMention, removeToolMentions, autoMentionCanvasResourceReferences, buildToolMentionReference, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, overwriteSameTypeToolMention, parseToolMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
@@ -79,6 +83,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
const simpleMode = workspaceMode === "simple";
const mode = defaultMode(node.type);
const showPromptTemplates = !simpleMode && mode !== "image";
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
@@ -86,6 +91,14 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const [prompt, setPrompt] = useState(savedPrompt);
const [presetOpen, setPresetOpen] = useState(false);
const [expandedPresetOpen, setExpandedPresetOpen] = useState(false);
const [nineGridOpen, setNineGridOpen] = useState(false);
const [expandedNineGridOpen, setExpandedNineGridOpen] = useState(false);
const [styleToolOpen, setStyleToolOpen] = useState(false);
const [expandedStyleToolOpen, setExpandedStyleToolOpen] = useState(false);
const [effectToolOpen, setEffectToolOpen] = useState(false);
const [expandedEffectToolOpen, setExpandedEffectToolOpen] = useState(false);
const [motionToolOpen, setMotionToolOpen] = useState(false);
const [expandedMotionToolOpen, setExpandedMotionToolOpen] = useState(false);
const [expandedPromptOpen, setExpandedPromptOpen] = useState(false);
const [expandedModalSize, setExpandedModalSize] = useState<{ width: number; height: number } | null>(null);
const expandedModalRef = useRef<HTMLDivElement>(null);
@@ -96,8 +109,22 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
const [autoLinkEnabled, setAutoLinkEnabled] = useState(true);
const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences, { projectId });
const promptToolReferences = useMemo(() => parseToolMentionTokens(prompt).map(({toolId, label, type, icon}) => buildToolMentionReference(toolId, label, type, icon)), [prompt]);
const textareaReferences = useMemo(() => {
if (!promptToolReferences.length) return resolvedMentionReferences;
const existingIds = new Set(resolvedMentionReferences.map((r) => r.id));
const extras = promptToolReferences.filter((r) => !existingIds.has(r.id));
return extras.length ? [...resolvedMentionReferences, ...extras] : resolvedMentionReferences;
}, [resolvedMentionReferences, promptToolReferences]);
// 当前提示词持有的九宫格工具图标,用于触发按钮回显已选工具。
const activeNineGridIcon = useMemo(() => parseToolMentionTokens(prompt).find((tool) => tool.type === "nine_grid")?.icon ?? "Grid3x3", [prompt]);
const activeStyleTool = parseToolMentionTokens(prompt).find(t => t.type === "style");
const activeEffectTool = parseToolMentionTokens(prompt).find(t => t.type === "effect");
// 当前提示词持有的运镜工具标签(可多个),用于运镜按钮回显与菜单高亮。
const activeMotionTools = useMemo(() => parseToolMentionTokens(prompt).filter((tool) => tool.type === "motion"), [prompt]);
const activeMotionTool = activeMotionTools[0];
const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]);
const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill");
const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill" && item.kind !== "tool");
const requirements: ModelRequirements = {
capability: mode,
input: {
@@ -232,7 +259,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const updatePrompt = (value: string) => {
setPrompt(value);
onPromptChange(node.id, value);
if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) {
if (showPromptTemplates && /(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) {
if (expandedPromptOpen) setExpandedPresetOpen(true);
else setPresetOpen(true);
}
@@ -246,15 +273,19 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const insertPromptReference = (reference: CanvasResourceReference) => {
const insertText = `${canvasResourceMentionToken(reference)} `;
const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt);
if (pendingMentionMatch) {
const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, "");
updatePrompt(prefix ? `${prefix} ${insertText}` : insertText);
const basePrompt = pendingMentionMatch ? prompt.slice(0, pendingMentionMatch.index) : prompt;
// 同类型工具标签(style/nine_grid/effect)唯一:已有旧标签时原位覆盖,不再追加。
const overwrittenPrompt = overwriteSameTypeToolMention(basePrompt, reference);
if (overwrittenPrompt != null) {
updatePrompt(overwrittenPrompt.replace(/\s+$/, ""));
return;
}
const basePrompt = prompt.replace(/\s*$/, "");
updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText);
const trimmedBase = basePrompt.replace(/\s*$/, "");
updatePrompt(trimmedBase ? `${trimmedBase} ${insertText}` : insertText);
};
const removeMotionToolMention = () => updatePrompt(removeToolMentions(prompt, "motion"));
const submit = () => {
const text = prompt.trim();
if (!text || isRunning) return false;
@@ -265,6 +296,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const submitExpandedPrompt = () => {
if (submit()) {
setExpandedPresetOpen(false);
setExpandedNineGridOpen(false);
setExpandedPromptOpen(false);
}
};
@@ -293,8 +325,17 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
{activeReferenceCount > 0 ? <span className="canvas-node-composer-reference-heading">{referenceShelfHeading(activeReferences)}</span> : null}
</div>
)}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
{!simpleMode ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense appearance="quiet" /> : null}
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
{!simpleMode && (mode === "image" || mode === "video") ? <div className="canvas-node-tool-controls canvas-node-tool-controls-inline flex items-center gap-1" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={e => e.stopPropagation()}>
{mode === "image" ? <>
<CanvasChooseImageStylePicker open={expanded ? expandedStyleToolOpen : styleToolOpen} onOpenChange={expanded ? setExpandedStyleToolOpen : setStyleToolOpen} activeToolId={activeStyleTool?.toolId} activeLabel={activeStyleTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePrompt(removeToolMentions(prompt,"style"))} />
<CanvasNineGridPicker open={expanded ? expandedNineGridOpen : nineGridOpen} onOpenChange={expanded ? setExpandedNineGridOpen : setNineGridOpen} icon={activeNineGridIcon} onSelect={(id,label,icon) => updatePrompt(applyToolMention(prompt,{id,label,type:"nine_grid"},icon))} />
</> : <>
<CanvasChooseEffectPicker open={expanded ? expandedEffectToolOpen : effectToolOpen} onOpenChange={expanded ? setExpandedEffectToolOpen : setEffectToolOpen} activeToolId={activeEffectTool?.toolId} activeLabel={activeEffectTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePrompt(removeToolMentions(prompt,"effect"))} />
<CanvasChooseMotionPicker open={expanded ? expandedMotionToolOpen : motionToolOpen} onOpenChange={expanded ? setExpandedMotionToolOpen : setMotionToolOpen} activeToolIds={activeMotionTools.map(t => t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} />
</>}
</div> : null}
{showPromptTemplates ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense appearance="quiet" /> : null}
{canOptimizePrompt ? (
<Tooltip title="用 AI 润色提示词">
<button
@@ -478,7 +519,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
/>
<CanvasResourceMentionTextarea
value={prompt}
references={resolvedMentionReferences}
references={textareaReferences}
onSelectReference={onAddReference ? (reference) => onAddReference(node.id, reference) : undefined}
includeAssetLibrary
onChange={updatePrompt}
@@ -565,6 +606,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
destroyOnHidden
onCancel={() => {
setExpandedPresetOpen(false);
setExpandedNineGridOpen(false);
setExpandedPromptOpen(false);
}}
styles={{
@@ -684,7 +726,7 @@ function ConnectedReferenceShelf({
onReplaceReference?: (oldReference: CanvasResourceReference, sourceNodeId: string) => void;
onReplaceReferenceFiles?: (oldReference: CanvasResourceReference, files: File[]) => void;
}) {
const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
const activeReferences = references.filter((item) => item.active && item.kind !== "skill" && item.kind !== "tool");
const [imagePreview, setImagePreview] = useState<CanvasResourceReference | null>(null);
const [draggedReferenceId, setDraggedReferenceId] = useState<string | null>(null);
const [dropTargetReferenceId, setDropTargetReferenceId] = useState<string | null>(null);

View File

@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
import type { MenuProps } from "antd";
import { Camera, Check, ChevronDown, ChevronRight, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
import { Camera, Check, ChevronDown, ChevronRight, Ellipsis, Grid3x3, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
@@ -62,6 +62,7 @@ type CanvasNodeToolbarProps = {
onToggleFreeResize: (node: CanvasNodeData) => void;
onToggleLocked: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
onNineGrid: (node: CanvasNodeData, toolId: number, label: string, icon: string) => void;
workspaceMode?: CanvasWorkspaceMode;
};
@@ -127,6 +128,7 @@ export function CanvasNodeToolbar({
onToggleFreeResize,
onToggleLocked,
onDelete,
onNineGrid,
workspaceMode = "professional",
}: CanvasNodeToolbarProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
@@ -229,7 +231,7 @@ export function CanvasNodeToolbar({
}
copyText(prompt, "提示词已复制");
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt, onNineGrid });
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {
@@ -292,6 +294,7 @@ export function CanvasNodeToolbar({
const viewpointLightingTools = compact ? [] : [...inGroup("viewpoint"), ...inGroup("lighting")];
const panoramaTools = compact ? [] : inGroup("panorama");
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("panorama"), ...inGroup("process")] : inGroup("process");
const nineGridTools = compact ? [] : inGroup("nine_grid");
const workspaceTools = narrow ? [] : inGroup("workspace");
const utilityTools = inGroup("utility");
const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")];
@@ -324,6 +327,7 @@ export function CanvasNodeToolbar({
style={{ ...dockStyle, border: 0 }}
>
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{nineGridTools.length ? <NodeDockMenuButton menuId="nine-grid" label="九宫格" icon={<Grid3x3 className="size-3.5" />} tools={nineGridTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{panoramaTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{viewpointLightingTools.length ? <NodeDockMenuButton menuId="viewpoint-lighting" label="视角" icon={<Camera className="size-3.5" />} tools={viewpointLightingTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}

View File

@@ -1,7 +1,8 @@
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react";
import { renderToStaticMarkup } from "react-dom/server";
import { ArrowLeft, Brush, Camera, Clapperboard, ChevronRight, Clock, Contrast, FastForward, FileText, Folder, Globe2, Grid2x2, Grid3x3, Image as ImageIcon, Music2, Package, Pencil, Palette, PersonStanding, Rewind, ScanFace, Search, SlidersHorizontal, Sparkles, Sun, UserRound, Video, Workflow } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
@@ -84,13 +85,16 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const rawAssetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]);
const assetReferences = useResolvedCanvasResourceReferences(rawAssetReferences);
const activeCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.active), [canvasReferences]);
const availableReferences = useMemo(() => [...(onSelectReference ? canvasReferences : activeCanvasReferences), ...assetReferences], [onSelectReference, canvasReferences, activeCanvasReferences, assetReferences]);
// 工具标签只能经九宫格等面板入口插入,@ 引用菜单不再重复展示。
const mentionCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.kind !== "tool"), [canvasReferences]);
const activeMentionCanvasReferences = useMemo(() => mentionCanvasReferences.filter((item) => item.active), [mentionCanvasReferences]);
const availableReferences = useMemo(() => [...(onSelectReference ? mentionCanvasReferences : activeMentionCanvasReferences), ...assetReferences], [onSelectReference, mentionCanvasReferences, activeMentionCanvasReferences, assetReferences]);
const candidates = useMemo(() => {
if (!mention) return [];
const query = mention.query.trim().toLowerCase();
if (!query) return onSelectReference ? canvasReferences : activeCanvasReferences;
if (!query) return onSelectReference ? mentionCanvasReferences : activeMentionCanvasReferences;
return availableReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.category || ""} ${item.text || ""}`.toLowerCase().includes(query));
}, [onSelectReference, canvasReferences, activeCanvasReferences, availableReferences, mention]);
}, [onSelectReference, mentionCanvasReferences, activeMentionCanvasReferences, availableReferences, mention]);
const activeReferences = useMemo(() => {
if (!highlightLabels) return [];
return [...activeCanvasReferences, ...assetReferences.filter((item) => value.includes(canvasResourceMentionToken(item)))];
@@ -327,7 +331,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const menu = mention && availableReferences.length && menuAnchor ? (
<MentionMenu
anchor={menuAnchor}
connectedReferences={activeCanvasReferences}
connectedReferences={activeMentionCanvasReferences}
assetReferences={assetReferences}
filteredReferences={candidates}
query={mention.query}
@@ -591,25 +595,40 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
);
});
export const TOOL_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brush, Camera, Clapperboard, Clock, Contrast, FastForward, Globe2, Grid2x2, Grid3x3, Package, Palette, PersonStanding, Rewind, ScanFace, SlidersHorizontal, Sparkles, Sun,
};
function toolIconSvg(iconName: string): string {
const Icon = TOOL_ICON_MAP[iconName];
if (!Icon) return "🔧";
return renderToStaticMarkup(<Icon className="size-3" />);
}
function createInlineMentionChip(reference: CanvasResourceReference, token: string) {
const chip = document.createElement("span");
chip.contentEditable = "false";
chip.dataset.mentionToken = token;
chip.dataset.mentionReferenceId = reference.id;
chip.className = `canvas-resource-inline-mention ${reference.kind === "skill" ? "is-skill" : ""}`;
const isDecorated = reference.kind === "skill" || reference.kind === "tool";
chip.className = `canvas-resource-inline-mention ${isDecorated ? `is-${reference.kind}` : ""}`;
chip.title = "双击放大预览";
if (reference.kind === "skill") chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference));
if (reference.kind === "tool") chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference));
const prefix = document.createElement("span");
prefix.className = reference.kind === "skill" ? "canvas-resource-inline-skill-icon" : "canvas-resource-inline-at";
// “/” is an input command, not part of the selected Skill name. Keep the
// command token in the serialized value, but render the chip as a normal
// icon + label so it remains readable after selection and submission.
prefix.textContent = reference.kind === "skill" ? "✦" : "@";
prefix.className = isDecorated ? "canvas-resource-inline-skill-icon" : "canvas-resource-inline-at";
if (reference.kind === "skill") {
prefix.textContent = "✦";
} else if (reference.kind === "tool") {
prefix.innerHTML = toolIconSvg(reference.toolIcon ?? "Grid3x3");
} else {
prefix.textContent = "@";
}
chip.appendChild(prefix);
// Skill chip 的前缀已经承担图标职责,不再追加 fallback preview,避免出现两个星标。
if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));
// Skill/tool chip 的前缀已经承担图标职责,不再追加 fallback preview,避免出现两个图标。
if (!isDecorated) chip.appendChild(createInlinePreview(reference));
const label = document.createElement("span");
label.className = "canvas-resource-inline-label";

View File

@@ -51,6 +51,7 @@ export function CanvasToolbar({
onSaveAppearanceDefault,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenWorkspace,
onOpenMyAssets,
onOpenProjectCharacters,
}: {
@@ -86,6 +87,7 @@ export function CanvasToolbar({
onSaveAppearanceDefault: (appearance: CanvasAppearance) => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenWorkspace?: () => void;
onOpenMyAssets: () => void;
onOpenProjectCharacters: () => void;
}) {
@@ -150,7 +152,8 @@ export function CanvasToolbar({
onChooseStyle,
onOpenDirector,
onUpload,
onOpenMyAssets,
onOpenWorkspace,
onOpenMyAssets,
onOpenProjectCharacters,
onBackgroundModeChange,
onShowImageInfoChange,

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { App, Button, Select } from "antd";
import { useInfiniteQuery } from "@tanstack/react-query";
import { FileText, Images, Plus, RefreshCw } from "lucide-react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { ASSET_CATEGORY_LABELS, type AssetCategory } from "@/lib/asset-category";
import { resourceStorageLabel } from "@/lib/canvas/resource-storage-status";
import { loadAssetLibraryPage } from "@/services/user-data-sync";
import { useUserStore } from "@/stores/use-user-store";
import type { Asset } from "@/stores/use-asset-store";
import { assetPickerItemsToInsertPayloads, type InsertAssetPayload } from "./asset-picker-modal";
const categories: AssetCategory[] = ["material", "character", "environment", "prop", "other"];
export function CanvasWorkspaceAssetPanel({ onInsert, onManage, onProjectAssets }: { onInsert: (payloads: InsertAssetPayload[]) => Promise<unknown>; onManage: () => void; onProjectAssets?: () => void }) {
const { message } = App.useApp();
const userId = useUserStore((state) => state.user?.id);
const hydrated = useUserStore((state) => state.hydrated);
const [category, setCategory] = useState<AssetCategory>("material");
const [kind, setKind] = useState("all");
const [input, setInput] = useState("");
const [search, setSearch] = useState("");
const [inserting, setInserting] = useState<string | null>(null);
useEffect(() => {
const timer = window.setTimeout(() => setSearch(input.trim()), 250);
return () => window.clearTimeout(timer);
}, [input]);
const query = useInfiniteQuery({
queryKey: ["canvas-workspace-assets", userId, category, kind, search],
initialPageParam: 1,
queryFn: ({ signal, pageParam }) => loadAssetLibraryPage({ page: pageParam, pageSize: 40, category, kind: kind === "all" ? undefined : kind, status: "active", query: search, signal }),
getNextPageParam: (page, pages) => (pages.reduce((count, item) => count + item.assets.length, 0) < page.total ? pages.length + 1 : undefined),
enabled: Boolean(userId) && hydrated,
});
const assets = query.data?.pages.flatMap((page) => page.assets) ?? [];
const insert = async (asset: Asset) => {
if (inserting) return;
setInserting(asset.id);
try {
const payloads = assetPickerItemsToInsertPayloads([asset.id], [{ id: asset.id, title: asset.title, category: asset.category || "other", kindLabel: asset.kind, asset }]);
await onInsert(payloads);
} catch (error) {
message.error(error instanceof Error ? error.message : "插入素材失败");
} finally {
setInserting(null);
}
};
return (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
<Images className="size-4" />
<strong className="text-sm">资产</strong>
<span className="text-muted-foreground">{query.data?.pages[0]?.total ?? 0}</span>
</header>
<div className="sidebar-subtabs border-b border-border p-2">
{categories.map((id) => (
<button key={id} type="button" className={category === id ? "active" : ""} aria-pressed={category === id} onClick={() => setCategory(id)}>
{ASSET_CATEGORY_LABELS[id]}
</button>
))}
</div>
<div className="space-y-2 border-b border-border p-3">
<div className="flex items-center justify-between gap-2">
<Select
aria-label="素材类型"
value={kind}
onChange={setKind}
options={[
{ value: "all", label: "全部" },
{ value: "image", label: "图片" },
{ value: "video", label: "视频" },
{ value: "audio", label: "音频" },
{ value: "text", label: "文本" },
]}
/>
<Button type="text" aria-label="刷新资产" loading={query.isFetching} icon={<RefreshCw className="size-4" />} onClick={() => void query.refetch()} />
</div>
<input className="w-full rounded-md border border-border bg-transparent px-2 py-1.5 text-sm" aria-label="搜索资产名称或标签" placeholder="搜索名称/标签" value={input} onChange={(event) => setInput(event.target.value)} />
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">
{!userId ? (
<p className="p-3 text-sm">登录后查看资产。</p>
) : query.isPending ? (
<p role="status" className="p-3 text-sm">
正在加载资产…
</p>
) : null}
{query.isError ? (
<div role="alert" className="p-3 text-sm">
资产读取失败,已加载的内容仍保留。<Button onClick={() => void query.refetch()}>重试</Button>
</div>
) : null}
{assets.map((asset) => {
const insertable = ["image", "video", "audio", "text"].includes(asset.kind);
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
return (
<div key={asset.id} className="group flex items-center gap-2 rounded-md p-2 hover:bg-surface-hover">
<div className="grid h-11 w-14 shrink-0 place-items-center overflow-hidden rounded-md border border-border">
<CachedResourceImage src={cover} storageKey={asset.kind === "image" ? asset.data.storageKey : undefined} alt="" className="h-full w-full object-cover" fallback={<FileText className="size-4 text-muted-foreground" />} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm" title={asset.title}>
{asset.title}
</div>
<div className="truncate text-xs text-muted-foreground">
{resourceStorageLabel("storageKey" in asset.data ? asset.data.storageKey : undefined)}
{asset.tags?.length ? ` · ${asset.tags[0]}` : ""}
</div>
</div>
<Button
type="text"
size="small"
aria-label={`插入画布:${asset.title}`}
title={insertable ? "插入画布" : "此类型请在素材库中查看"}
disabled={!insertable || Boolean(inserting)}
loading={inserting === asset.id}
icon={<Plus className="size-4" />}
onClick={() => void insert(asset)}
/>
</div>
);
})}
{query.isSuccess && !assets.length ? <WorkspaceState icon="canvas" compact title="没有匹配的资产" description="换个分类或搜索条件试试。" /> : null}
{query.hasNextPage ? (
<Button block loading={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>
加载更多
</Button>
) : null}
</div>
<footer className="flex shrink-0 gap-2 border-t border-border p-2">
<Button size="small" onClick={onManage}>
管理素材
</Button>
{onProjectAssets ? (
<Button size="small" onClick={onProjectAssets}>
项目素材
</Button>
) : null}
</footer>
</>
);
}

View File

@@ -0,0 +1,35 @@
import { History, RefreshCw } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import type { GenerationTask } from "@/services/api/task-center";
import { TaskListItem } from "./canvas-workspace-task-list-item";
// 历史面板:展示项目最近任务(含云端 Agent 根任务与步骤任务),与任务面板互不依赖。
export function CanvasWorkspaceHistoryPanel({ tasks, refreshing, onRefresh, onCancelTask }: { tasks: GenerationTask[]; refreshing?: boolean; onRefresh?: () => void; onCancelTask?: (task: GenerationTask) => void }) {
return (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2.5">
<History className="size-3.5 shrink-0" />
<span className="truncate text-xs font-semibold">历史 · 最近 30 条(含 Agent)</span>
<span className="tabular-nums text-foreground/32">{tasks.length.toLocaleString("zh-CN")}</span>
<span className="ml-auto">
<button type="button" className="icon-btn tip-down" data-tip="刷新" aria-label="刷新" onClick={onRefresh}>
<RefreshCw className={`size-3.5 ${refreshing ? "animate-spin" : ""}`} />
</button>
</span>
</header>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain py-1">
{tasks.length ? (
<div className="space-y-0.5">
{tasks.map((task) => (
<TaskListItem key={task.id} task={task} onCancelTask={onCancelTask} />
))}
</div>
) : (
<WorkspaceState icon="canvas" compact title="暂无历史" description="发起生成后,记录会展示在这里。" />
)}
</div>
</>
);
}

View File

@@ -0,0 +1,148 @@
import { memo, useState } from "react";
import { AudioLines, Clock3, FileText, Image, Layers, Pencil, Search, X } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes } from "@/lib/canvas/canvas-node-search";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { Video as CanvasVideoIcon } from "lucide-react";
export function CanvasWorkspaceNodeListPanel({
nodes,
results,
query,
deferredQuery,
selectedNodeIds,
onQueryChange,
onFocus,
}: {
nodes: CanvasNodeData[];
results: CanvasNodeData[];
query: string;
deferredQuery: string;
selectedNodeIds: Set<string>;
onQueryChange: (value: string) => void;
onFocus: (nodeId: string) => void;
}) {
return (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2.5">
<Layers className="size-3.5 shrink-0" />
<span className="truncate text-xs font-semibold">画布节点</span>
<span className="tabular-nums text-foreground/32">{nodes.length.toLocaleString("zh-CN")}</span>
</header>
<div className="shrink-0 border-b border-border/70 p-2">
<label className="flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-foreground/[.025] px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
<Search className="size-3.5 shrink-0 text-foreground/32" />
<input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28"
placeholder="搜索节点、章节、镜头、模型或标签…"
aria-label="搜索画布节点"
/>
{query ? (
<button type="button" onClick={() => onQueryChange("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover hover:text-foreground" aria-label="清空搜索">
<X className="size-3" />
</button>
) : null}
</label>
{deferredQuery ? <div className="mt-1 px-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/35">找到 {results.length.toLocaleString("zh-CN")} 个节点</div> : null}
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain py-1" role="listbox" aria-label="画布节点列表">
{results.length ? (
results.map((node) => <CanvasNodeListItem key={node.id} node={node} active={selectedNodeIds.has(node.id)} onSelect={() => onFocus(node.id)} />)
) : (
<WorkspaceState icon="canvas" compact title="没有匹配节点" description="换一个关键词继续搜索。" />
)}
</div>
</>
);
}
const CanvasNodeListItem = memo(function CanvasNodeListItem({ node, active, onSelect }: { node: CanvasNodeData; active: boolean; onSelect: () => void }) {
const times = canvasNodeSearchTimes(node);
const materialSummary = canvasNodeMaterialSummary(node);
const context = canvasNodeSearchContext(node);
return (
<button
type="button"
role="option"
aria-selected={active}
className="grid w-full grid-cols-[44px_minmax(0,1fr)] items-center gap-2 rounded-[var(--r-md)] px-2 py-1.5 text-left transition-[background-color,box-shadow] hover:bg-[var(--surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
style={{
background: active ? "var(--surface-active)" : undefined,
boxShadow: active ? "inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent)" : undefined,
contentVisibility: "auto",
containIntrinsicSize: "48px",
}}
onClick={onSelect}
>
<CanvasNodeListThumbnail node={node} />
<span className="min-w-0 self-center">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-xs font-medium leading-4 text-foreground" title={node.title}>
{node.title || getNodeListLabel(node.type)}
</span>
</span>
<span className="mt-0.5 block truncate text-[10px] leading-3 text-foreground/45" title={materialSummary}>
{materialSummary}
</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-[10px] leading-3 text-foreground/40">
<Clock3 className="size-2.5 shrink-0" />
<span className="mt-0.5 block text-[10px] tabular-nums text-foreground/35" title={fullTime(times.updatedAt)}>
{times.updatedLabel}
</span>
</span>
</span>
</button>
);
});
function CanvasNodeListThumbnail({ node }: { node: CanvasNodeData }) {
const [failed, setFailed] = useState(false);
const mediaSource =
node.type === CanvasNodeType.Video
? canvasNodeVideoPreviewUrl(node)
: node.metadata?.drawingPreviewUrl ||
node.metadata?.characterCoverUrl ||
node.metadata?.folder?.themeCover ||
(node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade ? node.metadata?.content : undefined);
const commonClass = "h-9 w-11 rounded-[var(--r-sm)] border object-cover";
const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" };
if (mediaSource && !failed) {
return <img src={mediaSource} alt="" width={44} height={36} loading="lazy" decoding="async" className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
}
const textPreview = node.metadata?.previewContent || node.metadata?.composerContent || node.metadata?.prompt || node.metadata?.content;
if (textPreview && (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Markdown || node.type === CanvasNodeType.Script)) {
return (
<span aria-hidden="true" className="line-clamp-3 h-9 w-11 overflow-hidden rounded-[var(--r-sm)] border px-1 py-0.5 text-[7px] leading-[10px] text-foreground/55" style={commonStyle}>
{textPreview}
</span>
);
}
return (
<span aria-hidden="true" className="grid h-9 w-11 place-items-center rounded-[var(--r-sm)] border text-foreground/48" style={commonStyle}>
{node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama ? (
<Image className="size-3.5" />
) : node.type === CanvasNodeType.Video ? (
<CanvasVideoIcon className="size-3.5" />
) : node.type === CanvasNodeType.Audio ? (
<AudioLines className="size-3.5" />
) : node.type === CanvasNodeType.Drawing ? (
<Pencil className="size-3.5" />
) : (
<FileText className="size-3.5" />
)}
</span>
);
}
function fullTime(value?: string) {
if (!value) return "时间未记录";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}

View File

@@ -48,7 +48,7 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
toolbarRef.current.classList.toggle("-translate-y-full", placement === "above");
return;
}
setAnchor((current) => current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement });
setAnchor((current) => (current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement }));
};
update();
@@ -79,15 +79,45 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
onMouseDown={(event) => event.stopPropagation()}
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.toolbar.border, color: theme.accent.primary }}>已选 {count}</span>
<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.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, panelHeight = 190, dragOffset, isDragging = false, allowOverflow = false, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; dragOffset?: Position | null; isDragging?: boolean; allowOverflow?: boolean; children: ReactNode }) {
export function CanvasNodePanelOverlay({
node,
viewport,
containerRef,
panelWidth,
panelHeight = 190,
dragOffset,
isDragging = false,
allowOverflow = false,
children,
}: {
node: CanvasNodeData;
viewport: ViewportTransform;
containerRef: RefObject<HTMLDivElement | null>;
panelWidth?: number;
panelHeight?: number;
dragOffset?: Position | null;
isDragging?: boolean;
allowOverflow?: boolean;
children: ReactNode;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer(`node-panel:${node.id}`, "var(--z-modal-overlay)");
const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth);
@@ -109,9 +139,7 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
panel.style.width = `${nextWidth}px`;
const nodeElement = container.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(node.id)}"]`);
const position = nodeElement
? getAttachedNodePanelPosition(nodeElement, container, nextWidth)
: getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
const position = nodeElement ? getAttachedNodePanelPosition(nodeElement, container, nextWidth) : getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`;
};
update(viewport);
@@ -154,7 +182,31 @@ function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform
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.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub") => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub") => void; onClose: () => void }) {
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.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion,
provider?: "runninghub",
) => string;
onCreate: (
type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion,
provider?: "runninghub",
) => void;
onClose: () => void;
}) {
const theme = canvasThemes[useActiveTheme()];
const reducedMotion = useReducedMotion();
const menuRef = useRef<HTMLDivElement>(null);
@@ -226,28 +278,137 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
onPointerDown={(event) => event.stopPropagation()}
>
<div className="grid min-w-0 grid-cols-1 gap-1">
<ConnectionCreateOption expanded={activeOption === "文本生成"} motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" description="引用当前内容,生成或改写文本" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption expanded={activeOption === "分镜脚本"} motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" description="根据剧情拆解镜头,编排分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} />
<ConnectionCreateOption expanded={activeOption === "批量创作表"} motionEnabled={!reducedMotion} icon={<Table2 className="size-4" />} title="批量创作表" description="汇总多张图片,批量执行换装或创意生图" disabledReason={getDisabledReason(CanvasNodeType.BatchTable)} onClick={() => onCreate(CanvasNodeType.BatchTable)} />
<ConnectionCreateOption expanded={activeOption === "图片生成"} motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" description="结合提示词和参考图,生成新的画面" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption expanded={activeOption === "生成配置"} motionEnabled={!reducedMotion} icon={<WorkflowIcon className="size-4" />} title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} />
{canCreateDrawing ? <ConnectionCreateOption expanded={activeOption === "绘图"} motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" description="以参考图片为底图,自由绘制和标注" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
<ConnectionCreateOption expanded={activeOption === "视频生成"} motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" description="结合提示词与参考素材,生成动态视频" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption expanded={activeOption === "音频参考"} motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" description="连接文本或角色卡,创建音频生成节点" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption expanded={activeOption === "转换"} motionEnabled={!reducedMotion} icon={<WandSparkles className="size-4" />} title="转换" description="本地处理图片或视频" disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)} onClick={() => onCreate(CanvasNodeType.MediaConversion)} />
<ConnectionCreateOption
expanded={activeOption === "文本生成"}
motionEnabled={!reducedMotion}
icon={<List className="size-4" />}
title="文本生成"
description="引用当前内容,生成或改写文本"
disabledReason={getDisabledReason(CanvasNodeType.Text)}
onClick={() => onCreate(CanvasNodeType.Text)}
/>
<ConnectionCreateOption
expanded={activeOption === "分镜脚本"}
motionEnabled={!reducedMotion}
icon={<Clapperboard className="size-4" />}
title="分镜脚本"
description="根据剧情拆解镜头,编排分镜脚本"
disabledReason={getDisabledReason(CanvasNodeType.Script)}
onClick={() => onCreate(CanvasNodeType.Script)}
/>
<ConnectionCreateOption
expanded={activeOption === "批量创作表"}
motionEnabled={!reducedMotion}
icon={<Table2 className="size-4" />}
title="批量创作表"
description="汇总多张图片,批量执行换装或创意生图"
disabledReason={getDisabledReason(CanvasNodeType.BatchTable)}
onClick={() => onCreate(CanvasNodeType.BatchTable)}
/>
<ConnectionCreateOption
expanded={activeOption === "图片生成"}
motionEnabled={!reducedMotion}
icon={<ImageIcon className="size-4" />}
title="图片生成"
description="结合提示词和参考图,生成新的画面"
disabledReason={getDisabledReason(CanvasNodeType.Image)}
onClick={() => onCreate(CanvasNodeType.Image)}
/>
<ConnectionCreateOption
expanded={activeOption === "生成配置"}
motionEnabled={!reducedMotion}
icon={<WorkflowIcon className="size-4" />}
title="生成配置"
description="选择模型,或使用已启用的工作流插件"
disabledReason={getDisabledReason(CanvasNodeType.Config)}
onClick={() => onCreate(CanvasNodeType.Config)}
/>
{canCreateDrawing ? (
<ConnectionCreateOption
expanded={activeOption === "绘图"}
motionEnabled={!reducedMotion}
icon={<Pencil className="size-4" />}
title="绘图"
description="以参考图片为底图,自由绘制和标注"
disabledReason={getDisabledReason(CanvasNodeType.Drawing)}
onClick={() => onCreate(CanvasNodeType.Drawing)}
/>
) : null}
<ConnectionCreateOption
expanded={activeOption === "视频生成"}
motionEnabled={!reducedMotion}
icon={<Video className="size-4" />}
title="视频生成"
description="结合提示词与参考素材,生成动态视频"
disabledReason={getDisabledReason(CanvasNodeType.Video)}
onClick={() => onCreate(CanvasNodeType.Video)}
/>
<ConnectionCreateOption
expanded={activeOption === "音频参考"}
motionEnabled={!reducedMotion}
icon={<Music2 className="size-4" />}
title="音频参考"
description="连接文本或角色卡,创建音频生成节点"
disabledReason={getDisabledReason(CanvasNodeType.Audio)}
onClick={() => onCreate(CanvasNodeType.Audio)}
/>
<ConnectionCreateOption
expanded={activeOption === "转换"}
motionEnabled={!reducedMotion}
icon={<WandSparkles className="size-4" />}
title="转换"
description="本地处理图片或视频"
disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)}
onClick={() => onCreate(CanvasNodeType.MediaConversion)}
/>
</div>
</motion.div>
);
}
function ConnectionCreateOption({ expanded, motionEnabled, icon, title, description, disabledReason, onClick }: { expanded: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; disabledReason?: string; onClick: () => void }) {
function ConnectionCreateOption({
expanded,
motionEnabled,
icon,
title,
description,
disabledReason,
onClick,
}: {
expanded: boolean;
motionEnabled: boolean;
icon: ReactNode;
title: string;
description: string;
disabledReason?: string;
onClick: () => void;
}) {
const theme = canvasThemes[useActiveTheme()];
return (
<button type="button" aria-disabled={Boolean(disabledReason)} aria-label={title} aria-description={disabledReason || description} data-create-option={title} data-expanded={expanded} data-motion={motionEnabled ? "enabled" : "reduced"} className="canvas-connection-create-option group flex min-h-10 w-full cursor-pointer items-start gap-2 rounded-[var(--dock-item-radius)] px-2 py-1.5 text-left outline-none focus-visible:ring-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-40" style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted, background: expanded ? theme.toolbar.itemHover : undefined } as CSSProperties} onClick={() => { if (!disabledReason) onClick(); }}>
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: theme.toolbar.itemHover }}>{icon}</span>
<button
type="button"
aria-disabled={Boolean(disabledReason)}
aria-label={title}
aria-description={disabledReason || description}
data-create-option={title}
data-expanded={expanded}
data-motion={motionEnabled ? "enabled" : "reduced"}
className="canvas-connection-create-option group flex min-h-10 w-full cursor-pointer items-start gap-2 rounded-[var(--dock-item-radius)] px-2 py-1.5 text-left outline-none focus-visible:ring-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-40"
style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted, background: expanded ? theme.toolbar.itemHover : undefined } as CSSProperties}
onClick={() => {
if (!disabledReason) onClick();
}}
>
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: theme.toolbar.itemHover }}>
{icon}
</span>
<span className="min-w-0 flex-1 pt-1.5">
<span className="flex items-center gap-2 text-[var(--fs-tiny)] font-semibold leading-4">{title}</span>
<span aria-hidden="true" className="canvas-connection-create-description" style={{ color: theme.node.muted }}><span className="min-h-0 overflow-hidden"><span className="block pt-1 whitespace-normal break-words text-[var(--fs-micro)] leading-relaxed">{disabledReason || description}</span></span></span>
<span aria-hidden="true" className="canvas-connection-create-description" style={{ color: theme.node.muted }}>
<span className="min-h-0 overflow-hidden">
<span className="block pt-1 whitespace-normal break-words text-[var(--fs-micro)] leading-relaxed">{disabledReason || description}</span>
</span>
</span>
</span>
</button>
);

View File

@@ -0,0 +1,131 @@
.canvas-workspace-panel .sidebar-subtabs,
.canvas-workspace-panel .col-feed-tabs,
.canvas-node-toolbar-menu .col-feed-tabs,
.canvas-workspace-panel .col-tag-filter,
.canvas-node-toolbar-menu .col-tag-filter {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.canvas-workspace-panel .sidebar-subtabs button,
.canvas-workspace-panel .col-feed-tab,
.canvas-node-toolbar-menu .col-feed-tab,
.canvas-workspace-panel .col-tag-chip,
.canvas-node-toolbar-menu .col-tag-chip {
padding: var(--space-1) var(--space-2);
border-radius: var(--r-md);
font-size: var(--fs-caption);
}
.canvas-workspace-panel .sidebar-subtabs button.active,
.canvas-workspace-panel button.on,
.canvas-node-toolbar-menu button.on {
background: var(--surface-active);
color: var(--foreground);
}
.canvas-workspace-panel .asset-filter-row,
.canvas-node-toolbar-menu .asset-filter-row {
display: flex;
align-items: center;
gap: var(--space-1);
}
.canvas-workspace-panel .asset-search,
.canvas-node-toolbar-menu .asset-search {
min-width: 0;
width: 100%;
background: transparent;
font-size: var(--fs-caption);
}
.canvas-node-tool-controls-inline .canvas-node-fixed-chip {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
height: var(--space-6);
max-width: 7rem;
padding: 0 var(--space-1);
border-radius: var(--r-md);
font-size: var(--fs-tiny);
font-weight: 500;
}
.canvas-node-tool-controls-inline .canvas-node-fixed-chip > svg {
width: 12px;
height: 12px;
}
.canvas-workspace-rail {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: var(--space-16);
min-height: 0;
padding: var(--space-4) var(--space-2);
gap: var(--space-3);
overflow-y: auto;
border-right: 1px solid var(--dock-border);
backdrop-filter: blur(var(--dock-blur));
}
.canvas-workspace-rail-items {
display: flex;
flex-direction: column;
gap: var(--space-3);
flex-shrink: 0;
}
.canvas-workspace-rail-divider {
height: 1px;
margin-inline: var(--space-2);
background: var(--dock-tooltip-border);
flex-shrink: 0;
}
.canvas-workspace-rail .canvas-workspace-rail-button {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
min-height: var(--space-12);
width: 100%;
flex-shrink: 0;
border-radius: var(--r-lg);
font-size: var(--fs-tiny);
line-height: 1.4;
font-weight: 500;
color: inherit;
transition:
background-color var(--motion-instant) ease-out,
color var(--motion-instant) ease-out;
}
.canvas-workspace-rail svg {
width: var(--space-5);
height: var(--space-5);
stroke-width: 1.65;
}
.canvas-workspace-rail .canvas-workspace-rail-button:hover {
background: var(--dock-command-hover);
}
.canvas-workspace-rail .canvas-workspace-rail-button[aria-pressed="true"] {
background: var(--dock-command-active);
color: var(--dock-command-active-text);
}
.canvas-workspace-rail-button[aria-pressed="true"]::before {
content: "";
position: absolute;
inset-inline-start: calc(-1 * var(--space-2));
top: var(--space-4);
bottom: var(--space-4);
width: 2px;
border-radius: var(--r-full);
background: currentColor;
}
.canvas-workspace-rail .canvas-workspace-rail-toggle {
margin-top: auto;
}
.canvas-workspace-rail :focus-visible {
outline: 2px solid var(--dock-command-active-text);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.canvas-workspace-rail .canvas-workspace-rail-button {
transition: none;
}
}

View File

@@ -0,0 +1,145 @@
import { Link } from "react-router";
import { CanvasWorkspaceAssetPanel } from "./canvas-workspace-asset-panel";
import { CanvasWorkspaceHistoryPanel } from "./canvas-workspace-history-panel";
import type { InsertAssetPayload } from "./asset-picker-modal";
import { useDeferredValue, useState } from "react";
import { Button, Grid } from "antd";
import { Home, PanelLeftClose, PanelLeftOpen, Layers, Images, ListChecks, History, X } from "lucide-react";
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { GenerationTask } from "@/services/api/task-center";
import { useCanvasWorkspaceTasks } from "@/pages/canvas/use-canvas-workspace-tasks";
import { CanvasWorkspaceNodeListPanel } from "./canvas-workspace-node-list-panel";
import { CanvasWorkspaceTaskPanel } from "./canvas-workspace-task-panel";
import "./canvas-workspace-panel.css";
export function CanvasWorkspacePanel({
projectId,
nodes,
selectedNodeIds,
onClose,
open,
onOpen,
onInsertAssets,
onFocus,
onAssets,
onProjectAssets,
onCancelTask,
}: {
projectId: string;
nodes: CanvasNodeData[];
selectedNodeIds: Set<string>;
onClose: () => void;
open: boolean;
onOpen: () => void;
onInsertAssets: (payloads: InsertAssetPayload[]) => Promise<unknown>;
onFocus: (id: string) => void;
onAssets: () => void;
onProjectAssets?: () => void;
onCancelTask: (task: GenerationTask) => void;
}) {
const [tab, setTab] = useState("nodes");
const theme = canvasThemes[useActiveTheme()];
const [query, setQuery] = useState("");
const history = tab === "history";
const deferredQuery = useDeferredValue(query);
const screens = Grid.useBreakpoint();
const tasks = useCanvasWorkspaceTasks(projectId, open && (tab === "tasks" || history));
const content = (
<div className="canvas-workspace-panel flex h-full min-h-0 flex-col bg-background text-foreground" data-canvas-no-zoom data-canvas-wheel-scroll onWheel={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<header className="flex items-center justify-between border-b border-border p-3">
<strong>工作区</strong>
<button aria-label="关闭工作区" onClick={onClose}>
<X className="size-4" />
</button>
</header>
{tab === "nodes" && (
<CanvasWorkspaceNodeListPanel nodes={nodes} results={searchCanvasNodes(nodes, deferredQuery, nodes.length)} query={query} deferredQuery={deferredQuery} selectedNodeIds={selectedNodeIds} onQueryChange={setQuery} onFocus={onFocus} />
)}
{tab === "assets" && <CanvasWorkspaceAssetPanel onInsert={onInsertAssets} onManage={onAssets} onProjectAssets={onProjectAssets} />}
{(tab === "tasks" || history) && (
<>
{tasks.error ? (
<div role="alert" className="p-4">
任务加载失败。<Button onClick={() => void tasks.refetch()}>重试</Button>
</div>
) : tasks.loading ? (
<p className="p-4">正在加载任务…</p>
) : history ? (
<CanvasWorkspaceHistoryPanel tasks={tasks.allTasks} refreshing={tasks.refreshing} onRefresh={() => void tasks.refetch()} onCancelTask={onCancelTask} />
) : (
<CanvasWorkspaceTaskPanel
tasks={history ? tasks.allTasks : tasks.tasks.filter((t) => t.status === "queued" || t.status === "running")}
refreshing={tasks.refreshing}
onRefresh={() => void tasks.refetch()}
onCancelTask={onCancelTask}
/>
)}
</>
)}
</div>
);
const entries = [
{ id: "nodes", label: "节点", icon: Layers },
{ id: "assets", label: "资产", icon: Images },
{ id: "tasks", label: "任务", icon: ListChecks },
{ id: "history", label: "历史", icon: History },
];
return (
<>
<nav className="canvas-workspace-rail" style={{ ...canvasDockStyle(theme), boxShadow: "none" }} aria-label="画布左侧菜单" data-canvas-no-zoom onWheel={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Link to="/" className="canvas-workspace-rail-button" aria-label="主页" title="返回主页">
<Home />
<span>主页</span>
</Link>
<div className="canvas-workspace-rail-divider" aria-hidden="true" />
<div className="canvas-workspace-rail-items">
{entries.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
className="canvas-workspace-rail-button"
title={open && tab === id ? `收起${label}` : `打开${label}`}
aria-pressed={open && tab === id}
onClick={() => {
if (open && tab === id) onClose();
else {
setTab(id);
onOpen();
}
}}
>
<Icon />
<span>{label}</span>
</button>
))}
</div>
<button
className="canvas-workspace-rail-button canvas-workspace-rail-toggle"
type="button"
onClick={open ? onClose : onOpen}
aria-expanded={open}
aria-label={open ? "折叠面板" : "展开面板"}
title={open ? "收起面板,扩大画布" : "展开工作区面板"}
>
{open ? <PanelLeftClose /> : <PanelLeftOpen />}
<span>{open ? "收起" : "展开"}</span>
</button>
</nav>
{open &&
(screens.lg ? (
<aside className="w-80 shrink-0 border-r border-border min-h-0" aria-label="画布工作区">
{content}
</aside>
) : (
<AppDrawer flush open placement="left" title={null} closable={false} size={320} onClose={onClose}>
{content}
</AppDrawer>
))}
</>
);
}

View File

@@ -0,0 +1,130 @@
import { memo, useEffect, useState } from "react";
import { CheckCircle2, ChevronDown, ChevronUp, Clock3, Coins, LoaderCircle, MoreHorizontal, XCircle } from "lucide-react";
import { Dropdown, type MenuProps } from "antd";
import { formatCredits } from "@/constant/credits";
import { formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { useUserStore } from "@/stores/use-user-store";
// 任务列表项:任务面板与历史面板共用;进度、时长、计费与取消菜单都在这里渲染。
export const TaskListItem = memo(function TaskListItem({ task, onCancelTask }: { task: GenerationTask; onCancelTask?: (task: GenerationTask) => void }) {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const [expanded, setExpanded] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (task.status !== "queued" && task.status !== "running") return;
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [task.status]);
const showsProgress = generationTaskShowsProgress(task);
const progress = showsProgress && typeof task.progress === "number" ? Math.max(0, Math.min(100, Math.round(task.progress))) : undefined;
const startedAt = task.startedAt || task.createdAt;
const elapsedMs = Math.max(0, (task.completedAt ? parseTime(task.completedAt) : now) - parseTime(startedAt));
const durationLabel = `${task.status === "queued" ? "已等待" : "已运行"} ${formatDuration(elapsedMs)}`;
const billingLabel = task.billing ? `${({ reserved: "冻结", running: "处理中", settled: "已结算", refunded: "已退还", uncertain: "待核对" } as const)[task.billing.status]} ${formatCredits(task.billing.amountMicrocredits)} 积分` : "";
const isActive = task.status === "queued" || task.status === "running";
const statusColor = task.status === "running" ? "var(--primary)" : task.status === "succeeded" ? "var(--success, #16a34a)" : task.status === "failed" ? "var(--danger, #dc2626)" : "color-mix(in srgb, var(--foreground) 40%, transparent)";
const menuItems: MenuProps["items"] = [...(isActive && onCancelTask ? [{ key: "cancel", danger: true, icon: <XCircle className="size-3.5" />, label: "取消任务", onClick: () => onCancelTask(task) }] : [])];
return (
<div
className="group relative rounded-[var(--r-md)] px-2 py-1.5 text-left transition-[background-color] hover:bg-[var(--surface-hover)] focus-within:ring-2 focus-within:ring-primary/35"
style={{ contentVisibility: "auto", containIntrinsicSize: "48px" }}
>
<button type="button" className="flex w-full min-w-0 items-start gap-2 text-left" onClick={() => setExpanded((value) => !value)} aria-expanded={expanded}>
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ background: `color-mix(in srgb, ${statusColor} 12%, transparent)`, color: statusColor }}>
{isActive ? (
<LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" />
) : task.status === "succeeded" ? (
<CheckCircle2 className="size-3.5" />
) : task.status === "failed" ? (
<XCircle className="size-3.5" />
) : (
<Clock3 className="size-3.5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium leading-4 text-foreground" title={`${formatTaskKind(task)}:${task.prompt}`}>
{task.prompt}
</span>
<span className="shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none" style={{ borderColor: `color-mix(in srgb, ${statusColor} 30%, transparent)`, color: statusColor }}>
{generationTaskStatusLabel(task)}
</span>
</span>
{showsProgress && progress !== undefined && isActive ? (
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/10">
<div className="h-full rounded-full transition-[width] duration-300 ease-out" style={{ width: `${progress}%`, background: statusColor }} />
</div>
) : null}
<span className="mt-1.5 flex min-w-0 items-center gap-1 text-[10px] leading-3 text-foreground/40">
<Clock3 className="size-2.5 shrink-0" />
<span className="truncate">{durationLabel}</span>
{creditsEnabled && billingLabel ? <span className="truncate">· {billingLabel}</span> : null}
<span className="mt-0.5 block truncate text-[10px] leading-3 text-foreground/45" title={generationTaskStageLabel(task)}>
{generationTaskStageLabel(task)}
</span>
</span>
</span>
{expanded ? <ChevronUp className="mt-0.5 size-3.5 shrink-0 text-foreground/40" /> : <ChevronDown className="mt-0.5 size-3.5 shrink-0 text-foreground/40" />}
</button>
{expanded ? (
<div className="mt-1.5 border-t border-border/50 px-1 pt-1.5 text-[10px] leading-3 text-foreground/50">
<div className="flex items-center justify-between gap-2">
<span>当前阶段</span>
<span className="max-w-[200px] truncate text-right text-foreground/70">{generationTaskStageLabel(task)}</span>
</div>
{task.model ? (
<div className="mt-1 flex items-center justify-between gap-2">
<span>模型</span>
<span className="max-w-[200px] truncate text-right text-foreground/70">{task.model}</span>
</div>
) : null}
{task.prompt ? (
<div className="mt-1">
<span className="block">提示词</span>
<span className="mt-0.5 block line-clamp-2 text-foreground/60">{task.prompt}</span>
</div>
) : null}
{creditsEnabled && task.billing ? (
<div className="mt-1 flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1">
<Coins className="size-2.5" />
计费
</span>
<span className="text-foreground/70">{billingLabel}</span>
</div>
) : null}
</div>
) : null}
{menuItems.length > 0 ? (
<Dropdown trigger={["click"]} menu={{ items: menuItems }}>
<button type="button" className="asset-more absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" aria-label="更多操作" title="更多">
<MoreHorizontal className="size-3.5" />
</button>
</Dropdown>
) : null}
</div>
);
});
function parseTime(value?: string) {
if (!value) return Date.now();
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : Date.now();
}
function formatDuration(value: number) {
const totalSeconds = Math.floor(value / 1_000);
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}

View File

@@ -0,0 +1,35 @@
import { ListChecks, RefreshCw } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import type { GenerationTask } from "@/services/api/task-center";
import { TaskListItem } from "./canvas-workspace-task-list-item";
// 任务面板:展示用户直接发起的生成任务;云端 Agent 内部任务由历史面板承载。
export function CanvasWorkspaceTaskPanel({ tasks, refreshing, onRefresh, onCancelTask }: { tasks: GenerationTask[]; refreshing?: boolean; onRefresh?: () => void; onCancelTask?: (task: GenerationTask) => void }) {
return (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2.5">
<ListChecks className="size-3.5 shrink-0" />
<span className="truncate text-xs font-semibold">任务</span>
<span className="tabular-nums text-foreground/32">{tasks.length.toLocaleString("zh-CN")}</span>
<span className="ml-auto">
<button type="button" className="icon-btn tip-down" data-tip="刷新" aria-label="刷新" onClick={onRefresh}>
<RefreshCw className={`size-3.5 ${refreshing ? "animate-spin" : ""}`} />
</button>
</span>
</header>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain py-1">
{tasks.length ? (
<div className="space-y-0.5">
{tasks.map((task) => (
<TaskListItem key={task.id} task={task} onCancelTask={onCancelTask} />
))}
</div>
) : (
<WorkspaceState icon="canvas" compact title="暂无任务" description="点击画布上的生成按钮开始创作。" />
)}
</div>
</>
);
}

View File

@@ -0,0 +1,581 @@
import { useUserStore } from "@/stores/use-user-store";
import { FEED_TABS, SUB_TAB_TAGS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation";
type FeedTab = ToolScope;
import { memo, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Eye, Loader2, MoreHorizontal, Plus, Star, Trash2, Upload, Wrench, X } from "lucide-react";
import { App, Button, Dropdown, Form, Input, Radio, Select, Space, type MenuProps } from "antd";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { AppModal } from "@/components/ui/product/app-modal";
import { VideoPlayer } from "@/components/video-player";
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { createTool, deleteTool, listTools, setToolFavorite, type ToolScope, type ToolSummary, type ToolType, type ToolVisibility } from "@/services/api/tools";
type ToolSubTab = "style" | "effect" | "motion" | "nine_grid";
// 工具类型与后端一致。
const TOOL_SUB_TABS: Array<{ id: ToolSubTab; type: ToolType; label: string }> = [
{ id: "style", type: "style", label: "风格" },
{ id: "nine_grid", type: "nine_grid", label: "九宫格" },
{ id: "effect", type: "effect", label: "特效" },
{ id: "motion", type: "motion", label: "运镜" },
];
const TOOL_PAGE_SIZE = 40;
const TOOL_SEARCH_DEBOUNCE_MS = 250;
const VIDEO_URL_RE = /\.(mp4|webm|mov|m4v)(?:$|[?#])/i;
// 上传到资源服务的文件 URL(/api/resources/:id/file)没有扩展名,按路径形态识别。
const RESOURCE_FILE_URL_RE = /\/resources\/[^/?#]+\/file(?:[?#]|$)/i;
function isVideoUrl(url: string) {
return VIDEO_URL_RE.test(url) || RESOURCE_FILE_URL_RE.test(url);
}
// 悬停播放预览视频的工具类型:运镜、特效
const HOVER_VIDEO_TYPES = new Set(["motion", "effect"]);
type CreateToolFormValues = {
label: string;
tag?: string;
visibility: ToolVisibility;
desc?: string;
prompt: string;
cover?: string;
mediaUrl?: string;
ratio?: string;
};
export type CanvasWorkspaceToolPanelProps = {
onInsert?: (tool: ToolSummary) => void;
};
// 种子数据的 mediaUrl 多为相对路径(如 "period_idol/cover.webp"),无法直接预览;
// 绝对 http(s) URL 原样保留,站内资源路径(/api/resources/…)按当前 origin 绝对化。
function resolveToolPreview(tool: ToolSummary): { kind: "video" | "image"; src: string } | null {
const media = toAbsoluteUrl(tool.mediaUrl);
if (media && isVideoUrl(media)) return { kind: "video", src: media };
const cover = toAbsoluteUrl(tool.cover) || media;
return cover ? { kind: "image", src: cover } : null;
}
export function CanvasWorkspaceToolPanel({ onInsert }: CanvasWorkspaceToolPanelProps) {
const userId = useUserStore((s) => s.user?.id);
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const [subTab, setSubTab] = useState<ToolSubTab>("style");
const [feedTab, setFeedTab] = useState<FeedTab>("public");
const [tagFilter, setTagFilter] = useState("");
const [searchInput, setSearchInput] = useState("");
const [searchText, setSearchText] = useState("");
const [previewTool, setPreviewTool] = useState<ToolSummary | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<CreateToolFormValues>();
const [coverUploading, setCoverUploading] = useState(false);
const [mediaUploading, setMediaUploading] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const mediaInputRef = useRef<HTMLInputElement>(null);
// 上传成功才回填表单;本地降级(pendingRemoteUpload)拿到的是页面级 objectURL,
// 刷新即失效,绝不能写进工具数据。
async function handleCoverUpload(file: File) {
setCoverUploading(true);
try {
const image = await uploadImage(file);
if (image.pendingRemoteUpload) throw new Error(image.remoteUploadError || "图片暂存本机,尚未上传到云端,请稍后重试");
createForm.setFieldValue("cover", toAbsoluteUrl(image.url));
message.success("封面已上传");
} catch (error) {
message.error(error instanceof Error ? error.message : "封面上传失败");
} finally {
setCoverUploading(false);
}
}
async function handleMediaUpload(file: File) {
setMediaUploading(true);
try {
const uploaded = await uploadMediaFile(file, "video");
if (uploaded.pendingRemoteUpload) throw new Error(uploaded.remoteUploadError || "视频暂存本机,尚未上传到云端,请稍后重试");
createForm.setFieldValue("mediaUrl", toAbsoluteUrl(uploaded.url));
message.success("演示视频已上传");
} catch (error) {
message.error(error instanceof Error ? error.message : "视频上传失败");
} finally {
setMediaUploading(false);
}
}
useEffect(() => {
const timer = window.setTimeout(() => setSearchText(searchInput.trim()), TOOL_SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [searchInput]);
const activeType = TOOL_SUB_TABS.find((tab) => tab.id === subTab)?.type ?? "style";
const subTabLabel = TOOL_SUB_TABS.find((tab) => tab.id === subTab)?.label || "";
const feedTabLabel = FEED_TABS.find((tab) => tab.id === feedTab)?.label || "";
const tagChips = useMemo(() => {
const tags = subTab === "style" ? SUB_TAB_TAGS.style : subTab === "motion" ? SUB_TAB_TAGS.motion : [];
return [{ id: "", label: "全部" }, ...tags];
}, [subTab]);
const tagOptions = useMemo(() => {
const tags = subTab === "style" ? SUB_TAB_TAGS.style : subTab === "motion" ? SUB_TAB_TAGS.motion : [];
return tags.map((tag) => ({ value: tag.id, label: tag.label }));
}, [subTab]);
const toolsQuery = useInfiniteQuery({
queryKey: ["canvas-tools", userId, feedTab, activeType, tagFilter, searchText],
queryFn: ({ pageParam, signal }) =>
listTools(
{
page: pageParam as number,
pageSize: TOOL_PAGE_SIZE,
scope: feedTab,
type: activeType,
tag: tagFilter || undefined,
search: searchText || undefined,
},
{ signal },
),
initialPageParam: 1,
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined),
enabled: Boolean(userId),
});
const tools = useMemo(() => toolsQuery.data?.pages.flatMap((page) => page.tools) ?? [], [toolsQuery.data]);
const totalCount = toolsQuery.data?.pages[0]?.totalCount ?? 0;
const invalidateTools = () => queryClient.invalidateQueries({ queryKey: ["canvas-tools"] });
const favoriteMutation = useMutation({
mutationFn: ({ id, favorite }: { id: number; favorite: boolean }) => setToolFavorite(id, favorite),
onSuccess: () => void invalidateTools(),
onError: (error) => message.error(error instanceof Error ? error.message : "收藏状态更新失败"),
});
const deleteMutation = useMutation({
mutationFn: deleteTool,
onSuccess: () => {
void invalidateTools();
message.success("工具已删除");
},
onError: (error) => message.error(error instanceof Error ? error.message : "工具删除失败"),
});
const createMutation = useMutation({
mutationFn: (values: CreateToolFormValues) =>
createTool({
type: activeType,
label: values.label.trim(),
desc: values.desc?.trim() || undefined,
tag: values.tag?.trim() || undefined,
cover: values.cover?.trim() || undefined,
prompt: values.prompt.trim(),
ratio: values.ratio?.trim() || undefined,
mediaUrl: values.mediaUrl?.trim() || undefined,
visibility: values.visibility,
}),
onSuccess: () => {
message.success("工具已创建");
setCreateOpen(false);
setFeedTab("custom");
void invalidateTools();
},
onError: (error) => message.error(error instanceof Error ? error.message : "工具创建失败"),
});
function confirmDelete(tool: ToolSummary) {
modal.confirm({
title: `删除“${tool.label}”?`,
content: "删除后不可恢复,该工具的收藏记录也会一同移除。",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => deleteMutation.mutateAsync(tool.id),
});
}
function handleScroll(event: UIEvent<HTMLDivElement>) {
const el = event.currentTarget;
if (el.scrollHeight - el.scrollTop - el.clientHeight > 120) return;
if (toolsQuery.hasNextPage && !toolsQuery.isFetchingNextPage) void toolsQuery.fetchNextPage();
}
const emptyTitle = feedTab === "favorites" ? "还没有收藏工具" : feedTab === "custom" ? "还没有自定义工具" : "没有匹配工具";
const emptyDescription = feedTab === "custom" ? "点击右上角“+”新建一个工具。" : "换一个分类或关键词继续搜索。";
const preview = previewTool ? resolveToolPreview(previewTool) : null;
return (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2.5">
<Wrench className="size-3.5 shrink-0" />
<span className="truncate text-xs font-semibold">工具</span>
<span className="tabular-nums text-foreground/32">{totalCount.toLocaleString("zh-CN")}</span>
</header>
<div className="sidebar-subtabs shrink-0 border-b border-border/70">
{TOOL_SUB_TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={subTab === tab.id ? "active" : ""}
aria-pressed={subTab === tab.id}
onClick={() => {
setSubTab(tab.id);
setTagFilter("");
}}
>
{tab.label}
</button>
))}
</div>
<div className="asset-filters shrink-0 border-b border-border/70 px-2 py-1.5">
<div className="col-feed-tabs">
{FEED_TABS.map((tab) => (
<button key={tab.id} type="button" className={`col-feed-tab${feedTab === tab.id ? " on" : ""}`} aria-pressed={feedTab === tab.id} onClick={() => setFeedTab(tab.id)}>
{tab.label}
</button>
))}
<span className="asset-head-actions">
<button type="button" className="icon-btn tip-down" data-tip={`新建${subTabLabel}`} aria-label={`新建${subTabLabel}`} onClick={() => setCreateOpen(true)}>
<Plus className="size-3.5" />
</button>
</span>
</div>
<div className="asset-filter-row mt-1.5">
<input className="asset-search" placeholder="搜索名称/标签" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} aria-label="搜索工具" />
{searchInput ? (
<button type="button" className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover hover:text-foreground" onClick={() => setSearchInput("")} aria-label="清空搜索">
<X className="size-3" />
</button>
) : null}
</div>
<div className="col-tag-filter mt-1.5">
{tagChips.map((tag) => (
<button key={tag.id || "all"} type="button" className={`col-tag-chip${tagFilter === tag.id ? " on" : ""}`} aria-pressed={tagFilter === tag.id} onClick={() => setTagFilter(tag.id)}>
{tag.label}
</button>
))}
</div>
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain p-2" onScroll={handleScroll}>
{toolsQuery.isLoading ? (
<ToolGridSkeleton />
) : toolsQuery.isError ? (
<WorkspaceErrorState compact description={toolsQuery.error instanceof Error ? toolsQuery.error.message : undefined} onRetry={() => void toolsQuery.refetch()} />
) : tools.length ? (
<>
<div className="grid grid-cols-2 gap-1.5">
{tools.map((tool) => (
<ToolPresetCard
key={tool.id}
tool={tool}
feedTabLabel={feedTabLabel}
favoritePending={favoriteMutation.isPending}
onInsert={onInsert}
onView={setPreviewTool}
onToggleFavorite={(item) => favoriteMutation.mutate({ id: item.id, favorite: !item.favorited })}
onDelete={confirmDelete}
/>
))}
</div>
{toolsQuery.isFetchingNextPage ? (
<div className="flex items-center justify-center gap-1.5 py-3 text-[11px] text-foreground/45">
<Loader2 className="size-3.5 animate-spin" />
正在加载…
</div>
) : null}
</>
) : (
<WorkspaceState icon="canvas" compact title={emptyTitle} description={emptyDescription} />
)}
</div>
{previewTool && preview ? (
preview.kind === "video" ? (
<AppModal flush open title={previewTool.label} onCancel={() => setPreviewTool(null)} footer={null} width="min(1200px, calc(100vw - 32px))">
<VideoPlayer src={preview.src} title={previewTool.label || "视频预览"} className="max-h-[84vh] max-w-full bg-black" />
</AppModal>
) : (
<CanvasImagePreview src={preview.src} alt={previewTool.label} onClose={() => setPreviewTool(null)} />
)
) : null}
<AppModal
open={createOpen}
title={`新建${subTabLabel}`}
okText="创建"
cancelText="取消"
confirmLoading={createMutation.isPending}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
afterClose={() => createForm.resetFields()}
>
<Form<CreateToolFormValues> form={createForm} layout="vertical" requiredMark={false} initialValues={{ visibility: "private" }} onFinish={(values) => createMutation.mutate(values)} className="max-h-[72vh] overflow-y-auto p-6 px-8">
<Form.Item name="label" label="名称" rules={[{ required: true, whitespace: true, message: "请输入工具名称" }, { max: 120 }]}>
<Input maxLength={120} showCount placeholder="例如:电影感胶片" />
</Form.Item>
<div className="grid grid-cols-2 gap-3">
<Form.Item name="tag" label="标签">
{tagOptions.length ? <Select allowClear showSearch options={tagOptions} placeholder="选择标签" /> : <Input maxLength={64} placeholder="可选" />}
</Form.Item>
<p className="text-xs text-muted-foreground">预览仅支持本人上传的资源;公开工具请移除私人预览。</p>
<Form.Item name="visibility" label="可见性">
<Radio.Group
optionType="button"
buttonStyle="solid"
options={[
{ label: "私有", value: "private" },
{ label: "公开", value: "public" },
]}
/>
</Form.Item>
</div>
<Form.Item name="desc" label="描述" rules={[{ max: 500 }]}>
<Input.TextArea rows={2} maxLength={500} showCount autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item name="prompt" label="提示词" rules={[{ required: true, whitespace: true, message: "请输入提示词" }, { max: 8000 }]}>
<Input.TextArea rows={5} maxLength={8000} showCount placeholder="该工具应用到生成节点时使用的提示词" />
</Form.Item>
<Form.Item label="封面图">
<Space.Compact block>
<Form.Item name="cover" noStyle className="flex-1 min-w-0" rules={[{ type: "url", message: "请输入合法 URL" }]}>
<Input placeholder="上传图片或粘贴 URL" allowClear disabled={coverUploading} />
</Form.Item>
<Button icon={<Upload className="size-3.5" />} loading={coverUploading} onClick={() => coverInputRef.current?.click()}>
上传
</Button>
</Space.Compact>
</Form.Item>
{HOVER_VIDEO_TYPES.has(activeType) ? (
<Form.Item label="演示视频">
<Space.Compact block>
<Form.Item name="mediaUrl" noStyle className="flex-1 min-w-0" rules={[{ type: "url", message: "请输入合法 URL" }]}>
<Input placeholder="上传视频或粘贴 URL" allowClear disabled={mediaUploading} />
</Form.Item>
<Button icon={<Upload className="size-3.5" />} loading={mediaUploading} onClick={() => mediaInputRef.current?.click()}>
上传
</Button>
</Space.Compact>
</Form.Item>
) : null}
<input
ref={coverInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleCoverUpload(file);
}}
/>
{HOVER_VIDEO_TYPES.has(activeType) ? (
<input
ref={mediaInputRef}
type="file"
accept="video/*"
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleMediaUpload(file);
}}
/>
) : null}
{/* <Form.Item name="ratio" label="比例" rules={[{ max: 32 }]}>
<Input placeholder="可选,例如 original、16:9" />
</Form.Item> */}
</Form>
</AppModal>
</>
);
}
const ToolGridSkeleton = memo(function ToolGridSkeleton() {
return (
<div className="grid grid-cols-2 gap-1.5" aria-busy="true" aria-live="polite">
{Array.from({ length: 8 }, (_, index) => (
<div key={index} className="flex flex-col gap-1 p-1.5">
<div className="aspect-[4/3] w-full animate-pulse rounded-[var(--r-sm)] bg-surface-active" />
<div className="h-3 w-3/4 animate-pulse rounded bg-surface-active" />
</div>
))}
</div>
);
});
const ToolPresetCard = memo(function ToolPresetCard({
tool,
feedTabLabel,
favoritePending,
onInsert,
onView,
onToggleFavorite,
onDelete,
}: {
tool: ToolSummary;
feedTabLabel: string;
favoritePending: boolean;
onInsert?: (tool: ToolSummary) => void;
onView?: (tool: ToolSummary) => void;
onToggleFavorite?: (tool: ToolSummary) => void;
onDelete?: (tool: ToolSummary) => void;
}) {
const [failed, setFailed] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [hovered, setHovered] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [videoFailed, setVideoFailed] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" };
const coverUrl = toAbsoluteUrl(tool.cover);
const reduceMotion = useMemo(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches, []);
const mediaAbsoluteUrl = toAbsoluteUrl(tool.mediaUrl);
const hoverVideoUrl = mediaAbsoluteUrl && isVideoUrl(mediaAbsoluteUrl) ? mediaAbsoluteUrl : "";
const showHoverVideo = !reduceMotion && HOVER_VIDEO_TYPES.has(tool.type) && Boolean(hoverVideoUrl) && !videoFailed;
useEffect(() => {
if (!menuOpen) return;
const handler = (e: PointerEvent) => {
const target = e.target instanceof Element ? e.target : null;
if (target && !rootRef.current?.contains(target) && !target.closest(".ant-dropdown")) {
setMenuOpen(false);
}
};
document.addEventListener("pointerdown", handler, true);
return () => document.removeEventListener("pointerdown", handler, true);
}, [menuOpen]);
// 视频只在悬停时挂载;浏览器自动播放策略要求静音,React 首次渲染的 muted 属性不一定写入 DOM 属性,需手动赋值后再 play。
useEffect(() => {
if (!hovered || !showHoverVideo) {
setVideoReady(false);
return;
}
const video = videoRef.current;
if (!video) return;
video.muted = true;
// 缓存命中时 canplay 可能已派发,直接按 readyState 标记可淡入。
if (video.readyState >= 3) setVideoReady(true);
const handle = window.setTimeout(() => {
void video.play().catch(() => {});
}, 0);
return () => window.clearTimeout(handle);
}, [hovered, showHoverVideo]);
const menuItems: MenuProps["items"] = useMemo(() => {
const items: NonNullable<MenuProps["items"]> = [];
items.push({ key: "insert", icon: <Plus className="size-3.5" />, label: "插入画布", onClick: () => onInsert?.(tool) });
items.push({ key: "view", icon: <Eye className="size-3.5" />, label: "查看", onClick: () => onView?.(tool) });
items.push({ type: "divider" as const });
items.push({
key: "favorite",
icon: <Star className={tool.favorited ? "size-3.5 fill-current" : "size-3.5"} />,
label: tool.favorited ? "取消收藏" : "收藏",
onClick: () => onToggleFavorite?.(tool),
});
if (tool.source === "user" && tool.ownerId === useUserStore.getState().user?.id) {
items.push({ type: "divider" as const });
items.push({ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => onDelete?.(tool) });
}
return items;
}, [tool, onInsert, onView, onToggleFavorite, onDelete]);
return (
<div
ref={rootRef}
className="group relative flex flex-col gap-1 rounded-[var(--r-md)] border p-1.5 text-left transition-[background-color] hover:bg-[var(--surface-hover)] focus-within:ring-2 focus-within:ring-primary/35"
style={commonStyle}
onMouseEnter={() => {
if (showHoverVideo) setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-[var(--r-sm)] border" style={commonStyle}>
{coverUrl && !failed ? (
<img src={coverUrl} alt="" loading="lazy" decoding="async" className="asset-thumb h-full w-full object-cover" onError={() => setFailed(true)} />
) : (
<div className="grid h-full w-full place-items-center text-foreground/30">
<Wrench className="size-4" />
</div>
)}
{/* 悬停播放的预览视频:仅 hover 时挂载,静音/循环/内联满足各浏览器自动播放策略,首帧就绪后淡入覆盖封面 */}
{showHoverVideo && hovered ? (
<video
ref={videoRef}
src={hoverVideoUrl}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out ${videoReady ? "opacity-100" : "opacity-0"}`}
muted
playsInline
loop
autoPlay
preload="auto"
aria-hidden="true"
onCanPlay={() => setVideoReady(true)}
onError={() => setVideoFailed(true)}
/>
) : null}
{/* 左上角标签 */}
<span className="absolute left-1 top-1 rounded-[var(--r-sm)] bg-black/40 px-1.5 py-0.5 text-[9px] font-medium leading-3 text-white backdrop-blur-sm">{feedTabLabel}</span>
{/* 右上角操作按钮 */}
<span className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="grid size-5 place-items-center rounded-[var(--r-sm)] bg-black/40 text-white backdrop-blur-sm hover:bg-black/55"
aria-label="查看"
title="查看"
onClick={(e) => {
e.stopPropagation();
onView?.(tool);
}}
>
<Eye className="size-3" />
</button>
<button
type="button"
disabled={favoritePending}
className="grid size-5 place-items-center rounded-[var(--r-sm)] bg-black/40 text-white backdrop-blur-sm hover:bg-black/55 disabled:opacity-60"
aria-label={tool.favorited ? "取消收藏" : "收藏"}
title={tool.favorited ? "取消收藏" : "收藏"}
aria-pressed={tool.favorited}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite?.(tool);
}}
>
<Star className={tool.favorited ? "size-3 fill-current text-amber-300" : "size-3"} />
</button>
<Dropdown trigger={["click"]} menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} autoAdjustOverflow>
<button
type="button"
className="grid size-5 place-items-center rounded-[var(--r-sm)] bg-black/40 text-white backdrop-blur-sm hover:bg-black/55"
aria-label="更多操作"
title="更多"
onClick={(e) => {
e.stopPropagation();
}}
>
<MoreHorizontal className="size-3" />
</button>
</Dropdown>
</span>
</div>
<span className="truncate text-[11px] font-medium leading-4 text-foreground">{tool.label}</span>
{tool.desc ? <span className="truncate text-[10px] leading-3 text-foreground/40">{tool.desc}</span> : null}
</div>
);
});