mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-25 16:04:30 +08:00
feat(plugin-sdk): add TypeScript SDK for Infinite Canvas plugins with automatic JSX and build support
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export const APP_VERSION = __APP_VERSION__ || "dev";
|
||||
|
||||
export const DOCS_URL = import.meta.env.VITE_DOC_URL || "https://docs.canvas.best";
|
||||
|
||||
// 官方插件清单地址:CI 发布到 plugins-dist 分支,经 jsDelivr 远程拉取;可用环境变量覆盖成自建来源
|
||||
export const PLUGIN_REGISTRY_URL = import.meta.env.VITE_PLUGIN_REGISTRY_URL || "https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json";
|
||||
|
||||
@@ -23,7 +23,7 @@ export const navigationTools = [
|
||||
},
|
||||
{
|
||||
slug: "assets",
|
||||
label: "我的素材",
|
||||
label: "我的资产",
|
||||
icon: Images,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
|
||||
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
||||
|
||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。
|
||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、资产增删查等)。
|
||||
// 这些工具的数据都在浏览器本地(localforage / zustand),因此由本模块直接读写对应 store 后返回结果。
|
||||
|
||||
export const SITE_TOOL_NAMES = [
|
||||
@@ -36,8 +36,8 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
|
||||
workbench_video_get_config: "视频配置",
|
||||
workbench_video_generate: "视频创作台生成",
|
||||
prompts_search: "搜索提示词",
|
||||
assets_list: "素材列表",
|
||||
assets_add: "添加素材",
|
||||
assets_list: "资产列表",
|
||||
assets_add: "添加资产",
|
||||
};
|
||||
|
||||
type SiteToolInput = Record<string, unknown>;
|
||||
@@ -194,7 +194,7 @@ async function searchPrompts(input: SiteToolInput) {
|
||||
|
||||
function listAssets(input: SiteToolInput) {
|
||||
const { assets, hydrated } = useAssetStore.getState();
|
||||
if (!hydrated) throw new Error("素材还在加载中,请稍后重试");
|
||||
if (!hydrated) throw new Error("资产还在加载中,请稍后重试");
|
||||
const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all";
|
||||
const keyword = String(input.keyword || "").trim().toLowerCase();
|
||||
const filtered = assets.filter((asset) => {
|
||||
@@ -221,7 +221,7 @@ function listAssets(input: SiteToolInput) {
|
||||
async function addAsset(input: SiteToolInput) {
|
||||
const kind = input.kind;
|
||||
const title = String(input.title || "").trim();
|
||||
if (!title) throw new Error("请提供素材标题 title");
|
||||
if (!title) throw new Error("请提供资产标题 title");
|
||||
const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : [];
|
||||
const source = typeof input.source === "string" ? input.source : "Agent";
|
||||
const note = typeof input.note === "string" ? input.note : undefined;
|
||||
|
||||
@@ -56,17 +56,17 @@ function withCacheBust(url: string) {
|
||||
}
|
||||
|
||||
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
|
||||
export async function installPluginFromUrl(url: string) {
|
||||
export async function installPluginFromUrl(url: string, opts?: { official?: boolean }) {
|
||||
const source = await fetchPluginSource(url);
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
deactivatePlugin(plugin.id); // 覆盖旧版本
|
||||
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true });
|
||||
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true, official: opts?.official });
|
||||
activatePlugin(plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export async function updatePlugin(record: InstalledPlugin) {
|
||||
return installPluginFromUrl(record.url);
|
||||
return installPluginFromUrl(record.url, { official: record.official });
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PLUGIN_REGISTRY_URL } from "@/constant/env";
|
||||
|
||||
// 官方插件清单里的一条(entry 已解析成绝对 URL)
|
||||
export type OfficialPluginEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type RawEntry = { id?: string; name?: string; version?: string; description?: string; icon?: string; entry?: string; url?: string };
|
||||
type RawManifest = { plugins?: RawEntry[] };
|
||||
|
||||
// 拉取官方插件清单;entry(相对文件名)按清单地址解析成绝对 URL,再走既有 URL 安装流程
|
||||
export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY_URL): Promise<OfficialPluginEntry[]> {
|
||||
const response = await fetch(registryUrl, { headers: { accept: "application/json" } });
|
||||
if (!response.ok) throw new Error(`获取官方插件列表失败 (HTTP ${response.status})`);
|
||||
const data = (await response.json()) as RawManifest;
|
||||
const list = Array.isArray(data?.plugins) ? data.plugins : [];
|
||||
return list
|
||||
.filter((item): item is RawEntry & { id: string } => Boolean(item && item.id && (item.entry || item.url)))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || item.id,
|
||||
version: item.version || "0.0.0",
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(),
|
||||
}));
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function buildSeedancePromptText(prompt: string, images: ReferenceImage[]
|
||||
];
|
||||
const text = prompt.trim();
|
||||
if (!labels.length) return text;
|
||||
return `参考素材编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
||||
return `参考资产编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
||||
}
|
||||
|
||||
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||
@@ -166,4 +166,4 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||
return "";
|
||||
}
|
||||
|
||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";
|
||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。";
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function exportAssets(assets: Asset[]) {
|
||||
|
||||
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
|
||||
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
||||
saveAs(zip, "我的素材.zip");
|
||||
saveAs(zip, "我的资产.zip");
|
||||
}
|
||||
|
||||
export async function readAssetPackage(file: File) {
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function AssetsPage() {
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
}
|
||||
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
message.success(editingAsset ? "资产已更新" : "资产已保存");
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function AssetsPage() {
|
||||
|
||||
const exportAllAssets = async () => {
|
||||
if (!validAssets.length) {
|
||||
message.warning("暂无素材可导出");
|
||||
message.warning("暂无资产可导出");
|
||||
return;
|
||||
}
|
||||
await exportAssets(validAssets);
|
||||
@@ -170,9 +170,9 @@ export default function AssetsPage() {
|
||||
delete payload.updatedAt;
|
||||
addAsset(payload as Parameters<typeof addAsset>[0]);
|
||||
});
|
||||
message.success(`已导入 ${importedAssets.length} 个素材`);
|
||||
message.success(`已导入 ${importedAssets.length} 个资产`);
|
||||
} catch {
|
||||
message.error("导入失败,请选择有效的素材压缩包");
|
||||
message.error("导入失败,请选择有效的资产压缩包");
|
||||
} finally {
|
||||
if (assetInputRef.current) assetInputRef.current.value = "";
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export default function AssetsPage() {
|
||||
const confirmDelete = () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
message.success("资产已删除");
|
||||
setDeletingAsset(null);
|
||||
};
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function AssetsPage() {
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的素材</h1>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的资产</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">收藏常用文本和图片,按类型、标题和标签快速查找。</p>
|
||||
</div>
|
||||
|
||||
@@ -239,21 +239,21 @@ export default function AssetsPage() {
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => void exportAllAssets()}
|
||||
>
|
||||
导出素材
|
||||
导出资产
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => assetInputRef.current?.click()}
|
||||
>
|
||||
导入素材
|
||||
导入资产
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新增素材
|
||||
新增资产
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +267,7 @@ export default function AssetsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到资产" className="py-20" /> : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
@@ -285,7 +285,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Modal title={editingAsset ? "编辑资产" : "新增资产"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
@@ -298,7 +298,7 @@ export default function AssetsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
||||
<Input size="large" placeholder="给资产起一个容易检索的名字" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Space.Compact className="w-full">
|
||||
@@ -321,7 +321,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
{formKind === "text" ? (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本资产" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="图片内容" required>
|
||||
@@ -352,7 +352,7 @@ export default function AssetsPage() {
|
||||
)}
|
||||
<div className="p-4">
|
||||
<Typography.Text strong ellipsis className="block">
|
||||
{title || "未命名素材"}
|
||||
{title || "未命名资产"}
|
||||
</Typography.Text>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{tags.length ? (
|
||||
@@ -395,8 +395,8 @@ export default function AssetsPage() {
|
||||
|
||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||
<Modal title="删除资产" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的资产中移除。
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
@@ -474,7 +474,7 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
|
||||
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
|
||||
return (
|
||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
<Drawer title="资产详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
{asset ? (
|
||||
<div className="space-y-5">
|
||||
{cover ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Puzzle, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
|
||||
import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
@@ -38,6 +38,7 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { CanvasSidePanel } from "@/components/canvas/canvas-side-panel";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
@@ -1049,6 +1050,21 @@ function InfiniteCanvasPage() {
|
||||
setContextMenu(null);
|
||||
}, [size.height, size.width]);
|
||||
|
||||
const focusNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node) return;
|
||||
const worldX = node.position.x + node.width / 2;
|
||||
const worldY = node.position.y + node.height / 2;
|
||||
const k = Math.min(Math.max(Math.min((size.width * 0.6) / node.width, (size.height * 0.6) / node.height), 0.05), 1.5);
|
||||
setViewport({ x: size.width / 2 - worldX * k, y: size.height / 2 - worldY * k, k });
|
||||
setSelectedNodeIds(new Set([nodeId]));
|
||||
setSelectedConnectionId(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[size.height, size.width],
|
||||
);
|
||||
|
||||
const setZoomScale = useCallback(
|
||||
(scale: number) => {
|
||||
const nextScale = Math.min(Math.max(scale, 0.05), 5);
|
||||
@@ -1651,13 +1667,13 @@ function InfiniteCanvasPage() {
|
||||
const content = node.metadata?.content?.trim();
|
||||
if (!content) return message.error("没有可保存的文本");
|
||||
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
return;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
if (!node.metadata?.content) return message.error("没有可保存的视频");
|
||||
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
return;
|
||||
}
|
||||
if (!node.metadata?.content) return message.error("没有可保存的图片");
|
||||
@@ -1678,7 +1694,7 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
},
|
||||
[addAsset, message],
|
||||
);
|
||||
@@ -2555,6 +2571,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
|
||||
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onInsertAsset={handleAssetInsert} />
|
||||
<section className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<CanvasTopBar
|
||||
title={currentProject?.title || "未命名画布"}
|
||||
@@ -2796,9 +2813,6 @@ function InfiniteCanvasPage() {
|
||||
onDeselect={deselectCanvas}
|
||||
onBackgroundModeChange={setBackgroundMode}
|
||||
onShowImageInfoChange={setShowImageInfo}
|
||||
onOpenMyAssets={() => {
|
||||
setAssetPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} onViewportChange={setViewport} /> : null}
|
||||
@@ -2957,8 +2971,7 @@ function CanvasTopBar({
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ key: "plugins", icon: <Puzzle className="size-4" />, label: "节点插件", onClick: onOpenPlugins },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
@@ -3002,6 +3015,7 @@ function CanvasTopBar({
|
||||
<UserStatusActions
|
||||
variant="canvas"
|
||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
/>
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function ImagePage() {
|
||||
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
|
||||
metadata: { source: "image-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
@@ -242,7 +242,7 @@ export default function ImagePage() {
|
||||
const stored = await uploadImage(payload.dataUrl);
|
||||
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
|
||||
} else {
|
||||
message.warning("生图工作台只能使用文本或图片素材");
|
||||
message.warning("生图工作台只能使用文本或图片资产");
|
||||
}
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
@@ -388,7 +388,7 @@ export default function ImagePage() {
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
查看我的资产
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,9 +560,9 @@ function ResultImageCard({
|
||||
<span>{formatDuration(image.durationMs)}</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-3 gap-2">
|
||||
<Tooltip title="添加到素材">
|
||||
<Tooltip title="添加到资产">
|
||||
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
||||
添加到素材
|
||||
添加到资产
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="加入参考图">
|
||||
|
||||
@@ -32,7 +32,7 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
||||
</Button>
|
||||
{onSaveAsset ? (
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||
加入我的素材
|
||||
加入我的资产
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function PromptsPage() {
|
||||
|
||||
const savePromptAsset = (item: Prompt) => {
|
||||
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
@@ -106,7 +106,7 @@ export default function PromptsPage() {
|
||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||
extraAction={
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||
加入我的素材
|
||||
加入我的资产
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function VideoPage() {
|
||||
const addReferences = async (files?: FileList | null) => {
|
||||
const selectedFiles = Array.from(files || []);
|
||||
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file));
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考素材,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
|
||||
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
|
||||
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
|
||||
@@ -244,7 +244,7 @@ export default function VideoPage() {
|
||||
data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType },
|
||||
metadata: { source: "video-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
@@ -394,7 +394,7 @@ export default function VideoPage() {
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
查看我的资产
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,7 +573,7 @@ function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedV
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}>
|
||||
添加到素材
|
||||
添加到资产
|
||||
</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}>
|
||||
下载
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
|
||||
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
||||
}
|
||||
if (videoReferences.length || audioReferences.length) {
|
||||
throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材");
|
||||
throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考资产");
|
||||
}
|
||||
return createOpenAIVideoTask(requestConfig, selectedModel, prompt, references, options);
|
||||
}
|
||||
@@ -263,7 +263,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
|
||||
let blob: Blob | null = null;
|
||||
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
|
||||
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
|
||||
if (!blob) throw new Error("参考视频必须是公网 URL、素材 ID,或本地已保存的视频");
|
||||
if (!blob) throw new Error("参考视频必须是公网 URL、资产 ID,或本地已保存的视频");
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
|
||||
let blob: Blob | null = null;
|
||||
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
|
||||
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
|
||||
if (!blob) throw new Error("参考音频必须是公网 URL、素材 ID,或本地已保存的音频");
|
||||
if (!blob) throw new Error("参考音频必须是公网 URL、资产 ID,或本地已保存的音频");
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@ function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取本地素材失败"));
|
||||
reader.onerror = () => reject(new Error("读取本地资产失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function syncAppDataToWebdav(config: WebdavSyncConfig, onProgress?:
|
||||
}),
|
||||
syncDomain<AssetDomainData>(config, onProgress, {
|
||||
key: "assets",
|
||||
label: "我的素材",
|
||||
label: "我的资产",
|
||||
emptyData: { assets: [] },
|
||||
localData: async () => ({ assets: useAssetStore.getState().assets }),
|
||||
mergeData: (local, remote) => ({ assets: mergeById(local.assets, remote.assets, "updatedAt") }),
|
||||
@@ -323,7 +323,7 @@ function domainPath(domain: DomainKey, path: string) {
|
||||
|
||||
function domainLabel(domain: DomainKey) {
|
||||
if (domain === "canvas") return "画布";
|
||||
if (domain === "assets") return "我的素材";
|
||||
if (domain === "assets") return "我的资产";
|
||||
if (domain === "image-workbench") return "生图工作台";
|
||||
return "视频创作台";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type InstalledPlugin = {
|
||||
source: string; // 缓存的插件源码,离线可用、版本固定
|
||||
enabled: boolean;
|
||||
local?: boolean; // 自动发现于 web/public/plugins 的本地插件(默认关闭,启用时按 url 重新拉取)
|
||||
official?: boolean; // 从官方注册表安装(用于在管理器里归类)
|
||||
installedAt: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user