Files
ttstd-infinite-canvas/web/src/components/assets/asset-library-picker-modal.tsx
ddCat 8e0ad4d597 fix(assets,storyboard): 统一素材库 entity 口径、修复远端空结果遮蔽,补分镜输出契约与解析 (#510)
远端空页只在本地仍有未同步结果或本页全是 entity 时回退,避免把合法空搜索铺成过期缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 00:01:57 +08:00

651 lines
34 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 { useQuery } from "@tanstack/react-query";
import { useUserStore } from "@/stores/use-user-store";
import { AssetMediaPreview } from "@/components/asset-media-preview";
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 { deleteAssetWithRemoteSync, 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;
/** 左侧「媒体类型」筛选项;只有一种类型或已由 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;
onConfirm: (ids: string[]) => Promise<void> | void;
onFolderAction?: (folderId: string) => Promise<void> | void;
};
export function AssetLibraryPickerModal({
remoteLibrary = false,
remoteKind,
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,
});
const remoteItems = useMemo<AssetLibraryPickerItem[]>(() => (remoteQuery.data?.assets || []).filter((asset) => asset.kind !== "entity" && asset.kind !== "model").map((asset) => ({
id: asset.id, title: asset.title, category: asset.category || "other", archived: asset.status === "archived", asset,
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本", searchText: (asset.tags ?? []).join(" "),
...(items.find((item) => item.id === asset.id) || { disabledReason: "此素材不适用于当前操作" }),
})), [remoteQuery.data, items]);
const uploadInputRef = useRef<HTMLInputElement>(null);
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([]);
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;
setCategory("all");
}, [normalCategories, category]);
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;
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 {
await onConfirm(selectedIds);
} 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 {
for (const id of archivedSelectedIds) await deleteAssetWithRemoteSync(id);
setSelected(new Set());
message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`);
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
} finally {
setWorking(false);
if (remoteEnabled) void remoteQuery.refetch();
}
};
const handleEmptyRecycleBin = async () => {
const toDelete = archivedItems;
if (!toDelete.length) return;
setWorking(true);
try {
for (const item of toDelete) await deleteAssetWithRemoteSync(item.id);
setSelected(new Set());
message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`);
setCategory("all");
} catch (err) {
message.error(err instanceof Error ? err.message : "清空回收站失败");
} finally {
setWorking(false);
if (remoteEnabled) void remoteQuery.refetch();
}
};
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);
}
};
const countFor = (value: string) => (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 ? <div role="alert">素材读取失败<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 ? (
<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">
{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>
);
}
// 角色卡、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", 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>
);
}
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 />;
}