From 9acac6cbbe252f898dcc3f8cef362d93dd3b6f42 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Wed, 9 Sep 2026 10:34:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets):=20=E7=B4=A0=E6=9D=90=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=20-=20=E6=94=AF=E6=8C=81=E6=8C=89=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E7=B1=BB=E5=9E=8B=E7=AD=9B=E9=80=89=E5=B9=B6=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E8=BF=9C=E7=A8=8B=E9=A1=B9=E7=9B=AE=E8=B5=84=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../assets/asset-library-picker-modal.tsx | 51 +++++++++++++++++-- .../components/canvas/asset-picker-modal.tsx | 2 + .../canvas/canvas-project-asset-modal.tsx | 12 ++++- web/src/pages/projects/detail/assets.tsx | 1 + web/src/pages/projects/detail/settings.tsx | 1 + web/test/canvas-asset-picker.test.ts | 32 +++++++++++- 6 files changed, 92 insertions(+), 7 deletions(-) diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 13a324baa..acde4eff2 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -14,12 +14,20 @@ import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types"; import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store"; import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync"; +export type AssetPickerMediaKind = "image" | "video" | "audio" | "text"; + +export const ASSET_PICKER_MEDIA_KIND_LABELS: Record = { 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; @@ -40,6 +48,8 @@ export type AssetLibraryPickerFolder = { type Props = { remoteLibrary?: boolean; remoteKind?: string; + /** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */ + mediaKinds?: AssetPickerMediaKind[]; open: boolean; items: AssetLibraryPickerItem[]; categoryLabels: Record; @@ -76,6 +86,7 @@ type Props = { export function AssetLibraryPickerModal({ remoteLibrary = false, remoteKind, + mediaKinds = DEFAULT_MEDIA_KINDS, open, items, categoryLabels, @@ -101,6 +112,7 @@ export function AssetLibraryPickerModal({ }: Props) { const { message } = App.useApp(); const [category, setCategory] = useState(initialCategory); + const [mediaKind, setMediaKind] = useState("all"); const [folderId, setFolderId] = useState(initialFolderId); const [source, setSource] = useState<"local" | "plugin">("local"); const [sourceMenuOpen, setSourceMenuOpen] = useState(false); @@ -120,10 +132,12 @@ export function AssetLibraryPickerModal({ const timer = window.setTimeout(() => setRemoteKeyword(keyword.trim()), 250); return () => window.clearTimeout(timer); }, [keyword]); - useEffect(() => setRemotePage(1), [category, remoteKeyword, open]); + 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, remoteKind], - queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteKind, category: category === "all" || category === "archived" || category === remoteKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }), + 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(() => (remoteQuery.data?.assets || []).filter((asset) => asset.kind !== "entity" && asset.kind !== "model").map((asset) => ({ @@ -144,9 +158,15 @@ export function AssetLibraryPickerModal({ 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 : remoteEnabled ? remoteItems : localItems; + // 媒体类型在分类之前收窄数据源,让左侧分类计数、网格和分页始终描述同一批素材。 + const sourceItems = useMemo(() => { + const base = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems; + if (mediaKind === "all") return base; + return base.filter((item) => pickerItemMediaKind(item) === mediaKind); + }, [localItems, mediaKind, pluginItems, remoteEnabled, 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(() => remoteEnabled ? 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, remoteEnabled]); @@ -180,6 +200,7 @@ export function AssetLibraryPickerModal({ setFolderId(initialFolderId); setCategory(initialCategory); + setMediaKind("all"); setSource("local"); setKeyword(""); setUploadedItems([]); @@ -200,6 +221,11 @@ export function AssetLibraryPickerModal({ 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); @@ -404,6 +430,16 @@ export function AssetLibraryPickerModal({