Files
ttstd-infinite-canvas/web/src/components/assets/asset-library-picker-modal.tsx
T

743 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Dropdown, Popconfirm } from "antd";
import type { MenuProps } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useUserStore } from "@/stores/use-user-store";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AudioPlayButton, AudioWaveArt, CharacterAssetCover } from "@/components/assets/asset-rich-cover";
import { AssetLibraryCard } from "@/components/assets/asset-library-card";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { PaginationBar } from "@/components/layout/workspace-page";
import { cn } from "@/lib/utils";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
import { deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
export type AssetPickerMediaKind = "image" | "video" | "audio" | "text";
export const ASSET_PICKER_MEDIA_KIND_LABELS: Record<AssetPickerMediaKind, string> = { image: "图片", video: "视频", audio: "音频", text: "文本" };
const DEFAULT_MEDIA_KINDS: AssetPickerMediaKind[] = ["image", "video", "audio"];
export type AssetLibraryPickerItem = {
id: string;
title: string;
category: string;
archived?: boolean;
kindLabel: string;
/** 媒体类型筛选依据;缺省时按本地素材或插件素材的 kind 推断。 */
mediaKind?: AssetPickerMediaKind;
asset?: Asset;
imageUrl?: string;
imageStorageKey?: string;
imageFit?: "cover" | "contain";
description?: string;
searchText?: string;
disabledReason?: string;
folderId?: string;
external?: ExternalAssetPickerReference;
};
export type AssetLibraryPickerFolder = {
id: string;
parentId?: string;
name: string;
};
type Props = {
remoteLibrary?: boolean;
remoteKind?: string;
/** 远端素材库里的角色卡是否展示并可选(插入画布时会作为角色卡节点)。 */
allowCharacters?: boolean;
/** 调用方能处理 onConfirm 的 pickedItems 时设为 true:远端分页里不在本地缓存的素材也可选。 */
acceptRemoteItems?: boolean;
/** 远端条目不依赖本地缓存,仍需按调用方当前能力校验。 */
remoteItemDisabledReason?: (asset: Asset) => string | undefined;
/** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */
mediaKinds?: AssetPickerMediaKind[];
open: boolean;
items: AssetLibraryPickerItem[];
categoryLabels: Record<string, string>;
initialCategory?: string;
initialFolderId?: string;
folders?: AssetLibraryPickerFolder[];
initialSelectedIds?: Iterable<string>;
multiple?: boolean;
title?: string;
eyebrow?: string;
confirmLabel?: (count: number) => string;
emptyTitle?: string;
emptyDescription?: string;
footerNote?: string;
loading?: boolean;
pagination?: { current: number; pageSize: number; total: number; onChange: (page: number, pageSize: number) => void };
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;
/** pickedItems 包含远端分页里选中的条目(它们不在调用方传入的 items 里)。 */
onConfirm: (ids: string[], pickedItems?: AssetLibraryPickerItem[]) => Promise<void> | void;
onFolderAction?: (folderId: string) => Promise<void> | void;
};
export function AssetLibraryPickerModal({
remoteLibrary = false,
remoteKind,
allowCharacters = false,
acceptRemoteItems = false,
remoteItemDisabledReason,
mediaKinds = DEFAULT_MEDIA_KINDS,
open,
items,
categoryLabels,
initialCategory = "all",
initialFolderId = "all",
folders = [],
initialSelectedIds,
multiple = true,
title = "素材库",
eyebrow = "参考内容",
confirmLabel = (count) => `使用已选素材${count ? `(${count})` : ""}`,
emptyTitle = "这个分类还没有素材",
emptyDescription = "换个分类后再试。",
footerNote,
loading = false,
pagination,
folderActionLabel = "将文件夹放到画布",
folderActionSource = "all",
upload,
onClose,
onConfirm,
onFolderAction,
}: Props) {
const { message } = App.useApp();
const [category, setCategory] = useState(initialCategory);
const [mediaKind, setMediaKind] = useState<AssetPickerMediaKind | "all">("all");
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("");
const userId = useUserStore((state) => state.user?.id);
const sessionHydrated = useUserStore((state) => state.hydrated);
const [remotePage, setRemotePage] = useState(1);
const [remotePageSize, setRemotePageSize] = useState(40);
const [remoteKeyword, setRemoteKeyword] = useState("");
const remoteEnabled = remoteLibrary && Boolean(userId) && source === "local";
useEffect(() => {
const timer = window.setTimeout(() => setRemoteKeyword(keyword.trim()), 250);
return () => window.clearTimeout(timer);
}, [keyword]);
useEffect(() => setRemotePage(1), [category, mediaKind, remoteKeyword, open]);
// remoteKind 是调用方写死的能力约束;媒体类型筛选只在没有该约束时参与服务端查询。
const remoteQueryKind = remoteKind || (mediaKind === "all" ? undefined : mediaKind);
const remoteQuery = useQuery({
queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteQueryKind],
queryFn: ({ signal }) =>
loadAssetLibraryPage({
page: remotePage,
pageSize: remotePageSize,
kind: remoteQueryKind,
category: category === "all" || category === "archived" || category === remoteQueryKind ? undefined : category,
status: category === "archived" ? "archived" : "active",
query: remoteKeyword,
signal,
}),
enabled: remoteEnabled && open && sessionHydrated,
// 切分类/翻页时保留上一页,避免加载间隙退回本地数据源、左侧分类被重置(点"角色"闪一下又跳回全部)。
placeholderData: keepPreviousData,
});
// 调用方的 items 只是本地缓存,不代表可插入集合(分页结果大多不在缓存里)。能接收 pickedItems 的
// 调用方(acceptRemoteItems)允许直接选远端条目;其余调用方保持"不在 items 里就不可选"的旧约束。
const remoteItems = useMemo<AssetLibraryPickerItem[]>(
() =>
(remoteQuery.data?.assets || [])
.filter((asset) => asset.kind !== "model" && (asset.kind !== "entity" || allowCharacters))
.map((asset) => remoteAssetToPickerItem(asset, items.find((item) => item.id === asset.id), acceptRemoteItems, remoteItemDisabledReason?.(asset))),
[acceptRemoteItems, allowCharacters, remoteItemDisabledReason, remoteQuery.data, items],
);
const uploadInputRef = useRef<HTMLInputElement>(null);
const pickedRemoteRef = useRef(new Map<string, AssetLibraryPickerItem>());
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
initialSelectedIdsRef.current = initialSelectedIds;
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]);
// 远端成功且有可展示素材时用远端。真正的空结果保持空列表。
// 仅在远端空而本地仍有素材、或远端总数>0 但本页全被排除时回退本地,避免合法空搜索被缓存铺满。
const remoteTotal = remoteQuery.data?.total ?? 0;
const remoteReady = remoteEnabled && remoteQuery.isSuccess;
const preferLocalUnsynced = remoteReady && remoteTotal === 0 && localItems.length > 0;
const remoteEntityOnlyPage = remoteReady && remoteItems.length === 0 && remoteTotal > 0;
const useRemoteItems = remoteReady && !preferLocalUnsynced && !remoteEntityOnlyPage && (remoteItems.length > 0 || remoteTotal === 0);
const effectivePagination = useRemoteItems
? {
current: remotePage,
pageSize: remotePageSize,
total: remoteTotal,
onChange: (page: number, pageSize: number) => {
setRemotePage(page);
setRemotePageSize(pageSize);
},
}
: pagination;
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 = useMemo(() => {
const base = source === "plugin" ? pluginItems : useRemoteItems ? remoteItems : localItems;
if (mediaKind === "all") return base;
return base.filter((item) => pickerItemMediaKind(item) === mediaKind);
}, [localItems, mediaKind, pluginItems, useRemoteItems, remoteItems, source]);
const activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]);
const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]);
const mediaKindOptions = useMemo(() => (remoteKind ? [] : Array.from(new Set(mediaKinds))), [mediaKinds, remoteKind]);
const sourceFolders = source === "plugin" ? folders : [];
const showCategories = source === "local" || !sourceFolders.length;
const normalCategories = useMemo(
() =>
useRemoteItems
? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:"))
: ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")],
[activeSourceItems, categoryLabels, useRemoteItems],
);
const archivedCount = archivedItems.length;
const isRecycleBin = category === "archived";
const visibleItems = useMemo(() => {
const query = keyword.trim().toLowerCase();
return sourceItems.filter((item) => {
if (category === "archived") {
if (!item.archived) return false;
} else if (item.archived || (category !== "all" && item.category !== category)) {
return false;
}
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
return useRemoteItems || !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, folderId, keyword, sourceItems, useRemoteItems]);
const selectedIds = useMemo(
() =>
Array.from(selected).filter((id) => {
const item = allItems.find((entry) => entry.id === id);
return !item?.disabledReason;
}),
[allItems, selected],
);
const archivedSelectedIds = useMemo(() => selectedIds.filter((id) => allItems.find((item) => item.id === id)?.archived), [allItems, selectedIds]);
useEffect(() => {
if (!open) return;
setFolderId(initialFolderId);
setCategory(initialCategory);
setMediaKind("all");
setSource("local");
setKeyword("");
setUploadedItems([]);
pickedRemoteRef.current.clear();
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);
setUploadingCount(0);
setError("");
}, [initialCategory, initialFolderId, open]);
useEffect(() => {
if (category === "all" || category === "archived" || normalCategories.includes(category)) return;
// 远端分页还在加载时分类列表是临时的,不能据此把用户刚点的分类改回"全部"。
if (remoteEnabled && (remoteQuery.isFetching || !remoteQuery.isSuccess)) return;
setCategory("all");
}, [normalCategories, category, remoteEnabled, remoteQuery.isFetching, remoteQuery.isSuccess]);
useEffect(() => {
if (hasPluginSource || source === "local") return;
setSource("local");
}, [hasPluginSource, source]);
useEffect(() => {
if (mediaKind === "all" || mediaKindOptions.includes(mediaKind)) return;
setMediaKind("all");
}, [mediaKind, mediaKindOptions]);
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;
// 远端分页条目翻页后就不在列表里了;选中时记下来,确认时仍能转成插入内容。
if (item.asset && !allItems.some((known) => known.id === item.id)) pickedRemoteRef.current.set(item.id, item);
setError("");
setSelected((current) => {
if (!multiple) return current.has(item.id) ? new Set() : new Set([item.id]);
const next = new Set(current);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
};
const confirm = async () => {
if (!selectedIds.length || working) return;
setWorking(true);
setError("");
try {
const known = new Set(allItems.map((item) => item.id));
await onConfirm(selectedIds, [...allItems, ...[...pickedRemoteRef.current.values()].filter((item) => !known.has(item.id))]);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材操作失败,请重试");
} finally {
setWorking(false);
}
};
const handleRestoreSelected = async () => {
if (!archivedSelectedIds.length) return;
setWorking(true);
try {
for (const id of archivedSelectedIds) {
useAssetStore.getState().updateAsset(id, { status: "confirmed" });
}
await flushAssetStorePersistence();
await saveRemoteUserDataNow();
setSelected(new Set());
message.success(`已还原 ${archivedSelectedIds.length} 个素材至素材库`);
setCategory("all");
} catch (error) {
message.warning(localSavedRemotePendingMessage("已在本地还原", error));
} finally {
setWorking(false);
if (remoteEnabled) void remoteQuery.refetch();
}
};
const handleDeleteSelected = async () => {
if (!archivedSelectedIds.length) return;
setWorking(true);
try {
await deleteAssetsWithRemoteSync(archivedSelectedIds);
setSelected(new Set());
message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`);
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
} finally {
setWorking(false);
}
};
const handleEmptyRecycleBin = async () => {
const toDelete = archivedItems;
if (!toDelete.length) return;
setWorking(true);
try {
await deleteAssetsWithRemoteSync(toDelete.map((item) => item.id));
setSelected(new Set());
message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`);
setCategory("all");
} catch (err) {
message.error(err instanceof Error ? err.message : "清空回收站失败");
} finally {
setWorking(false);
}
};
const handleUpload = async (files: FileList | null) => {
if (!files?.length || working || (source === "local" && !upload) || (source === "plugin" && !upload?.external)) return;
setWorking(true);
setError("");
setUploadingCount(files.length);
try {
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 {
if (uploadInputRef.current) uploadInputRef.current.value = "";
setWorking(false);
setUploadingCount(0);
}
};
const runFolderAction = async () => {
if (!onFolderAction || folderId === "all" || working) return;
setWorking(true);
setError("");
try {
await onFolderAction(folderId);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "文件夹操作失败,请重试");
} finally {
setWorking(false);
}
};
// 远端模式下列表是分页的,按当前页数会把"角色 4"之类的数字算错;未叠加类型筛选时用服务端分类计数。
const remoteCategoryCounts = useRemoteItems && mediaKind === "all" && !remoteKind ? remoteQuery.data?.categoryCounts : undefined;
const countFor = (value: string) => {
if (remoteCategoryCounts) return value === "all" ? Object.values(remoteCategoryCounts).reduce((sum, count) => sum + count, 0) : remoteCategoryCounts[value] || 0;
return value === "all" ? activeSourceItems.length : activeSourceItems.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.filter((item) => !item.archived).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 = isRecycleBin ? undefined : source === "plugin" ? upload?.external : upload;
const uploading = uploadingCount > 0;
return (
<AppModal
centered
open={open}
footer={null}
title={null}
destroyOnHidden
closable={!working}
mask={{ closable: !working }}
keyboard={!working}
onCancel={() => {
if (!working) onClose();
}}
className="workspace-modal workspace-modal-wide asset-library-picker-modal"
flush
>
<div className="asset-picker-shell">
<header className="asset-picker-toolbar">
<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>{isRecycleBin ? "回收站" : 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} · {effectivePagination ? effectivePagination.total : visibleItems.length} 个素材
</span>
</header>
<div className="asset-picker-body">
<nav className="asset-picker-categories" aria-label="素材分类">
{mediaKindOptions.length > 1 && !isRecycleBin ? (
<>
<span className="asset-picker-nav-label">媒体类型</span>
{(["all", ...mediaKindOptions] as const).map((value) => (
<button key={value} type="button" className={cn("assets-filter-item", mediaKind === value && "is-active")} aria-pressed={mediaKind === value} onClick={() => setMediaKind(value)}>
<span className="assets-filter-item-label">{value === "all" ? "全部类型" : ASSET_PICKER_MEDIA_KIND_LABELS[value]}</span>
</button>
))}
</>
) : null}
{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, activeSourceItems, folderId, setFolderId)}
</>
) : null}
{showCategories ? (
<>
<span className="asset-picker-nav-label">分类</span>
{normalCategories.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] || (value === "all" ? "全部素材" : "其他")}</span>
<span className="assets-filter-count">{countFor(value)}</span>
</button>
))}
{archivedCount > 0 || remoteEnabled ? (
<div className="mt-3 border-t border-border/40 pt-2">
<button
type="button"
className={cn("assets-filter-item text-amber-500 hover:text-amber-400 dark:text-amber-400", category === "archived" && "is-active !bg-amber-500/10")}
aria-pressed={category === "archived"}
onClick={() => setCategory("archived")}
>
<span className="assets-filter-item-label flex items-center gap-1.5">
<Trash2 className="size-3.5" />
<span>回收站</span>
</span>
<span className="assets-filter-count">{archivedCount}</span>
</button>
</div>
) : null}
</>
) : null}
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{remoteEnabled && remoteQuery.isError && !localItems.length ? (
<div className="asset-picker-empty" role="alert">
<FolderOpen />
<strong>素材读取失败</strong>
<span>服务端暂时不可用,重试不会影响本地已保存素材。</span>
<Button onClick={() => void remoteQuery.refetch()}>重试</Button>
</div>
) : loading || (useRemoteItems && remoteQuery.isFetching) ? (
<div className="asset-picker-empty">
<LoaderCircle className="animate-spin" />
<strong>正在读取素材</strong>
<span>素材会按页加载,不会一次下载整个项目库。</span>
</div>
) : 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>{isRecycleBin ? "回收站是空的" : emptyTitle}</strong>
<span>{isRecycleBin ? "删除画布或手动归档的素材会暂存到这里,可在需要时还原。" : activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span>
</div>
)}
</div>
{effectivePagination ? (
<PaginationBar alwaysShow current={effectivePagination.current} pageSize={effectivePagination.pageSize} total={effectivePagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={effectivePagination.onChange} />
) : null}
</div>
</div>
<footer className={cn("asset-picker-footer", !activeUpload && "is-compact")}>
{activeUpload ? (
<>
<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 ? "保存完成后会自动选中" : activeUpload.description}</small>
</span>
</button>
</>
) : footerNote || (remoteEnabled && remoteQuery.isError) ? (
<span className="asset-picker-footer-note">{remoteEnabled && remoteQuery.isError ? "云端素材暂时不可用,当前显示本地缓存" : footerNote}</span>
) : (
<span />
)}
{error ? (
<span className="asset-picker-footer-error" role="alert">
{error}
</span>
) : null}
<div className="asset-picker-actions">
{isRecycleBin ? (
<>
<Popconfirm
title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"}
description="仅删除当前列表中的素材;关联文件会直接释放,原画布或任务中的旧引用可能失效。删除不可恢复。"
onConfirm={handleEmptyRecycleBin}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
<Button type="text" danger disabled={working || !archivedCount}>
{remoteEnabled ? "删除当前页" : "清空回收站"}
</Button>
</Popconfirm>
<Popconfirm title="确认彻底删除已选素材?" onConfirm={handleDeleteSelected} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
<Button type="text" danger disabled={working || !archivedSelectedIds.length}>
彻底删除
</Button>
</Popconfirm>
<Button type="text" onClick={onClose} disabled={working}>
关闭
</Button>
<Button type="primary" icon={<RotateCcw className="size-3.5" />} disabled={working || !archivedSelectedIds.length} loading={working} onClick={handleRestoreSelected}>
还原已选素材{archivedSelectedIds.length ? `(${archivedSelectedIds.length})` : ""}
</Button>
</>
) : (
<>
{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)}
</Button>
</>
)}
</div>
</footer>
</div>
</AppModal>
);
}
export function remoteAssetToPickerItem(asset: Asset, known: AssetLibraryPickerItem | undefined, acceptRemoteItems: boolean, disabledReason?: string): AssetLibraryPickerItem {
return {
id: asset.id,
title: asset.title,
category: asset.category || "other",
archived: asset.status === "archived",
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : asset.kind === "entity" ? "角色" : "文本",
searchText: (asset.tags ?? []).join(" "),
...known,
disabledReason: known?.disabledReason || (!known && !acceptRemoteItems ? "此素材不适用于当前操作" : disabledReason),
asset,
};
}
// 角色卡、3D 模型等非媒体条目没有媒体类型,媒体筛选生效时不参与匹配。
export function pickerItemMediaKind(item: AssetLibraryPickerItem): AssetPickerMediaKind | undefined {
if (item.mediaKind) return item.mediaKind;
const kind = item.external?.item.kind || item.asset?.kind;
return kind === "image" || kind === "video" || kind === "audio" || kind === "text" ? kind : undefined;
}
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
const disabled = Boolean(item.disabledReason);
return (
<AssetLibraryCard selected={selected} className={cn("asset-picker-card relative", 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.asset?.kind === "entity" ? (
<CharacterAssetCover asset={item.asset} />
) : item.asset?.kind === "audio" && !item.imageUrl && !item.imageStorageKey ? (
<AudioWaveArt seed={item.asset.id} />
) : 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>
{item.asset?.kind === "audio" && !disabled ? (
// 与封面同尺寸的覆盖层:试听按钮落在封面左下角,又不嵌套在选择按钮里。
<span className="pointer-events-none absolute inset-x-0 top-0 aspect-[4/3]">
<AudioPlayButton asset={item.asset} className="pointer-events-auto absolute bottom-2 left-2 !size-8" />
</span>
) : null}
</AssetLibraryCard>
);
}
function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLibraryPickerItem[], selectedId: string, onSelect: (folderId: string) => void, parentId = "", depth = 0, visited: ReadonlySet<string> = new Set()): ReactNode {
if (depth >= 8) return null;
return folders
.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id))
.map((folder) => {
const nextVisited = new Set(visited).add(folder.id);
return (
<span key={folder.id} className="contents">
<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>
);
});
}
function kindIcon(label: string): ReactNode {
if (label.includes("角色")) return <UserRound />;
if (label.includes("视频")) return <Video />;
if (label.includes("音频")) return <Music2 />;
if (label.includes("文本")) return <FileText />;
return <ImageIcon />;
}