feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界

This commit is contained in:
ddCat
2026-08-22 10:01:41 +08:00
45 changed files with 5649 additions and 240 deletions

View File

@@ -1,4 +1,4 @@
import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, Typography } from "antd";
@@ -21,6 +21,7 @@ import { exportAssets, readAssetPackage } from "./asset-transfer";
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
import { deleteAssetWithRemoteSync } from "@/services/user-data-sync";
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
type AssetFormValues = {
@@ -76,6 +77,7 @@ export default function AssetsPage() {
const modelInputRef = useRef<HTMLInputElement>(null);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
@@ -88,6 +90,7 @@ export default function AssetsPage() {
const [deletingAsset, setDeletingAsset] = useState<LibraryAsset | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [formKind, setFormKind] = useState<AssetKind>("text");
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
const coverUrl = Form.useWatch("coverUrl", form) || "";
@@ -273,6 +276,7 @@ export default function AssetsPage() {
}
};
return (
<>
<WorkspacePage grid className="library-page assets-library-page canvas-library-page">
@@ -285,6 +289,7 @@ export default function AssetsPage() {
<div className="assets-header-actions">
<div className="assets-header-action-buttons">
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>新增素材</Button>
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库</Button>
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />

View File

@@ -6,6 +6,7 @@ import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -15,8 +16,6 @@ import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/ca
type CanvasTopBarProps = {
title: string;
workspaceMode: CanvasWorkspaceMode;
onWorkspaceModeChange: (mode: CanvasWorkspaceMode) => void;
titleDraft: string;
isTitleEditing: boolean;
onTitleDraftChange: (value: string) => void;
@@ -47,8 +46,6 @@ type CanvasTopBarProps = {
export function CanvasTopBar({
title,
workspaceMode,
onWorkspaceModeChange,
titleDraft,
isTitleEditing,
onTitleDraftChange,
@@ -77,22 +74,14 @@ export function CanvasTopBar({
shortDramaGuide,
}: CanvasTopBarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const dockStyle = canvasDockStyle(theme, theme.node.text);
const user = useUserStore((state) => state.user);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const { availableMicrocredits, refreshing } = useWalletBalance(user?.id, creditsEnabled);
const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [workspaceModeOpen, setWorkspaceModeOpen] = useState(false);
const handleWorkspaceModeOpenChange = (next: boolean) => {
// 模式开关下拉向下展开,会覆盖展开中的短剧流程条,打开时先收起流程条避免重叠。
if (next && shortDramaGuide && !shortDramaGuide.collapsed) shortDramaGuide.onToggle();
setWorkspaceModeOpen(next);
};
const handleShortDramaGuideToggle = () => {
// 展开流程条会与模式开关下拉在顶部中心重叠,展开前先关闭模式开关。
if (shortDramaGuide?.collapsed) setWorkspaceModeOpen(false);
shortDramaGuide?.onToggle();
};
@@ -112,7 +101,7 @@ export function CanvasTopBar({
return (
<>
<div className="canvas-topbar pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2">
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2" style={dockStyle}>
<Dropdown
trigger={["click"]}
menu={{
@@ -141,7 +130,7 @@ export function CanvasTopBar({
],
}}
>
<button type="button" className="grid size-9 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<button type="button" className="canvas-topbar-action grid size-9 place-items-center rounded-full" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<Menu className="size-5" />
</button>
</Dropdown>
@@ -168,7 +157,7 @@ export function CanvasTopBar({
{title}
</button>
<Tooltip title="重命名画布">
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition hover:bg-black/5 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
<button type="button" className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
<Pencil className="size-3.5" />
</button>
</Tooltip>
@@ -191,10 +180,8 @@ export function CanvasTopBar({
</div>
</div>
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={onWorkspaceModeChange} open={workspaceModeOpen} onOpenChange={handleWorkspaceModeOpenChange} />
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Dropdown
trigger={["click"]}
placement="bottomRight"
@@ -205,7 +192,7 @@ export function CanvasTopBar({
],
}}
>
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
<Button type="text" className="canvas-topbar-action canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
<span className="hidden lg:inline">导入第三方画布</span>
</Button>
</Dropdown>
@@ -222,13 +209,13 @@ export function CanvasTopBar({
],
}}
>
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
</Dropdown>
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
{user && creditsEnabled ? (
<Link
to="/wallet"
className="inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums transition hover:bg-black/5 dark:hover:bg-white/10"
className="canvas-topbar-action inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums"
style={{ color: theme.node.text }}
title="查看积分明细"
>
@@ -239,7 +226,7 @@ export function CanvasTopBar({
<Tooltip title="进入专注模式(⇧⌘F)">
<Button
type="text"
className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0"
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
style={{ color: theme.node.text }}
icon={<Focus className="size-4" />}
onClick={onEnterFocusMode}
@@ -250,24 +237,26 @@ export function CanvasTopBar({
<Tooltip title={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
<Button
type="text"
className="!h-10 !rounded-xl !px-2.5 !font-medium"
className="canvas-topbar-action !h-10 !rounded-xl !px-2.5 !font-medium"
style={{ color: theme.node.text, background: shortDramaGuide.collapsed ? undefined : theme.toolbar.activeBg }}
icon={<Clapperboard className="size-4" />}
onClick={handleShortDramaGuideToggle}
aria-label="短剧流程"
aria-pressed={!shortDramaGuide.collapsed}
>
<span className="tabular-nums">{shortDramaGuide.progress.completedCount}/5</span>
</Button>
</Tooltip>
) : null}
<Button type="text" className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
className="canvas-topbar-action !h-10 !rounded-xl !px-3 !font-medium"
style={{ background: agentOpen ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
icon={<Bot className="size-4" />}
onClick={onToggleAgent}
aria-pressed={agentOpen}
>
Agent
</Button>
@@ -301,19 +290,20 @@ export function CanvasTopBar({
);
}
function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void; open: boolean; onOpenChange: (open: boolean) => void }) {
export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const simple = mode === "simple";
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const closeOnOutsidePress = (event: PointerEvent) => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) onOpenChange(false);
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onOpenChange(false);
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", closeOnOutsidePress);
document.addEventListener("keydown", closeOnEscape);
@@ -321,26 +311,26 @@ function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mod
document.removeEventListener("pointerdown", closeOnOutsidePress);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open, onOpenChange]);
}, [open]);
const selectMode = (nextMode: CanvasWorkspaceMode) => {
if (nextMode !== mode) onChange(nextMode);
onOpenChange(false);
setOpen(false);
};
return (
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto absolute left-1/2 top-2 z-[var(--dock-z-popover)] -translate-x-1/2">
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto relative z-[var(--dock-z-popover)]">
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
transition={aceternityMotion.spring.dock}
className="flex h-9 min-w-28 items-center gap-2 rounded-full px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ background: open ? theme.spatial.surface : theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)", outlineColor: theme.accent.primary }}
className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl border px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ ...canvasDockStyle(theme, theme.node.text), background: open ? "var(--dock-command-active)" : "var(--dock-surface)", outlineColor: theme.accent.primary }}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`当前为${simple ? "简洁" : "专业"}模式,点击切换`}
onClick={() => onOpenChange(!open)}
onClick={() => setOpen((value) => !value)}
>
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}
@@ -351,18 +341,18 @@ function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mod
</motion.span>
</motion.button>
<div className="absolute left-1/2 top-11 w-72 -translate-x-1/2" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
<div className="absolute bottom-11 right-0 w-72" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
<AnimatePresence>
{open ? (
<motion.div
role="listbox"
aria-label="选择画布工作模式"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.92 }}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.95 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
transition={aceternityMotion.spring.panel}
className="aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] p-1.5 backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)" }}
className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] border p-1.5 backdrop-blur-2xl"
style={{ ...canvasDockStyle(theme, theme.node.text), background: "var(--dock-surface)" }}
>
<ModeOption active={simple} motionEnabled={!reducedMotion} icon={<Sparkles className="size-4" />} title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
<ModeOption active={!simple} motionEnabled={!reducedMotion} icon={<Settings2 className="size-4" />} title="专业模式" description="显示完整节点、导演台与生成控制" theme={theme} onClick={() => selectMode("professional")} />
@@ -384,7 +374,7 @@ function ModeOption({ active, motionEnabled, icon, title, description, theme, on
aria-selected={active}
whileTap={motionEnabled ? { scale: 0.98 } : undefined}
transition={aceternityMotion.spring.dock}
className="group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none transition-colors hover:bg-black/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0 dark:hover:bg-white/8"
className="canvas-mode-switch-option group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0"
style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text, outlineColor: theme.accent.primary }}
onClick={onClick}
>
@@ -414,7 +404,7 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean;
const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex";
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return (
<button type="button" className="flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium transition hover:opacity-85" style={{ background: theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }} onClick={onClick} title="打开本地 Codex 面板">
<button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} title="打开本地 Codex 面板">
<span className="size-2 rounded-full" style={{ background: dotColor }} />
<span className="max-w-[180px] truncate">{label}</span>
</button>

View File

@@ -73,7 +73,7 @@ import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
@@ -1752,8 +1752,6 @@ function InfiniteCanvasPage() {
{!focusMode ? (
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
workspaceMode={workspaceMode}
onWorkspaceModeChange={setWorkspaceMode}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
onTitleDraftChange={setTitleDraft}
@@ -1791,6 +1789,18 @@ function InfiniteCanvasPage() {
/>
) : null}
{!focusMode ? (
<div
data-canvas-no-zoom
className="pointer-events-none absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] right-[var(--canvas-inset-x)] z-[var(--z-toolbar)] lg:bottom-[var(--canvas-inset-y)]"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={setWorkspaceMode} />
</div>
) : null}
<CanvasNodeSearchModal
open={nodeSearchOpen}
nodes={nodes}
@@ -2144,7 +2154,7 @@ function InfiniteCanvasPage() {
{!focusMode ? (
<div
data-canvas-no-zoom
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-4 z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-[var(--canvas-inset-x)] z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}

View File

@@ -706,11 +706,18 @@ export function useCanvasUpload({
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "video/mp4", assetId: payload.assetId } } satisfies CanvasNodeData;
}
const storedImage = payload.storageKey ? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(payload.dataUrl);
const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage;
const storedImage = payload.url
? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
: payload.storageKey
? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" }
: await uploadImage(payload.dataUrl);
const meta = !payload.storageKey && (!payload.width || !payload.height) ? await readImageMeta(storedImage.url) : storedImage;
const size = fitNodeSize(meta.width, meta.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
const metadata = storedImage.storageKey
? imageMetadata({ ...storedImage, storageKey: storedImage.storageKey, width: meta.width, height: meta.height })
: { content: storedImage.url, status: NODE_STATUS_SUCCESS, naturalWidth: meta.width, naturalHeight: meta.height, bytes: storedImage.bytes, mimeType: storedImage.mimeType };
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...metadata, prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
}, []);
const handleAssetInsert = useCallback(async (payload: InsertAssetPayload, options: { openDialog?: boolean } = {}): Promise<CanvasNodeData | null> => {

View File

@@ -1,5 +1,6 @@
import type { UploadedFile } from "@/services/file-storage";
import type { UploadedImage } from "@/services/image-storage";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import type { Asset, AudioAsset, ImageAsset, NewAsset } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -192,6 +193,49 @@ export function creationAttachmentFromAudioAsset(asset: AudioAsset): CreationAtt
};
}
export function creationAttachmentFromExternalAsset(reference: ExternalAssetPickerReference): CreationAttachment {
const item = reference.item;
const url = item.fileUrl || "";
if (!url) throw new Error("“" + item.title + "”暂时无法读取,请先在 Eagle 中确认文件可用");
const id = "external:" + reference.sourceId + ":" + item.id;
const type = item.mimeType || (item.kind === "image" ? "image/png" : item.kind === "video" ? "video/mp4" : "audio/mpeg");
if (item.kind === "image") {
return {
id,
name: item.title || "素材图片",
type,
dataUrl: url,
url,
bytes: item.bytes,
width: item.width,
height: item.height,
previewUrl: item.thumbnailUrl || url,
};
}
if (item.kind === "video") {
return {
id,
name: item.title || "素材视频",
type,
url,
bytes: item.bytes,
width: item.width,
height: item.height,
previewUrl: item.thumbnailUrl || url,
};
}
if (item.kind === "audio") {
return {
id,
name: item.title || "素材音频",
type,
url,
bytes: item.bytes,
previewUrl: item.thumbnailUrl || url,
};
}
throw new Error("“" + item.title + "”不是可用于创作参考的媒体文件");
}
export function creationImageAsset({ title, uploaded, metadata }: { title: string; uploaded: UploadedImage; metadata?: Record<string, unknown> }): NewAsset {
return {
kind: "image",

View File

@@ -16,6 +16,7 @@ import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGe
import { createClientId } from "@/lib/client-id";
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
import { useCopyText } from "@/hooks/use-copy-text";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
@@ -37,7 +38,7 @@ import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModel
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
import { useUserStore } from "@/stores/use-user-store";
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references";
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
type CreationMode = "text" | "image" | "video";
type CreationViewMode = "chat" | "storyboard";
@@ -151,6 +152,7 @@ export default function CreatePage() {
const [composingNextShot, setComposingNextShot] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
const externalAssetSources = useExternalAssetSources(libraryOpen);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerFocusRef = useRef<HTMLTextAreaElement>(null);
@@ -189,6 +191,13 @@ export default function CreatePage() {
const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]);
const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]);
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
const referenceImageSize = useMemo(() => {
const imageAttachments = attachments.filter(isImageAttachment);
if (imageAttachments.length !== 1) return undefined;
const { width, height } = imageAttachments[0];
if (typeof width !== "number" || typeof height !== "number" || width <= 0 || height <= 0) return undefined;
return { width, height };
}, [attachments]);
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
const isEmpty = !activeConversation?.messages.length;
const pendingTaskKey = useMemo(() => pendingCreationTaskKey(conversations), [conversations]);
@@ -344,17 +353,27 @@ export default function CreatePage() {
}
};
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => assets
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" | "audio" }> => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio")
.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
asset,
searchText: asset.tags.join(" "),
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
})), [assets, mode]);
const externalLibraryItems = useMemo<AssetLibraryPickerItem[]>(
() => externalAssetSources.items.map((item) => ({
...item,
disabledReason: mode === "image" && item.external?.item.kind !== "image" ? "图片创作仅支持参考图" : undefined,
})),
[externalAssetSources.items, mode],
);
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => [
...assets
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" | "audio" }> => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio")
.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
asset,
searchText: asset.tags.join(" "),
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
})),
...externalLibraryItems,
], [assets, externalLibraryItems, mode]);
const uploadCreationAsset = async (file: File) => {
if (file.type.startsWith("video/")) {
const uploaded = await uploadMediaFile(file, "create-upload");
@@ -420,12 +439,14 @@ export default function CreatePage() {
event.target.value = "";
};
const handleLibrarySelect = (selected: Asset[]) => {
const next = selected.flatMap((asset): CreationAttachment[] => {
if (asset.kind === "image") return [creationAttachmentFromAsset(asset)];
if (asset.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)];
if (asset.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)];
return [];
const handleLibrarySelect = (selectedIds: string[]) => {
const next = selectedIds.flatMap((id): CreationAttachment[] => {
const asset = assets.find((item) => item.id === id);
if (asset?.kind === "image") return [creationAttachmentFromAsset(asset)];
if (asset?.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)];
if (asset?.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)];
const external = libraryItems.find((item) => item.id === id)?.external;
return external ? [creationAttachmentFromExternalAsset(external)] : [];
});
if (!next.length) return;
setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences));
@@ -787,6 +808,7 @@ export default function CreatePage() {
setPrompt,
busy,
attachments,
referenceImageSize,
maxReferences,
references: mentionReferences,
onRemoveAttachment: removeAttachment,
@@ -885,11 +907,12 @@ export default function CreatePage() {
<AssetLibraryPickerModal
open={libraryOpen}
items={libraryItems}
categoryLabels={creationAssetCategoryLabels}
initialSelectedIds={attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6))}
upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }}
categoryLabels={{ ...creationAssetCategoryLabels, ...externalAssetSources.categoryLabels }}
folders={externalAssetSources.folders}
initialSelectedIds={attachments.flatMap((item) => item.id.startsWith("asset:") ? [item.id.slice(6)] : item.id.startsWith("external:") ? [item.id] : [])}
upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets, external: { accept: "image/*", description: "写入当前 Eagle 文件夹;Eagle 当前支持图片文件", onUpload: (files, folderId) => externalAssetSources.uploadExternalFiles(files, folderId) } }}
onClose={() => setLibraryOpen(false)}
onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))}
onConfirm={handleLibrarySelect}
/>
</>;
}
@@ -1051,6 +1074,7 @@ type ComposerProps = {
setPrompt: (value: string) => void;
busy: boolean;
attachments: CreationAttachment[];
referenceImageSize?: { width: number; height: number };
maxReferences: number;
references: CreationReference[];
onRemoveAttachment: (id: string) => void;
@@ -1183,6 +1207,11 @@ function GenerationSettingsMenu(props: ComposerProps) {
? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio)))
: mergedProfile.size.values.length ? mergedProfile.size.values : ratioOptions.map((item) => item.value);
const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios;
const referenceImageSize = props.mode === "image" && mergedProfile.size.allowCustom ? props.referenceImageSize : undefined;
const referenceImageSizeValue = referenceImageSize ? String(referenceImageSize.width) + "x" + String(referenceImageSize.height) : "";
const referenceImageSizeLabel = referenceImageSize ? String(referenceImageSize.width) + " × " + String(referenceImageSize.height) : "";
const referenceImageSizeRatio = referenceImageSize ? String(referenceImageSize.width) + ":" + String(referenceImageSize.height) : "";
const referenceImageSizeSelected = Boolean(referenceImageSizeValue && props.ratio === referenceImageSizeValue);
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
const selectImageRatio = (nextRatio: string) => {
if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") {
@@ -1199,15 +1228,20 @@ function GenerationSettingsMenu(props: ComposerProps) {
const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size;
if (nextSize) props.setRatio(nextSize);
};
const selectReferenceImageSize = () => {
if (!referenceImageSizeValue) return;
props.setRatio(referenceImageSizeValue);
setCustomRatioOpen(false);
};
const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;
const imageSummary = [
...(mergedProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
...(mergedProfile.size.parameter !== "none" ? [referenceImageSizeSelected ? referenceImageSizeLabel : usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
].join(" · ");
const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
const panel = <div className="creation-parameter-menu">
{props.mode === "video" || mergedProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
{props.mode === "video" || mergedProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={referenceImageSizeSelected ? referenceImageSizeLabel : props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{referenceImageSizeValue ? <button type="button" aria-pressed={referenceImageSizeSelected} aria-label={"使用参考图尺寸 " + referenceImageSizeLabel} title={"使用参考图尺寸 " + referenceImageSizeLabel} className={"creation-reference-size-choice" + (referenceImageSizeSelected ? " is-selected" : "")} onClick={selectReferenceImageSize}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(referenceImageSizeRatio)} /></span><span>参考图</span></button> : null}{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
{imageResolutionChoiceOptions.length ? <SettingSection title="分辨率" value={activeImageResolutionChoice === "auto" ? "自动" : activeImageResolutionChoice.toUpperCase()}><div className="creation-choice-grid is-resolution">{imageResolutionChoiceOptions.map((choice) => <button key={choice} type="button" aria-pressed={choice === activeImageResolutionChoice} className={choice === activeImageResolutionChoice ? "is-selected" : ""} onClick={() => selectImageResolution(choice)}>{choice === "auto" ? "自动" : choice.toUpperCase()}</button>)}</div></SettingSection> : null}
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
@@ -1509,7 +1543,7 @@ function isVideoAttachment(attachment: CreationAttachment): attachment is Creati
return creationAttachmentKind(attachment) === "video";
}
function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } {
function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string; width?: number; height?: number } {
return creationAttachmentKind(attachment) === "image";
}
@@ -1607,7 +1641,8 @@ function formatConversationTime(value: string) {
function ratioPreviewStyle(value: string) {
const [width, height] = value.replace("x", ":").split(":").map(Number);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 14, height: 14 };
const scale = Math.min(28 / width, 20 / height);
return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) };
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 10, height: 10 };
// 画幅容器的可用空间是 14×10;同时计算宽高,避免 CSS 的 max-width/max-height 把宽银幕比例压扁。
const scale = Math.min(14 / width, 10 / height);
return { width: Math.max(4, Math.round(width * scale)), height: Math.max(4, Math.round(height * scale)) };
}

View File

@@ -0,0 +1,206 @@
.eagle-library-page .eagle-folder-sidebar {
min-width: 0;
}
.eagle-library-page .eagle-folder-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: 32px;
padding-inline: 4px;
}
.eagle-library-page .eagle-folder-sidebar-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-top: var(--space-4);
padding-inline: 4px;
color: color-mix(in srgb, var(--foreground) 38%, transparent);
font-size: var(--fs-tiny);
font-weight: 650;
letter-spacing: .06em;
text-transform: uppercase;
}
.eagle-library-page .eagle-folder-tree {
max-height: clamp(12rem, 42vh, 28rem);
overflow: auto;
margin-top: 4px;
}
.eagle-library-page .eagle-folder-tree .ant-tree-treenode {
width: 100%;
min-width: 0;
min-height: 34px;
padding-block: 1px;
}
.eagle-library-page .eagle-folder-tree .ant-tree-indent-unit {
width: 12px;
}
.eagle-library-page .eagle-folder-tree .ant-tree-switcher {
flex: 0 0 16px;
width: 16px;
margin-inline-end: 0;
}
.eagle-library-page .eagle-folder-tree .ant-tree-switcher-noop {
flex-basis: 0;
width: 0;
}
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
border-radius: var(--r-md);
padding: 6px 4px;
transition: background-color var(--motion-state) ease-out, color var(--motion-state) ease-out;
}
.eagle-library-page .eagle-folder-tree-title {
display: block;
width: 8rem;
max-width: 8rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper.ant-tree-node-selected {
background: var(--control-selected-bg);
color: var(--control-selected-fg);
font-weight: 560;
}
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.eagle-library-page .eagle-folder-collapse {
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 0;
border-radius: var(--r-md);
background: transparent;
color: inherit;
transition: background-color var(--motion-state) ease-out, color var(--motion-state) ease-out;
}
.eagle-library-page .eagle-folder-collapse:hover {
background: color-mix(in srgb, var(--foreground) 7%, transparent);
color: var(--foreground);
}
.eagle-library-page .eagle-folder-collapse:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.eagle-library-page .eagle-folder-empty {
padding: var(--space-3) 4px;
color: color-mix(in srgb, var(--foreground) 42%, transparent);
font-size: var(--fs-label);
}
.eagle-library-page .eagle-folder-create {
width: 100%;
margin-top: var(--space-3);
}
.eagle-library-page .eagle-cover-download {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: var(--r-md);
background: rgba(10, 10, 10, .62);
color: #fff;
opacity: 0;
transition: background-color var(--motion-state) ease-out, opacity var(--motion-state) ease-out;
}
.eagle-library-page .asset-library-card:hover .eagle-cover-download,
.eagle-library-page .asset-library-card:focus-within .eagle-cover-download,
.eagle-library-page .eagle-cover-download:focus-visible {
opacity: 1;
}
.eagle-library-page .eagle-cover-download:hover {
background: rgba(10, 10, 10, .82);
}
.eagle-library-page .eagle-cover-download:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.eagle-library-page .eagle-empty-state {
display: grid;
min-height: 16rem;
place-items: center;
border-radius: var(--r-lg);
background: color-mix(in srgb, var(--foreground) 2%, transparent);
}
.eagle-library-page .eagle-drawer-preview {
display: grid;
min-height: 220px;
max-height: 52vh;
place-items: center;
overflow: hidden;
border-radius: var(--r-lg);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.eagle-library-page .eagle-drawer-preview img {
display: block;
width: 100%;
height: 100%;
max-height: 52vh;
object-fit: contain;
}
.eagle-library-page .eagle-drawer-download {
width: 100%;
background: var(--primary);
color: var(--primary-foreground);
transition: background-color var(--motion-state) ease-out;
}
.eagle-library-page .eagle-drawer-download:hover {
background: color-mix(in srgb, var(--primary) 88%, var(--foreground));
color: var(--primary-foreground);
}
@media (max-width: 640px) {
.eagle-library-page .eagle-folder-tree {
max-height: 14rem;
}
.eagle-library-page .eagle-cover-download {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper,
.eagle-library-page .eagle-cover-download,
.eagle-library-page .eagle-drawer-download {
transition: none;
}
}

View File

@@ -0,0 +1,351 @@
import { ArrowLeft, ChevronDown, ChevronUp, Download, FileAudio, FileBox, FileImage, FileVideo, FolderOpen, FolderPlus, RefreshCw, Search, Settings2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { App, Button, Drawer, Empty, Input, Spin, Tag, Tree, Typography } from "antd";
import type { DataNode } from "antd/es/tree";
import { useNavigate } from "react-router";
import "@/lib/plugins/builtin";
import { createEagleAssetSource, EAGLE_DEFAULT_BASE_URL, eagleAssetPlugin } from "@/lib/plugins/builtin/eagle";
import type { ExternalAssetFolder, ExternalAssetItem } from "@/lib/plugins/plugin-types";
import type { Asset } from "@/stores/use-asset-store";
import { usePluginStore } from "@/stores/use-plugin-store";
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import "./eagle.css";
export default function EagleLibraryPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const installations = usePluginStore((state) => state.installations);
const hydrated = usePluginStore((state) => state.hydrated);
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
const installation = installations.find((item) => item.manifest.id === eagleAssetPlugin.manifest.id);
const enabled = Boolean(installation?.enabled);
const savedBaseUrl = installation?.config.baseUrl;
const baseUrl = typeof savedBaseUrl === "string" && savedBaseUrl.trim() ? savedBaseUrl.trim() : EAGLE_DEFAULT_BASE_URL;
const provider = useMemo(() => createEagleAssetSource(baseUrl), [baseUrl]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [folders, setFolders] = useState<ExternalAssetFolder[]>([]);
const [items, setItems] = useState<ExternalAssetItem[]>([]);
const [selectedFolder, setSelectedFolder] = useState("");
const [keyword, setKeyword] = useState("");
const [folderName, setFolderName] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [loading, setLoading] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [progress, setProgress] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(40);
const [previewItem, setPreviewItem] = useState<ExternalAssetItem | null>(null);
const [foldersExpanded, setFoldersExpanded] = useState(true);
const treeData = useMemo<DataNode[]>(() => renderFolderNodes(folders), [folders]);
const folderPath = useMemo(() => externalFolderPath(folders, selectedFolder), [folders, selectedFolder]);
const currentFolder = folders.find((folder) => folder.id === selectedFolder);
const visibleItems = useMemo(() => items.slice((page - 1) * pageSize, page * pageSize), [items, page, pageSize]);
const totalBytes = useMemo(() => items.reduce((total, item) => total + (item.bytes || 0), 0), [items]);
useEffect(() => {
ensurePlugin(eagleAssetPlugin.manifest);
}, [ensurePlugin]);
const load = async (folderId = selectedFolder, search = keyword) => {
setLoading(true);
setError("");
try {
const [nextFolders, nextItems] = await Promise.all([
provider.listFolders?.(),
provider.list?.({ folderId: folderId || undefined, keyword: search.trim() || undefined, limit: 100, offset: 0 }),
]);
setFolders(nextFolders || []);
setItems(nextItems || []);
setPage(1);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "连接 Eagle 失败,请确认 Eagle 已启动");
setItems([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!enabled) return;
setSelectedFolder("");
setKeyword("");
void load("", "");
// provider 随本机 API 地址变化;页面进入或配置变化时重新读取 Eagle。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, provider]);
const handleFolderSelect = (nextFolder: string) => {
const folderId = nextFolder === "root" ? "" : nextFolder;
setSelectedFolder(folderId);
setKeyword("");
setPreviewItem(null);
setPage(1);
void load(folderId, "");
};
const handleCreateFolder = async () => {
const name = folderName.trim();
if (!name || !provider.createFolder || creatingFolder) return;
setCreatingFolder(true);
setError("");
try {
await provider.createFolder(name, selectedFolder || undefined);
setFolderName("");
message.success("已在" + (currentFolder?.name || "Eagle 素材库") + "中新建文件夹");
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "新建 Eagle 文件夹失败");
} finally {
setCreatingFolder(false);
}
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (!files.length || !provider.uploadFile || working) return;
setWorking(true);
setError("");
let uploaded = 0;
try {
for (const [index, file] of files.entries()) {
setProgress("正在写入 " + (index + 1) + "/" + files.length + ":" + file.name);
await provider.uploadFile(file, selectedFolder || undefined);
uploaded += 1;
}
message.success("已写入 Eagle " + uploaded + " 个文件");
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "写入 Eagle 失败");
if (uploaded) message.warning("已写入 " + uploaded + " 个文件,剩余文件未完成");
} finally {
setProgress("");
setWorking(false);
}
};
if (hydrated && !enabled) {
return (
<WorkspacePage grid className="library-page eagle-library-page">
<PageHeader
title="Eagle 素材库"
description="把 Eagle 作为影策的外部素材来源,直接浏览和管理 Eagle 原始文件。"
actions={<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>返回影策素材库</Button>}
/>
<section className="mt-4 library-card-surface flex min-h-72 flex-col items-center justify-center rounded-[var(--r-xl)] px-6 py-10 text-center">
<span className="grid size-14 place-items-center rounded-[var(--r-lg)] bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]"><FolderOpen className="size-7" aria-hidden="true" /></span>
<h2 className="mt-4 text-base font-semibold">先启用 Eagle 素材来源</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-foreground/55">启用后,这里会直接显示 Eagle 原本的文件夹和文件,不会把素材复制成影策本地素材。</p>
<Button type="primary" className="mt-5" icon={<Settings2 className="size-4" />} onClick={() => navigate("/plugins")}>去插件中心启用</Button>
</section>
</WorkspacePage>
);
}
return (
<>
<WorkspacePage grid className="library-page assets-library-page canvas-library-page eagle-library-page">
<div className="studio-band">
<PageHeader
title="Eagle 素材库"
description="Eagle 是影策的外部素材来源;这里复用影策素材库的浏览方式,直接读取和写入 Eagle 原始文件。"
meta={<span className="app-projects-header-meta assets-header-meta">{error ? "Eagle · 连接异常" : "Eagle · 已连接"}</span>}
actions={(
<div className="assets-header-actions">
<div className="assets-header-action-buttons">
<Button className="library-primary-action" type="primary" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()} disabled={working}>写入素材</Button>
<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>影策素材库</Button>
<Button icon={<Settings2 className="size-3.5" />} onClick={() => navigate("/plugins")}>插件设置</Button>
</div>
</div>
)}
/>
<ListToolbar
className="library-toolbar"
active={Boolean(keyword)}
onReset={() => { setKeyword(""); setPage(1); void load(selectedFolder, ""); }}
trailing={<Button icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load()}>刷新</Button>}
>
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索 Eagle 素材标题、标签或文件夹" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onPressEnter={() => void load()} />
</ListToolbar>
</div>
{error ? <div className="mt-3 flex items-start justify-between gap-3 rounded-[var(--r-lg)] bg-danger/[.08] px-4 py-3 text-sm text-danger" role="alert"><span>{error}</span><Button size="small" icon={<RefreshCw className="size-3.5" />} onClick={() => void load()}>重试</Button></div> : null}
<div className="canvas-library-frame assets-library-frame eagle-library-frame mt-4">
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
<aside className="eagle-folder-sidebar thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-190px)] lg:overflow-y-auto lg:pr-3">
<div className="eagle-folder-sidebar-header">
<span className="text-[var(--fs-label)] font-semibold">Eagle 文件夹</span>
<Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} aria-label="刷新 Eagle 文件夹" loading={loading} onClick={() => void load()} />
</div>
<button type="button" className={"assets-filter-item " + (selectedFolder === "" ? "is-active" : "")} aria-pressed={selectedFolder === ""} onClick={() => handleFolderSelect("root")}>
<span className="assets-filter-item-label">全部素材</span>
<span className="assets-filter-count">{items.length}</span>
</button>
<div className="eagle-folder-sidebar-label">
<span>文件夹</span>
<button type="button" className="eagle-folder-collapse" aria-expanded={foldersExpanded} aria-controls="eagle-folder-tree" onClick={() => setFoldersExpanded((expanded) => !expanded)}>
{foldersExpanded ? <ChevronUp className="size-3.5" aria-hidden="true" /> : <ChevronDown className="size-3.5" aria-hidden="true" />}
<span className="sr-only">{foldersExpanded ? "收起文件夹" : "展开文件夹"}</span>
</button>
</div>
{foldersExpanded ? (treeData.length ? <div id="eagle-folder-tree"><Tree className="eagle-folder-tree" blockNode selectable selectedKeys={selectedFolder ? [selectedFolder] : []} treeData={treeData} onSelect={(keys) => handleFolderSelect(String(keys[0] || "root"))} /></div> : <div className="eagle-folder-empty">Eagle 中还没有文件夹</div>) : null}
<Button className="eagle-folder-create" icon={<FolderPlus className="size-3.5" />} onClick={() => setFolderName((value) => value ? "" : "新文件夹")}>新建文件夹</Button>
</aside>
<section className="min-w-0">
<nav aria-label="Eagle 文件夹路径" className="mb-3 flex min-w-0 items-center gap-1 text-xs text-foreground/48">
<button type="button" className="truncate rounded px-1.5 py-1 hover:bg-surface-hover" onClick={() => handleFolderSelect("root")}>Eagle 素材库</button>
{folderPath.map((folder) => <span key={folder.id} className="contents"><span aria-hidden="true">/</span><button type="button" className="truncate rounded px-1.5 py-1 font-medium text-foreground hover:bg-surface-hover" onClick={() => handleFolderSelect(folder.id)}>{folder.name}</button></span>)}
</nav>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold">{currentFolder?.name || "全部素材"}</h2>
<span className="app-projects-header-meta">{items.length} 个素材</span>
</div>
<p className="mt-1 text-xs text-foreground/48">{currentFolder ? "当前文件夹由 Eagle 管理,影策只负责展示和调用。" : "当前展示 Eagle 素材库中的全部文件。"}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button icon={<FolderPlus className="size-3.5" />} onClick={() => setFolderName((value) => value ? "" : "新文件夹")}>新建文件夹</Button>
<Button icon={<Download className="size-3.5" />} onClick={() => { const firstFile = visibleItems.find((item) => item.fileUrl); if (firstFile?.fileUrl) window.open(firstFile.fileUrl, "_blank", "noopener,noreferrer"); }}>下载当前文件</Button>
</div>
</div>
{folderName ? <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[var(--r-lg)] bg-surface-secondary p-3">
<Input autoFocus value={folderName} onChange={(event) => setFolderName(event.target.value)} onPressEnter={() => void handleCreateFolder()} placeholder="输入文件夹名称" className="min-w-48 flex-1" aria-label="新文件夹名称" />
<Button type="primary" loading={creatingFolder} onClick={() => void handleCreateFolder()}>创建</Button>
<Button onClick={() => setFolderName("")}>取消</Button>
</div> : null}
{loading ? <div className="library-loading-grid grid min-h-64 place-items-center"><Spin tip="读取 Eagle 文件…"/></div> : items.length ? (
<>
<CollectionGrid className="library-grid assets-library-grid eagle-assets-grid">
{visibleItems.map((item) => <EagleItemCard key={item.id} item={item} selected={previewItem?.id === item.id} onOpen={() => setPreviewItem(item)} />)}
</CollectionGrid>
<PaginationBar current={page} pageSize={pageSize} total={items.length} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</>
) : <div className="eagle-empty-state"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前 Eagle 文件夹没有文件" /></div>}
</section>
</div>
</div>
<input ref={fileInputRef} type="file" accept="image/*,video/*,audio/*" multiple className="hidden" onChange={(event) => void handleUpload(event)} />
</WorkspacePage>
<EagleAssetDrawer item={previewItem} onClose={() => setPreviewItem(null)} totalBytes={totalBytes} />
</>
);
}
function EagleItemCard({ item, selected, onOpen }: { item: ExternalAssetItem; selected: boolean; onOpen: () => void }) {
return <AssetLibraryCard selected={selected}>
<AssetLibraryCardMedia className={item.kind === "image" || item.kind === "video" ? "assets-cover" : "assets-cover is-light"}>
<button type="button" className="assets-cover-link" onClick={onOpen} aria-label={"查看 Eagle 素材:" + item.title}>
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt={item.title} loading="lazy" decoding="async" className="assets-cover-media" /> : <div className="assets-cover-fallback"><AssetKindIcon kind={item.kind} size="size-7" /></div>}
<span className="assets-cover-vignette" aria-hidden="true" />
</button>
<span className="assets-cover-badges">
<span className="assets-cover-badge is-kind"><AssetKindIcon kind={item.kind} size="size-3" />{assetKindLabel(item.kind)}</span>
<span className="assets-cover-badge is-category">Eagle</span>
</span>
{item.fileUrl ? <a href={item.fileUrl} download={item.title} target="_blank" rel="noreferrer" className="eagle-cover-download" aria-label={"下载原文件:" + item.title}><Download className="size-3.5" aria-hidden="true" /></a> : null}
</AssetLibraryCardMedia>
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
<div className="flex min-w-0 items-center justify-between gap-2">
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={item.title}>{item.title}</h2>
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums text-foreground/38">{formatBytes(item.bytes || 0)}</span>
</div>
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={item.folderPath?.join(" / ") || "Eagle 根目录"}>{item.folderPath?.join(" / ") || "Eagle 根目录"}</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38">
<span className="truncate">{formatDimensions(item)}</span>
<span aria-hidden="true">·</span>
<span className="truncate">Eagle 原文件</span>
</div>
</button>
</AssetLibraryCard>;
}
function EagleAssetDrawer({ item, onClose, totalBytes }: { item: ExternalAssetItem | null; onClose: () => void; totalBytes: number }) {
return <Drawer className="library-drawer" title="素材档案" open={Boolean(item)} size="large" onClose={onClose}>
{item ? <div className="space-y-4">
<div className="asset-archive-header">
<span className="asset-archive-header-icon"><AssetKindIcon kind={item.kind} size="size-5" /></span>
<div className="min-w-0">
<h2 className="asset-archive-title">{item.title}</h2>
<p className="asset-archive-subtitle">Eagle 原文件 · {item.folderPath?.join(" / ") || "根目录"}</p>
</div>
</div>
<div className="eagle-drawer-preview">
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt={item.title} loading="lazy" decoding="async" /> : <AssetKindIcon kind={item.kind} size="size-9" />}
</div>
<div className="grid gap-2">
<EagleFact label="类型" value={assetKindLabel(item.kind)} />
<EagleFact label="尺寸" value={formatDimensions(item)} />
<EagleFact label="文件大小" value={formatBytes(item.bytes || 0)} />
<EagleFact label="所在文件夹" value={item.folderPath?.join(" / ") || "Eagle 根目录"} />
<EagleFact label="素材库总大小" value={formatBytes(totalBytes)} />
</div>
{item.tags?.length ? <div><Typography.Text strong className="text-xs">标签</Typography.Text><div className="mt-2 flex flex-wrap gap-1.5">{item.tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>)}</div></div> : null}
{item.description ? <div><Typography.Text strong className="text-xs">备注</Typography.Text><p className="mt-2 text-sm leading-6 text-foreground/65">{item.description}</p></div> : null}
{item.fileUrl ? <a href={item.fileUrl} download={item.title} target="_blank" rel="noreferrer" className="eagle-drawer-download inline-flex min-h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium"><Download className="size-4" aria-hidden="true" />下载 Eagle 原文件</a> : null}
</div> : null}
</Drawer>;
}
function EagleFact({ label, value }: { label: string; value: string }) {
return <div className="flex items-center justify-between gap-3 rounded-md bg-surface-secondary px-3 py-2 text-sm"><span className="text-foreground/48">{label}</span><span className="max-w-[65%] truncate text-right font-medium" title={value}>{value}</span></div>;
}
function AssetKindIcon({ kind, size = "size-5" }: { kind: Asset["kind"]; size?: string }) {
if (kind === "image") return <FileImage className={size + " text-foreground/48"} aria-hidden="true" />;
if (kind === "video") return <FileVideo className={size + " text-foreground/48"} aria-hidden="true" />;
if (kind === "audio") return <FileAudio className={size + " text-foreground/48"} aria-hidden="true" />;
return <FileBox className={size + " text-foreground/48"} aria-hidden="true" />;
}
function assetKindLabel(kind: Asset["kind"]) {
if (kind === "image") return "图片";
if (kind === "video") return "视频";
if (kind === "audio") return "音频";
if (kind === "model") return "模型";
return "文件";
}
function formatBytes(bytes: number) {
if (!bytes) return "—";
if (bytes < 1024) return String(bytes) + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
function formatDimensions(item: ExternalAssetItem) {
if (item.width && item.height) return item.width + " × " + item.height;
return item.mimeType || assetKindLabel(item.kind);
}
function renderFolderNodes(folders: ExternalAssetFolder[], parentId = ""): DataNode[] {
return folders.filter((folder) => (folder.parentId || "") === parentId).map((folder) => ({
key: folder.id,
title: <span className="eagle-folder-tree-title" title={folder.name}>{folder.name}</span>,
children: renderFolderNodes(folders, folder.id),
}));
}
function externalFolderPath(folders: ExternalAssetFolder[], folderId: string) {
const byId = new Map(folders.map((folder) => [folder.id, folder]));
const result: ExternalAssetFolder[] = [];
const seen = new Set<string>();
let current = byId.get(folderId);
while (current && !seen.has(current.id)) {
seen.add(current.id);
result.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return result;
}

View File

@@ -0,0 +1,256 @@
import { App, Button, Input, Select, Switch, Tag, Typography } from "antd";
import { CheckCircle2, ChevronDown, ExternalLink, FolderOpen, PlugZap, Settings2, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
import { usePluginStore } from "@/stores/use-plugin-store";
const categoryLabels: Record<string, string> = {
"asset-source": "素材来源",
"canvas-node": "画布节点",
workflow: "工作流",
"ai-capability": "AI 能力",
"import-export": "导入导出",
agent: "智能体",
};
const surfaceLabels: Record<string, string> = {
node: "画布节点",
fullscreen: "全屏工作台",
hybrid: "混合接入",
"asset-source": "素材库",
};
const permissionLabels: Record<string, string> = {
"canvas.read": "读取画布",
"canvas.write": "修改画布",
"asset.read": "读取素材",
"asset.search": "搜索素材",
"asset.import": "导入素材",
"asset.upload": "上传素材",
"generation.run": "调用生成",
"external.open": "打开外部详情",
};
export default function PluginsPage() {
const { message } = App.useApp();
const navigate = useNavigate();
const installations = usePluginStore((state) => state.installations);
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
const setEnabled = usePluginStore((state) => state.setEnabled);
const updateConfig = usePluginStore((state) => state.updateConfig);
const registeredPlugins = useMemo(() => listRegisteredPlugins(), []);
const [expandedPluginId, setExpandedPluginId] = useState<string | null>(null);
const [eagleBaseUrl, setEagleBaseUrl] = useState("http://localhost:41595");
const [eagleAutoUploadGenerated, setEagleAutoUploadGenerated] = useState(true);
const [eagleGeneratedFolderId, setEagleGeneratedFolderId] = useState("");
const [eagleFolders, setEagleFolders] = useState<EagleFolder[]>([]);
const [eagleFoldersLoading, setEagleFoldersLoading] = useState(false);
const [eagleFoldersError, setEagleFoldersError] = useState("");
useEffect(() => {
for (const plugin of registeredPlugins) ensurePlugin(plugin.manifest);
}, [ensurePlugin, registeredPlugins]);
const eagle = installations.find((item) => item.manifest.id === EAGLE_PLUGIN_ID);
useEffect(() => {
const configured = eagle?.config.baseUrl;
if (typeof configured === "string" && configured.trim()) setEagleBaseUrl(configured);
const autoUpload = eagle?.config.autoUploadGenerated;
setEagleAutoUploadGenerated(autoUpload !== false && autoUpload !== "false");
const folderId = eagle?.config.generatedFolderId;
setEagleGeneratedFolderId(typeof folderId === "string" ? folderId : "");
}, [eagle?.config.baseUrl, eagle?.config.autoUploadGenerated, eagle?.config.generatedFolderId]);
const loadEagleFolders = async (url = eagleBaseUrl) => {
setEagleFoldersLoading(true);
setEagleFoldersError("");
try {
const result = await getEagleLibrary(url.trim().replace(/\/$/, ""));
setEagleFolders(result.library.folders || []);
} catch (reason) {
setEagleFoldersError(reason instanceof Error ? reason.message : "读取 Eagle 文件夹失败");
setEagleFolders([]);
} finally {
setEagleFoldersLoading(false);
}
};
const saveEagleConfig = () => {
const baseUrl = eagleBaseUrl.trim().replace(/\/$/, "");
if (!/^https?:\/\//i.test(baseUrl)) {
message.error("Eagle 地址必须以 http:// 或 https:// 开头");
return;
}
updateConfig(EAGLE_PLUGIN_ID, { baseUrl, autoUploadGenerated: eagleAutoUploadGenerated, generatedFolderId: eagleGeneratedFolderId });
message.success("Eagle 插件配置已保存");
};
return (
<main className="app-workspace-page flex h-full min-h-0 flex-col text-foreground">
<header className="shrink-0 border-b border-[var(--workspace-border)] px-5 py-4 md:px-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="mb-2 flex items-center gap-2 text-[var(--fs-label)] text-foreground/45">
<PlugZap className="size-3.5" aria-hidden="true" />
<span>工作台管理</span>
<span>/</span>
<span>插件中心</span>
</div>
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">插件中心</h1>
<p className="mt-1 text-sm text-foreground/55">管理可连接到影策的素材来源、画布能力和创作工作流。</p>
</div>
<Tag icon={<ShieldCheck className="size-3.5" />} color="gold">可信插件模式</Tag>
</div>
</header>
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto px-5 py-5 md:px-7 md:py-6">
<div className="mx-auto w-full max-w-5xl">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold">已接入插件</h2>
<p className="mt-1 text-xs text-foreground/48">插件状态和配置按当前账号隔离保存。</p>
</div>
<Tag className="m-0">{registeredPlugins.length} 个插件</Tag>
</div>
<div className="flex flex-col gap-3">
{registeredPlugins.map((plugin) => {
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
const enabled = Boolean(installation?.enabled);
const expanded = expandedPluginId === plugin.manifest.id;
const settingsId = `plugin-settings-${plugin.manifest.id}`;
return (
<section key={plugin.manifest.id} className="library-card-surface rounded-[var(--r-xl)] p-4 transition-colors duration-200 md:p-5">
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-[var(--r-lg)] bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]">
<PlugZap className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-base font-semibold">{plugin.manifest.name}</h3>
<span className="whitespace-nowrap text-[var(--fs-tiny)] text-foreground/42">v{plugin.manifest.version}</span>
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2 py-1 text-[var(--fs-tiny)] text-foreground/58">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
</div>
<p className="mt-1 max-w-3xl text-sm leading-6 text-foreground/55">{plugin.manifest.description}</p>
<div className="mt-3 flex flex-wrap gap-2">
{plugin.manifest.surfaces.map((surface) => <span key={surface} className="rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/62">{surfaceLabels[surface] ?? surface}</span>)}
<span className="rounded-full bg-[var(--library-icon-surface)] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.permissions.length} 项能力</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-3 lg:justify-end">
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
<i aria-hidden="true" />
{enabled ? "已启用" : "未启用"}
</span>
<Switch checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} onChange={(checked) => setEnabled(plugin.manifest.id, checked)} />
<Button
className="min-h-9"
icon={<Settings2 className="size-4" />}
aria-expanded={expanded}
aria-controls={settingsId}
onClick={() => setExpandedPluginId((current) => current === plugin.manifest.id ? null : plugin.manifest.id)}
>
{expanded ? "收起设置" : "设置"}
<ChevronDown className={`ml-1 size-3.5 transition-transform duration-200${expanded ? " rotate-180" : ""}`} aria-hidden="true" />
</Button>
</div>
</div>
{expanded ? (
<div id={settingsId} className="mt-4 border-t border-[var(--workspace-border)] pt-4">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold">{plugin.manifest.name} 设置</h4>
<p className="mt-1 text-xs leading-5 text-foreground/48">只展示这个插件实际支持的配置项。</p>
</div>
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.trusted ? "可信插件" : "第三方插件"}</span>
</div>
{plugin.manifest.id === EAGLE_PLUGIN_ID ? (
<>
<div className="grid min-w-0 gap-4 lg:grid-cols-2">
<div className="min-w-0">
<label htmlFor="eagle-base-url" className="block text-xs font-medium text-foreground/58">Eagle 本地 API 地址</label>
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" className="mt-2 w-full" />
<p className="mt-2 text-xs leading-5 text-foreground/45">Eagle 必须在本机运行;影策通过插件直接读取和写入 Eagle 原始文件。</p>
</div>
<div className="min-w-0">
<div className="flex items-center justify-between gap-3">
<label htmlFor="eagle-auto-upload-generated" className="block text-xs font-medium text-foreground/58">自动归档生成结果</label>
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
</div>
<p className="mt-2 text-xs leading-5 text-foreground/45">图片、视频和音频生成成功后,自动写入 Eagle;影策本地素材仍会保留。</p>
</div>
<div className="min-w-0">
<div className="flex items-center justify-between gap-3">
<label htmlFor="eagle-generated-folder" className="block text-xs font-medium text-foreground/58">生成结果写入文件夹</label>
<Button type="link" size="small" loading={eagleFoldersLoading} onClick={() => void loadEagleFolders()}>读取文件夹</Button>
</div>
<Select
id="eagle-generated-folder"
aria-label="生成结果写入文件夹"
className="mt-2 w-full"
showSearch
allowClear
value={eagleGeneratedFolderId || undefined}
placeholder="Eagle 根目录"
optionFilterProp="label"
options={[{ value: "__root__", label: "Eagle 根目录" }, ...eagleFolderOptions(eagleFolders)]}
onChange={(value) => setEagleGeneratedFolderId(value === "__root__" || !value ? "" : value)}
/>
<p className="mt-2 text-xs leading-5 text-foreground/45">{eagleFoldersError || "默认写入 Eagle 根目录;选择文件夹后按 Eagle 原始目录归档。"}</p>
</div>
</div>
<div className="mt-4 flex shrink-0 flex-wrap gap-2">
<Button type="primary" className="min-h-9" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>保存配置</Button>
<Button className="min-h-9" icon={<FolderOpen className="size-4" />} disabled={!enabled} onClick={() => navigate("/plugins/eagle")}>打开 Eagle 素材库</Button>
<Button className="min-h-9" icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">查看 API</Button>
</div>
</>
) : (
<div className="rounded-[var(--r-lg)] bg-foreground/[.035] p-4 text-sm text-foreground/55">该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。</div>
)}
<div className="mt-4 flex flex-wrap gap-x-6 gap-y-3 text-xs text-foreground/52">
<div><span className="mr-2 text-foreground/38">接入位置</span>{plugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}</div>
<div><span className="mr-2 text-foreground/38">插件能力</span>{plugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}</div>
</div>
</div>
) : null}
{installation?.lastError ? <Typography.Text type="danger" className="mt-3 block text-xs" role="alert">{installation.lastError}</Typography.Text> : null}
</section>
);
})}
</div>
</div>
</div>
</main>
);
}
function eagleFolderOptions(folders: EagleFolder[]) {
const byId = new Map(folders.map((folder) => [folder.id, folder]));
const pathFor = (folder: EagleFolder) => {
const path: string[] = [];
const seen = new Set<string>();
let current: EagleFolder | undefined = folder;
while (current && !seen.has(current.id)) {
seen.add(current.id);
path.unshift(current.name);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path.join(" / ");
};
return folders
.map((folder) => ({ value: folder.id, label: pathFor(folder) }))
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
}

View File

@@ -8,6 +8,7 @@ import { AssetMediaPreview } from "@/components/asset-media-preview";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/canvas/canvas-folder-theme";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
@@ -59,6 +60,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const personalAssets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const updatePersonalAsset = useAssetStore((state) => state.updateAsset);
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
@@ -69,6 +71,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const [addOpen, setAddOpen] = useState(false);
const [editorAsset, setEditorAsset] = useState<ProjectAsset | "new" | null>(null);
const [imageAsset, setImageAsset] = useState<ProjectAsset | null>(null);
const externalAssetSources = useExternalAssetSources(addOpen || Boolean(imageAsset));
const [voiceAsset, setVoiceAsset] = useState<ProjectAsset | null>(null);
const [previewAsset, setPreviewAsset] = useState<ProjectAsset | null>(null);
const [voiceProfileId, setVoiceProfileId] = useState("");
@@ -78,23 +81,29 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const projectAssetIds = new Set(detail.assets.map((asset) => asset.id));
const availableAssets = personalAssets.filter((asset) => !projectAssetIds.has(asset.id));
const imageAssets = personalAssets.filter((asset): asset is ImageAsset => asset.kind === "image");
const availablePickerItems = useMemo<AssetLibraryPickerItem[]>(() => availableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.kind === "entity" ? "character" : asset.category || "other",
kindLabel: mediaLabel(asset.kind),
asset,
description: asset.note,
searchText: asset.tags.join(" "),
})), [availableAssets]);
const imagePickerItems = useMemo<AssetLibraryPickerItem[]>(() => imageAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: "图片",
asset,
searchText: asset.tags.join(" "),
})), [imageAssets]);
const availablePickerItems = useMemo<AssetLibraryPickerItem[]>(() => [
...availableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.kind === "entity" ? "character" : asset.category || "other",
kindLabel: mediaLabel(asset.kind),
asset,
description: asset.note,
searchText: asset.tags.join(" "),
})),
...externalAssetSources.items,
], [availableAssets, externalAssetSources.items]);
const imagePickerItems = useMemo<AssetLibraryPickerItem[]>(() => [
...imageAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: "图片",
asset,
searchText: asset.tags.join(" "),
})),
...externalAssetSources.items.filter((item) => item.external?.item.kind === "image"),
], [externalAssetSources.items, imageAssets]);
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation");
const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character);
const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length;
@@ -202,7 +211,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
if (!imageAsset) throw new Error("未选择角色");
await saveRemoteUserDataNow();
const latest = useAssetStore.getState().assets;
const selected = latest.find((asset) => asset.id === selectedAssetId);
const pickerItem = imagePickerItems.find((item) => item.id === selectedAssetId);
const selected = latest.find((asset) => asset.id === selectedAssetId) || (pickerItem?.external ? await externalAssetSources.importExternalAsset(pickerItem.external) : undefined);
if (selected?.kind !== "image") throw new Error("请选择一张包含正面、侧面和背面的三视图设定图");
const resourceId = resourceIdFromStorageKey((selected as ImageAsset).data.storageKey);
if (!resourceId) throw new Error("所选图片尚未同步到后端资源库");
@@ -324,15 +334,24 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
<AssetLibraryPickerModal
open={addOpen}
items={availablePickerItems}
categoryLabels={pickerCategoryLabels}
categoryLabels={{ ...pickerCategoryLabels, ...externalAssetSources.categoryLabels }}
folders={externalAssetSources.folders}
footerNote={externalAssetSources.error || undefined}
multiple={false}
title="个人素材"
title="素材库"
confirmLabel={() => "加入项目"}
emptyTitle="没有可引用的个人素材"
emptyDescription="个人素材已全部加入项目,或素材库仍为空。"
emptyTitle="没有可引用的素材"
emptyDescription="本地素材已全部加入项目,或切换到插件来源选择外部素材。"
onClose={() => setAddOpen(false)}
onConfirm={async (ids) => {
const selected = personalAssets.find((asset) => asset.id === ids[0]);
const selectedItem = availablePickerItems.find((item) => item.id === ids[0]);
if (selectedItem?.external) {
const imported = await externalAssetSources.importExternalAsset(selectedItem.external);
const assetId = addAsset(imported);
await addMutation.mutateAsync({ assetId, category: imported.kind === "entity" ? "character" : imported.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
return;
}
const selected = selectedItem?.asset || personalAssets.find((asset) => asset.id === ids[0]);
if (!selected) throw new Error("所选素材已不存在,请重新选择");
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
}}
@@ -345,12 +364,14 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
<AssetLibraryPickerModal
open={Boolean(imageAsset)}
items={imagePickerItems}
categoryLabels={pickerCategoryLabels}
categoryLabels={{ ...pickerCategoryLabels, ...externalAssetSources.categoryLabels }}
folders={externalAssetSources.folders}
footerNote={externalAssetSources.error || undefined}
multiple={false}
eyebrow="绑定图片"
title={imageAsset?.title || "角色三视图"}
confirmLabel={() => "绑定并生成新版本"}
emptyTitle="个人图片素材为空"
emptyTitle="没有可绑定的图片"
emptyDescription="需要一张包含正面、侧面、背面的角色设定图。"
onClose={() => setImageAsset(null)}
onConfirm={async (ids) => {