feat(workbench): 插件素材与画布视觉 - 完成 Eagle 外部来源与节点可读性适配

This commit is contained in:
QAyong
2026-08-22 02:30:26 +08:00
parent b54e001425
commit ec5aa5ef82
35 changed files with 2512 additions and 214 deletions

View File

@@ -1,10 +1,13 @@
import { Button, Modal } from "antd";
import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react";
import { Button, Dropdown, Modal } from "antd";
import type { MenuProps } from "antd";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, Search, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard } from "@/components/assets/asset-library-card";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { cn } from "@/lib/utils";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import type { Asset } from "@/stores/use-asset-store";
export type AssetLibraryPickerItem = {
@@ -20,6 +23,7 @@ export type AssetLibraryPickerItem = {
searchText?: string;
disabledReason?: string;
folderId?: string;
external?: ExternalAssetPickerReference;
};
export type AssetLibraryPickerFolder = {
@@ -44,10 +48,16 @@ type Props = {
emptyDescription?: string;
footerNote?: string;
folderActionLabel?: string;
folderActionSource?: "local" | "all";
upload?: {
accept: string;
description: string;
onUpload: (files: FileList) => Promise<string[]>;
external?: {
accept: string;
description: string;
onUpload: (files: FileList, folderId?: string) => Promise<AssetLibraryPickerItem[]>;
};
};
onClose: () => void;
onConfirm: (ids: string[]) => Promise<void> | void;
@@ -70,6 +80,7 @@ export function AssetLibraryPickerModal({
emptyDescription = "换个分类后再试。",
footerNote,
folderActionLabel = "将文件夹放到画布",
folderActionSource = "all",
upload,
onClose,
onConfirm,
@@ -77,8 +88,11 @@ export function AssetLibraryPickerModal({
}: Props) {
const [category, setCategory] = useState(initialCategory);
const [folderId, setFolderId] = useState(initialFolderId);
const [source, setSource] = useState<"local" | "plugin">("local");
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [uploadedItems, setUploadedItems] = useState<AssetLibraryPickerItem[]>([]);
const [working, setWorking] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const [error, setError] = useState("");
@@ -86,26 +100,42 @@ export function AssetLibraryPickerModal({
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
initialSelectedIdsRef.current = initialSelectedIds;
itemsRef.current = items;
const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category || "other")))], [items]);
const allItems = useMemo(() => {
const known = new Set(items.map((item) => item.id));
return [...items, ...uploadedItems.filter((item) => !known.has(item.id))];
}, [items, uploadedItems]);
itemsRef.current = allItems;
const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]);
const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]);
const hasPluginSource = useMemo(
() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")),
[categoryLabels, pluginItems],
);
const sourceItems = source === "plugin" ? pluginItems : localItems;
const sourceFolders = source === "plugin" ? folders : [];
const showCategories = source === "local" || !sourceFolders.length;
const categories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other")))], [sourceItems]);
const visibleItems = useMemo(() => {
const query = keyword.trim().toLowerCase();
return items.filter((item) => {
return sourceItems.filter((item) => {
if (category !== "all" && item.category !== category) return false;
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, folderId, items, keyword]);
}, [category, folderId, keyword, sourceItems]);
const selectedIds = useMemo(
() => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
[items, selected],
() => allItems.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
[allItems, selected],
);
useEffect(() => {
if (!open) return;
setCategory(initialCategory);
setFolderId(initialFolderId);
setCategory(initialCategory);
setSource("local");
setKeyword("");
setUploadedItems([]);
const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id));
setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id))));
setWorking(false);
@@ -118,6 +148,19 @@ export function AssetLibraryPickerModal({
setCategory("all");
}, [categories, category]);
useEffect(() => {
if (hasPluginSource || source === "local") return;
setSource("local");
}, [hasPluginSource, source]);
const selectSource = (nextSource: "local" | "plugin") => {
if (nextSource === "plugin" && !hasPluginSource) return;
setSource(nextSource);
setCategory("all");
setFolderId("all");
setError("");
};
const toggle = (item: AssetLibraryPickerItem) => {
if (item.disabledReason || working) return;
setError("");
@@ -144,13 +187,20 @@ export function AssetLibraryPickerModal({
};
const handleUpload = async (files: FileList | null) => {
if (!upload || !files?.length || working) return;
if (!files?.length || working || (source === "local" && !upload) || (source === "plugin" && !upload?.external)) return;
setWorking(true);
setError("");
setUploadingCount(files.length);
try {
const ids = await upload.onUpload(files);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
if (source === "plugin") {
const uploaded = await upload!.external!.onUpload(files, folderId === "all" ? undefined : folderId);
setUploadedItems((current) => [...current, ...uploaded]);
const ids = uploaded.map((item) => item.id);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
} else {
const ids = await upload!.onUpload(files);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材上传失败,请重试");
} finally {
@@ -173,7 +223,13 @@ export function AssetLibraryPickerModal({
}
};
const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length;
const countFor = (value: string) => value === "all" ? sourceItems.length : sourceItems.filter((item) => item.category === value).length;
const sourceLabel = source === "plugin" ? "插件来源" : "本地素材";
const sourceMenuItems: MenuProps["items"] = [
{ key: "local", icon: <HardDrive aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>本地素材</span><em>{localItems.length}</em></span> },
...(hasPluginSource ? [{ key: "plugin", icon: <Puzzle aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>插件来源</span><em>{pluginItems.length}</em></span> }] : []),
];
const activeUpload = source === "plugin" ? upload?.external : upload;
const uploading = uploadingCount > 0;
return (
@@ -193,42 +249,63 @@ export function AssetLibraryPickerModal({
>
<div className="asset-picker-shell">
<header className="asset-picker-toolbar">
<div className="asset-picker-heading"><span>{eyebrow}</span><strong>{title}</strong></div>
<div className="asset-picker-heading">
<div className="asset-picker-heading-copy">
<span>{eyebrow}</span>
<Dropdown
trigger={["click"]}
placement="bottomLeft"
rootClassName="asset-picker-source-dropdown"
onOpenChange={setSourceMenuOpen}
menu={{
selectedKeys: [source],
items: sourceMenuItems,
onClick: ({ key }) => {
if (key === "local" || key === "plugin") selectSource(key);
},
}}
>
<button type="button" className="asset-picker-title-trigger" aria-haspopup="menu" aria-expanded={sourceMenuOpen} aria-label={"素材库来源:" + sourceLabel}>
<strong>{title}</strong><ChevronDown aria-hidden="true" />
</button>
</Dropdown>
</div>
</div>
<label className="asset-picker-search"><Search aria-hidden /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></label>
<span className="asset-picker-count">已选 {selectedIds.length} · {visibleItems.length} 个素材</span>
</header>
<div className="asset-picker-body">
<nav className="asset-picker-categories" aria-label="素材分类">
{folders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={folderId === "all" ? "is-active" : ""} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span>全部文件夹</span><em>{items.length}</em></button>{renderPickerFolders(folders, items, folderId, setFolderId)}<span className="asset-picker-nav-label">分类</span></> : null}
{categories.map((value) => (
<button key={value} type="button" className={category === value ? "is-active" : ""} aria-pressed={category === value} onClick={() => setCategory(value)}>
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
{sourceFolders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span className="assets-filter-item-label">全部文件夹</span><span className="assets-filter-count">{sourceItems.length}</span></button>{renderPickerFolders(sourceFolders, sourceItems, folderId, setFolderId)}</> : null}
{showCategories ? <><span className="asset-picker-nav-label">分类</span>{categories.map((value) => (
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
<span className="assets-filter-item-label">{categoryLabels[value] || "其他"}</span><span className="assets-filter-count">{countFor(value)}</span>
</button>
))}
))}</> : null}
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{visibleItems.length ? visibleItems.map((item) => (
<PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />
)) : (
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{upload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
)}
</div>
</div>
</div>
<footer className={cn("asset-picker-footer", !upload && "is-compact")}>
{upload ? (
<footer className={cn("asset-picker-footer", !activeUpload && "is-compact")}>
{activeUpload ? (
<>
<input ref={uploadInputRef} type="file" hidden accept={upload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
<input ref={uploadInputRef} type="file" hidden accept={activeUpload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
<button type="button" className="asset-picker-upload" onClick={() => uploadInputRef.current?.click()} disabled={working} aria-busy={uploading}>
{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : upload.description}</small></span>
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small></span>
</button>
</>
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
<div className="asset-picker-actions">
{onFolderAction && folderId !== "all" ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
{confirmLabel(selectedIds.length)}
@@ -243,15 +320,19 @@ export function AssetLibraryPickerModal({
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
const disabled = Boolean(item.disabledReason);
return (
<button type="button" className={cn("asset-picker-card", selected && "is-selected", disabled && "is-disabled")} onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="asset-picker-card-media">
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
<span className="asset-picker-card-check"><Check /></span>
<span className="asset-picker-card-kind">{item.kindLabel}</span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
</div>
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
</button>
<AssetLibraryCard selected={selected} className={cn("asset-picker-card", disabled && "is-disabled")}>
<button type="button" className="asset-picker-card-action" onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="assets-cover asset-picker-card-media">
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />}
<span className="assets-cover-vignette" aria-hidden="true" />
<span className="assets-cover-badges" aria-hidden="true">
<span className="assets-cover-badge is-kind">{item.kindLabel}</span></span>
<span className="asset-picker-card-check" aria-hidden="true"><Check /></span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
</div>
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
</button>
</AssetLibraryCard>
);
}
@@ -261,8 +342,8 @@ function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLi
const nextVisited = new Set(visited).add(folder.id);
return (
<span key={folder.id} className="contents">
<button type="button" className={selectedId === folder.id ? "is-active" : ""} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
<span>{folder.name}</span><em>{items.filter((item) => item.folderId === folder.id).length}</em>
<button type="button" className={cn("assets-filter-item", selectedId === folder.id && "is-active")} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
<span className="assets-filter-item-label" title={folder.name}>{folder.name}</span><span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
</button>
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
</span>