feat(plugin-sdk): add TypeScript SDK for Infinite Canvas plugins with automatic JSX and build support

This commit is contained in:
HouYunFei
2026-07-15 15:31:48 +08:00
parent 8d3f244524
commit 75aafc115f
82 changed files with 3228 additions and 3288 deletions
@@ -16,7 +16,7 @@ type Props = {
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
return (
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<Modal title="选择资产" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<MyAssetsTab onInsert={onInsert} />
</Modal>
);
@@ -90,7 +90,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
placeholder="搜索资产"
value={keyword}
allowClear
onChange={(e) => {
@@ -122,7 +122,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有资产" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
@@ -116,7 +116,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold"></div>
<div className="truncate text-[11px] opacity-55">@ </div>
<div className="truncate text-[11px] opacity-55">@ </div>
</div>
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
</div>
@@ -926,8 +926,8 @@ function siteToolSummary(name: string, result: unknown) {
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
if (name === "canvas_list_projects") return `${numberField(data, "total")} 个画布`;
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
if (name === "assets_list") return `${numberField(data, "total")}素材`;
if (name === "assets_add") return "已加入我的素材";
if (name === "assets_list") return `${numberField(data, "total")}资产`;
if (name === "assets_add") return "已加入我的资产";
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
return "已完成";
@@ -140,7 +140,7 @@ export function CanvasNodeHoverToolbar({
];
const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的资产", label: "存资产", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
@@ -1,9 +1,10 @@
import { useState } from "react";
import { App, Button, Input, Modal, Popconfirm, Switch } from "antd";
import { AlertTriangle, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd";
import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
import { fetchOfficialPlugins, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
@@ -15,7 +16,32 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
const [installing, setInstalling] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const handleInstall = async () => {
const [official, setOfficial] = useState<OfficialPluginEntry[]>([]);
const [loadingOfficial, setLoadingOfficial] = useState(false);
const [officialError, setOfficialError] = useState<string | null>(null);
const recordById = useMemo(() => new Map(plugins.map((item) => [item.id, item])), [plugins]);
const localPlugins = useMemo(() => plugins.filter((item) => item.local), [plugins]);
const thirdPartyPlugins = useMemo(() => plugins.filter((item) => !item.local && !item.official), [plugins]);
const loadOfficial = useCallback(async () => {
setLoadingOfficial(true);
setOfficialError(null);
try {
setOfficial(await fetchOfficialPlugins());
} catch (error) {
setOfficialError(error instanceof Error ? error.message : String(error));
} finally {
setLoadingOfficial(false);
}
}, []);
// 打开面板时拉取官方清单(仅在尚未加载过时,避免重复请求)
useEffect(() => {
if (open && official.length === 0 && !loadingOfficial && !officialError) void loadOfficial();
}, [open, official.length, loadingOfficial, officialError, loadOfficial]);
const handleInstallUrl = async () => {
const target = url.trim();
if (!target) return;
setInstalling(true);
@@ -30,6 +56,18 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
}
};
const handleInstallOfficial = async (entry: OfficialPluginEntry) => {
setBusyId(entry.id);
try {
const plugin = await installPluginFromUrl(entry.url, { official: true });
message.success(`已安装 ${plugin.name}`);
} catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setBusyId(null);
}
};
const runOnPlugin = async (record: InstalledPlugin, action: () => Promise<void>, successText: string) => {
setBusyId(record.id);
try {
@@ -42,72 +80,124 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
}
};
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
const installedControls = (record: InstalledPlugin) => (
<>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
{!record.local && (
<>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
</>
)}
</>
);
const versionTag = (version: string) => (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{version}
</span>
);
const emptyHint = (text: string) => (
<div className="py-10 text-center text-sm" style={{ color: theme.node.muted }}>
{text}
</div>
);
// 通用插件行:图标 + 标题(名称 + 版本)+ 描述 + 右侧操作
const row = (key: string, icon: ReactNode, name: string, version: string, subtitle: string | undefined, right: ReactNode) => (
<div key={key} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
<span className="grid size-9 shrink-0 place-items-center rounded-lg text-base" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium" style={{ color: theme.node.text }}>
<span className="truncate">{name}</span>
{versionTag(version)}
</div>
{subtitle ? (
<div className="mt-0.5 truncate text-xs" style={{ color: theme.node.muted }}>
{subtitle}
</div>
) : null}
</div>
{right}
</div>
);
const officialTab = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs" style={{ color: theme.node.muted }}>
,
</div>
<Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}>
</Button>
</div>
{officialError ? (
<div className="rounded-lg border px-3 py-2 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
{officialError}
</div>
) : loadingOfficial && official.length === 0 ? (
emptyHint("正在获取官方插件…")
) : official.length === 0 ? (
emptyHint("暂无官方插件")
) : (
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => {
const record = recordById.get(entry.id);
return row(
entry.id,
entry.icon || <Puzzle className="size-4" />,
entry.name,
entry.version,
entry.description,
record ? (
installedControls(record)
) : (
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
</Button>
),
);
})}
</div>
)}
</div>
);
const localTab = <div className="thin-scrollbar max-h-[52vh] space-y-2 overflow-auto">{localPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>;
const thirdPartyTab = (
<div className="space-y-3">
<div className="flex gap-2">
<Input placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js" value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear />
<Button type="primary" loading={installing} onClick={handleInstallUrl} icon={<Puzzle className="size-4" />}>
</Button>
</div>
<div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint("还没有安装第三方插件") : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>
</div>
);
const tabs = [
{ key: "official", label: "官方插件", children: officialTab },
...(localPlugins.length > 0 ? [{ key: "local", label: "本地插件", children: localTab }] : []),
{ key: "third", label: "第三方插件", children: thirdPartyTab },
];
return (
<Modal title="节点插件" open={open} onCancel={onClose} footer={null} centered width={640}>
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
<span>访 AI API Key</span>
</div>
<div className="flex gap-2">
<Input
placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js"
value={url}
onChange={(event) => setUrl(event.target.value)}
onPressEnter={handleInstall}
allowClear
/>
<Button type="primary" loading={installing} onClick={handleInstall} icon={<Puzzle className="size-4" />}>
</Button>
</div>
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{plugins.length === 0 ? (
<div className="py-10 text-center text-sm" style={{ color: theme.node.muted }}>
</div>
) : (
plugins.map((record) => (
<div key={record.id} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
<span className="grid size-9 shrink-0 place-items-center rounded-lg" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
<Puzzle className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium" style={{ color: theme.node.text }}>
<span className="truncate">{record.name}</span>
<span className="rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{record.version}
</span>
{record.local && (
<span className="rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: "#22c55e22", color: "#16a34a" }}>
</span>
)}
</div>
<div className="truncate text-xs" style={{ color: theme.node.muted }}>
{record.description || record.url}
</div>
</div>
<Switch
size="small"
checked={record.enabled}
loading={busyId === record.id}
onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")}
/>
{!record.local && (
<>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
</>
)}
</div>
))
)}
</div>
<Tabs defaultActiveKey="official" items={tabs} />
</div>
</Modal>
);
@@ -0,0 +1,224 @@
import { useMemo, useState } from "react";
import { Empty, Input, Select, Tag } from "antd";
import { ChevronRight, FileText, Image as ImageIcon, Music2, Search, Settings2, Square, Type, Video } from "lucide-react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { InsertAssetPayload } from "./asset-picker-modal";
type PanelTab = "canvas" | "assets";
type Props = {
nodes: CanvasNodeData[];
selectedNodeIds: Set<string>;
onFocusNode: (nodeId: string) => void;
onInsertAsset: (payload: InsertAssetPayload) => void;
};
const NODE_TYPE_ICON: Record<string, typeof Square> = {
[CanvasNodeType.Image]: ImageIcon,
[CanvasNodeType.Video]: Video,
[CanvasNodeType.Audio]: Music2,
[CanvasNodeType.Text]: Type,
[CanvasNodeType.Config]: Settings2,
[CanvasNodeType.Group]: Square,
};
const STATUS_COLOR: Record<string, string> = {
success: "#22c55e",
loading: "#f59e0b",
error: "#ef4444",
idle: "transparent",
};
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas");
return (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden border-r" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} data-canvas-no-zoom>
<div className="flex items-center gap-1 px-3 pt-3">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
</aside>
);
}
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className={cn("rounded-lg px-3 py-1.5 text-sm font-semibold transition", active ? "" : "opacity-55 hover:opacity-90")} style={active ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.text }}>
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// 画布 Tab —— 列出节点,点击居中放大并选中
// ---------------------------------------------------------------------------
const NODE_FILTER_OPTIONS = [
{ label: "全部", value: "all" },
{ label: "图片", value: CanvasNodeType.Image },
{ label: "视频", value: CanvasNodeType.Video },
{ label: "文本", value: CanvasNodeType.Text },
{ label: "音频", value: CanvasNodeType.Audio },
{ label: "配置", value: CanvasNodeType.Config },
{ label: "分组", value: CanvasNodeType.Group },
];
function nodePreviewText(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
return getNodeDefinition(node.type)?.title || node.type;
}
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) {
const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query)));
}, [nodes, keyword, typeFilter]);
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
<span className="text-xs font-medium opacity-60"></span>
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
<Select size="small" variant="borderless" className="ml-auto w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />
</div>
<div className="px-3 pb-2.5">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{filtered.length ? (
<div className="space-y-1.5">
{filtered.map((node) => {
const Icon = NODE_TYPE_ICON[node.type] || FileText;
const isImage = node.type === CanvasNodeType.Image && node.metadata?.content;
const active = selectedNodeIds.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => onFocusNode(node.id)}
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")}
style={active ? { background: theme.toolbar.activeBg } : undefined}
>
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md" style={{ background: theme.node.fill }}>
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-4.5 opacity-70" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span>
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
</button>
);
})}
</div>
) : (
<div className="pt-16 text-center text-sm opacity-40"></div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// ---------------------------------------------------------------------------
const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [
{ kind: "image", label: "图片", icon: ImageIcon },
{ kind: "video", label: "视频", icon: Video },
{ kind: "text", label: "文本", icon: FileText },
];
function buildInsertPayload(asset: Asset): InsertAssetPayload {
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title };
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height };
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title };
}
function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
const [tagFilter, setTagFilter] = useState<string>("all");
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return assets.filter((asset) => (tagFilter === "all" || (asset.tags || []).includes(tagFilter)) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)));
}, [assets, keyword, tagFilter]);
const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]);
return (
<div className="flex h-full flex-col">
<div className="px-3 pb-2">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
{allTags.length ? (
<div className="flex flex-wrap gap-1.5 px-3 pb-2">
<Tag.CheckableTag checked={tagFilter === "all"} className={cn("prompt-filter-tag", tagFilter === "all" && "is-active")} onChange={() => setTagFilter("all")}>
</Tag.CheckableTag>
{allTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={tagFilter === tag} className={cn("prompt-filter-tag", tagFilter === tag && "is-active")} onChange={() => setTagFilter((prev) => (prev === tag ? "all" : tag))}>
{tag}
</Tag.CheckableTag>
))}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{groups.length ? (
<div className="space-y-1">
{groups.map((group) => {
const isCollapsed = collapsed[group.kind];
return (
<div key={group.kind}>
<button type="button" onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
<group.icon className="size-3.5" />
<span>{group.label}</span>
<span className="opacity-50">{group.items.length}</span>
</button>
{isCollapsed ? null : (
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
{group.items.map((asset) => (
<AssetCard key={asset.id} asset={asset} theme={theme} onClick={() => onInsert(buildInsertPayload(asset))} />
))}
</div>
)}
</div>
);
})}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无资产" className="pt-16" />
)}
</div>
</div>
);
}
function AssetCard({ asset, theme, onClick }: { asset: Asset; theme: CanvasTheme; onClick: () => void }) {
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
return (
<button type="button" onClick={onClick} className="group relative overflow-hidden rounded-lg border text-left transition hover:shadow-md" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
{cover ? <img src={cover} alt={asset.title} className="aspect-square w-full object-cover" /> : <div className="flex aspect-square items-center justify-center p-2 text-center text-[11px] leading-4 opacity-60">{asset.title}</div>}
<div className="truncate px-2 py-1.5 text-[11px] font-medium">{asset.title}</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-xs font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div>
</button>
);
}
+3 -9
View File
@@ -1,7 +1,7 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useEffect, useRef, useState } from "react";
import { Button, Segmented, Switch } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
@@ -29,7 +29,6 @@ export function CanvasToolbar({
onDeselect,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenMyAssets,
}: {
selectedCount: number;
canUndo: boolean;
@@ -51,7 +50,6 @@ export function CanvasToolbar({
onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenMyAssets: () => void;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
@@ -137,13 +135,10 @@ export function CanvasToolbar({
<Puzzle className="size-4.5" />
</ToolbarButton>
) : null}
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<ToolbarButton id="tool-upload" label="上传资产" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
<FolderOpen className="size-4.5" />
</ToolbarButton>
<ToolbarButton
id="tool-style"
label="画布外观"
@@ -361,8 +356,7 @@ function toolLabel(id: string) {
if (id === "tool-config") return "生成配置";
if (id === "tool-group") return "组";
if (id === "tool-extensions") return "扩展节点";
if (id === "tool-upload") return "上传素材";
if (id === "tool-assets") return "我的素材";
if (id === "tool-upload") return "上传资产";
if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中";
if (id === "tool-clear") return "清空画布";
@@ -33,7 +33,7 @@ const modelGroups: ModelGroup[] = [
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
canvas: "画布",
assets: "我的素材",
assets: "我的资产",
"image-workbench": "生图工作台",
"video-workbench": "视频创作台",
};
@@ -141,7 +141,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
try {
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
updateWebdavConfig("lastSyncedAt", result.syncedAt);
message.success(`同步完成:${result.projects} 个画布,${result.assets}素材${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
message.success(`同步完成:${result.projects} 个画布,${result.assets}资产${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
} catch (error) {
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
@@ -252,7 +252,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
<Cloud className="size-4" />
WebDAV
</div>
<div className="mt-1 text-xs text-stone-500"> AI API Key WebDAV </div>
<div className="mt-1 text-xs text-stone-500"> AI API Key WebDAV </div>
</div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
</div>
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { BookOpen, Keyboard, Settings2 } from "lucide-react";
import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { GitHubLink } from "@/components/layout/github-link";
@@ -14,9 +14,10 @@ type UserStatusActionsProps = {
showConfig?: boolean;
variant?: "default" | "canvas";
onOpenShortcuts?: () => void;
onOpenPlugins?: () => void;
};
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts }: UserStatusActionsProps) {
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
@@ -29,6 +30,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
return (
<div className="inline-flex shrink-0 items-center gap-1">
{onOpenPlugins ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label="节点插件" title="节点插件">
<Puzzle className="size-4" />
</button>
) : null}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label="文档" title="文档">
<BookOpen className="size-4" />
</a>