feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
|
||||
type InsertableAsset = Extract<Asset, { kind: "text" | "image" | "video" | "audio" }>;
|
||||
|
||||
export type InsertAssetPayload =
|
||||
| { kind: "text"; content: string; title: string; assetId?: string }
|
||||
| { kind: "image"; dataUrl: string; title: string; storageKey?: string; assetId?: string }
|
||||
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
|
||||
@@ -22,17 +24,27 @@ const categoryLabels: Record<string, string> = { all: "全部素材", character:
|
||||
|
||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(() => insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
})), [insertableAssets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
...insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
], [externalAssetSources.items, insertableAssets]);
|
||||
|
||||
const insert = (id: string) => {
|
||||
const pickerItem = items.find((item) => item.id === id);
|
||||
if (pickerItem?.external) {
|
||||
onInsert(externalAssetToInsertPayload(pickerItem.external));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const asset = insertableAssets.find((item) => item.id === id);
|
||||
if (!asset) throw new Error("所选素材已不存在,请重新选择");
|
||||
if (asset.kind === "text") onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id });
|
||||
@@ -46,7 +58,9 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
<AssetLibraryPickerModal
|
||||
open={open}
|
||||
items={items}
|
||||
categoryLabels={categoryLabels}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
footerNote={externalAssetSources.error || undefined}
|
||||
multiple={false}
|
||||
confirmLabel={() => "插入所选素材"}
|
||||
emptyDescription="先在素材库中添加图片、视频、音频或文本。"
|
||||
@@ -55,3 +69,14 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function externalAssetToInsertPayload(reference: ExternalAssetPickerReference): InsertAssetPayload {
|
||||
const item = reference.item;
|
||||
const url = item.fileUrl || "";
|
||||
if (!url) throw new Error(`“${item.title}”暂时无法读取,请先在 Eagle 中确认文件可用`);
|
||||
const assetId = `external:${reference.sourceId}:${item.id}`;
|
||||
if (item.kind === "image") return { kind: "image", dataUrl: url, url, title: item.title || "素材图片", width: item.width, height: item.height, bytes: item.bytes, mimeType: item.mimeType, assetId };
|
||||
if (item.kind === "video") return { kind: "video", url, title: item.title, width: item.width, height: item.height, bytes: item.bytes, mimeType: item.mimeType, assetId };
|
||||
if (item.kind === "audio") return { kind: "audio", url, title: item.title, bytes: item.bytes, mimeType: item.mimeType, assetId };
|
||||
throw new Error(`“${item.title}”不是可插入画布的媒体文件`);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ const TRAY_DEFAULT_HEIGHT = 520;
|
||||
const TRAY_MIN_HEIGHT = 400;
|
||||
const TRAY_BOTTOM_SAFE_SPACE = 82;
|
||||
|
||||
type ResizeSession = {
|
||||
pointerId: number;
|
||||
startY: number;
|
||||
startHeight: number;
|
||||
nextHeight: number;
|
||||
frameId: number | null;
|
||||
};
|
||||
|
||||
function getMaxTrayHeight() {
|
||||
if (typeof window === "undefined") return 520;
|
||||
return Math.max(360, window.innerHeight - TRAY_BOTTOM_SAFE_SPACE);
|
||||
@@ -49,7 +57,10 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
const [tab, setTab] = useState<TrayTab>(() => showLibrary ? "library" : "canvas");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [trayHeight, setTrayHeight] = useState(() => clampTrayHeight(TRAY_DEFAULT_HEIGHT));
|
||||
const resizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const resizeRef = useRef<ResizeSession | null>(null);
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const resizeHandleRef = useRef<{ element: HTMLButtonElement; pointerId: number } | null>(null);
|
||||
const query = keyword.trim().toLowerCase();
|
||||
const filteredAssets = useMemo(() => assetImages.filter((asset) => !query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)), [assetImages, query]);
|
||||
const filteredNodes = useMemo(() => canvasImages.filter((node) => !query || canvasImageTitle(node).toLowerCase().includes(query)), [canvasImages, query]);
|
||||
@@ -63,29 +74,70 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
event.dataTransfer.setData("text/plain", asset.title);
|
||||
};
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeRef.current = { startY: event.clientY, startHeight: safeTrayHeight };
|
||||
const paintResize = useCallback(() => {
|
||||
const session = resizeRef.current;
|
||||
if (!session || session.frameId !== null) return;
|
||||
session.frameId = window.requestAnimationFrame(() => {
|
||||
session.frameId = null;
|
||||
if (resizeRef.current !== session || !panelRef.current) return;
|
||||
panelRef.current.style.height = String(session.nextHeight) + "px";
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateHeight = (moveEvent: PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
setTrayHeight(clampTrayHeight(resizeRef.current.startHeight + resizeRef.current.startY - moveEvent.clientY));
|
||||
};
|
||||
const stopResize = () => {
|
||||
resizeRef.current = null;
|
||||
window.removeEventListener("pointermove", updateHeight);
|
||||
window.removeEventListener("pointerup", stopResize);
|
||||
window.removeEventListener("pointercancel", stopResize);
|
||||
};
|
||||
const stopResize = useCallback(() => {
|
||||
const session = resizeRef.current;
|
||||
if (!session) return;
|
||||
resizeCleanupRef.current?.();
|
||||
resizeCleanupRef.current = null;
|
||||
if (session.frameId !== null) {
|
||||
window.cancelAnimationFrame(session.frameId);
|
||||
session.frameId = null;
|
||||
}
|
||||
const finalHeight = session.nextHeight;
|
||||
if (panelRef.current) {
|
||||
panelRef.current.style.height = String(finalHeight) + "px";
|
||||
panelRef.current.classList.remove("is-resizing");
|
||||
}
|
||||
const handle = resizeHandleRef.current;
|
||||
if (handle?.element.hasPointerCapture(handle.pointerId)) handle.element.releasePointerCapture(handle.pointerId);
|
||||
resizeHandleRef.current = null;
|
||||
resizeRef.current = null;
|
||||
setTrayHeight(finalHeight);
|
||||
}, []);
|
||||
|
||||
window.addEventListener("pointermove", updateHeight);
|
||||
window.addEventListener("pointerup", stopResize, { once: true });
|
||||
window.addEventListener("pointercancel", stopResize, { once: true });
|
||||
},
|
||||
[safeTrayHeight],
|
||||
);
|
||||
const startResize = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
stopResize();
|
||||
const session: ResizeSession = { pointerId: event.pointerId, startY: event.clientY, startHeight: safeTrayHeight, nextHeight: safeTrayHeight, frameId: null };
|
||||
const updateHeight = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== session.pointerId) return;
|
||||
session.nextHeight = clampTrayHeight(session.startHeight + session.startY - moveEvent.clientY);
|
||||
paintResize();
|
||||
};
|
||||
const finish = (endEvent: PointerEvent) => {
|
||||
if (endEvent.pointerId === session.pointerId) stopResize();
|
||||
};
|
||||
resizeRef.current = session;
|
||||
resizeHandleRef.current = { element: event.currentTarget, pointerId: event.pointerId };
|
||||
panelRef.current?.classList.add("is-resizing");
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
window.addEventListener("pointermove", updateHeight);
|
||||
window.addEventListener("pointerup", finish);
|
||||
window.addEventListener("pointercancel", finish);
|
||||
resizeCleanupRef.current = () => {
|
||||
window.removeEventListener("pointermove", updateHeight);
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", finish);
|
||||
};
|
||||
}, [paintResize, safeTrayHeight, stopResize]);
|
||||
|
||||
useEffect(() => () => {
|
||||
resizeCleanupRef.current?.();
|
||||
const session = resizeRef.current;
|
||||
if (session && session.frameId !== null) window.cancelAnimationFrame(session.frameId);
|
||||
resizeRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncHeight = () => setTrayHeight((height) => clampTrayHeight(height));
|
||||
@@ -134,11 +186,12 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
|
||||
exit={{ opacity: 0, y: 14, scale: 0.95, rotateX: 3 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel absolute bottom-[var(--canvas-dock-popover-offset)] left-0 flex w-[min(88vw,312px)] origin-bottom-left flex-col overflow-hidden rounded-[var(--r-2xl)] border p-2.5 backdrop-blur-2xl"
|
||||
ref={panelRef}
|
||||
className="canvas-asset-tray-panel aceternity-floating-panel absolute bottom-[var(--canvas-dock-popover-offset)] left-0 flex w-[min(88vw,312px)] origin-bottom-left flex-col overflow-hidden rounded-[var(--r-2xl)] border p-2.5 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, height: safeTrayHeight, minHeight: Math.min(TRAY_MIN_HEIGHT, getMaxTrayHeight()), maxHeight: "calc(100vh - 6rem)", boxShadow: `0 32px 100px ${theme.spatial.shadow}` }}
|
||||
>
|
||||
<div className="absolute inset-x-10 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.spatial.glowStrong}, transparent)` }} />
|
||||
<button type="button" className="absolute left-1/2 top-1 z-10 flex h-5 w-28 -translate-x-1/2 cursor-ns-resize items-center justify-center rounded-full opacity-35 transition-opacity hover:opacity-75" onPointerDown={startResize} aria-label="从顶部调整素材托盘高度" title="拖动调整高度">
|
||||
<button type="button" className="canvas-asset-tray-resize-handle absolute left-1/2 top-1 z-10 flex h-5 w-28 -translate-x-1/2 items-center justify-center rounded-full opacity-35 transition-opacity hover:opacity-75" onPointerDown={startResize} aria-label="从顶部调整素材托盘高度" title="拖动调整高度">
|
||||
<span className="h-1 w-12 rounded-full bg-current" />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -65,24 +65,41 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
/>
|
||||
{showVisual ? <path
|
||||
d={pathD}
|
||||
stroke={emphasized ? theme.accent.primary : theme.node.muted}
|
||||
strokeWidth={emphasized ? 1.4 : 1}
|
||||
stroke={theme.node.muted}
|
||||
strokeWidth={emphasized ? 5 : 3.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeOpacity={emphasized ? 0.42 : 0.16}
|
||||
strokeOpacity={emphasized ? 0.18 : 0.16}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/> : null}
|
||||
{showVisual ? <path
|
||||
d={pathD}
|
||||
stroke={emphasized ? theme.accent.primary : theme.node.muted}
|
||||
strokeWidth={emphasized ? 2.2 : 1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeOpacity={emphasized ? 0.9 : 0.72}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/> : null}
|
||||
{showVisual ? <>
|
||||
<circle cx={startX} cy={startY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
|
||||
<circle cx={endX} cy={endY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
|
||||
</> : null}
|
||||
{showVisual && emphasized ? <path
|
||||
className="canvas-connection-flow"
|
||||
d={pathD}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth="1.8"
|
||||
strokeWidth="2.2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeOpacity="0.72"
|
||||
strokeOpacity="0.84"
|
||||
strokeDasharray="18 26"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/> : null}
|
||||
</g>
|
||||
|
||||
@@ -99,10 +99,13 @@ function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProp
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="绘图预览" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className="absolute inset-0 grid place-items-center" style={{ backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
|
||||
<span className="grid size-12 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, color: theme.node.muted }}>
|
||||
<Pencil className="size-5" />
|
||||
</span>
|
||||
<div className="absolute inset-0 grid place-items-center" style={{ background: theme.node.panel, backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
|
||||
<div className="flex flex-col items-center gap-2 rounded-[var(--r-lg)] px-4 py-3" style={{ border: `1px solid ${theme.node.edge}`, background: theme.node.fill, color: theme.node.muted }}>
|
||||
<span className="grid size-10 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.edge}`, color: theme.node.text }}>
|
||||
<Pencil className="size-5" />
|
||||
</span>
|
||||
<span className="text-[var(--fs-tiny)] font-medium">打开绘图</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>
|
||||
|
||||
@@ -104,6 +104,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const activeReferenceCount = activeReferences.length;
|
||||
const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
|
||||
const monochromeAccent = theme.node.activeStroke;
|
||||
const composerTokens = {
|
||||
"--canvas-composer-surface": theme.node.panel,
|
||||
"--canvas-composer-control-surface": theme.toolbar.itemHover,
|
||||
"--canvas-composer-control-hover": theme.toolbar.activeBg,
|
||||
"--canvas-composer-shadow": theme.node.shadow,
|
||||
"--cn-text": theme.node.text,
|
||||
} as CSSProperties;
|
||||
const composerSurfaceStyle = {
|
||||
...composerTokens,
|
||||
border: `1px solid ${theme.node.edge}`,
|
||||
background: theme.node.panel,
|
||||
color: theme.node.text,
|
||||
boxShadow: theme.node.shadow,
|
||||
} as CSSProperties;
|
||||
const controlSurface = "var(--canvas-composer-control-surface)";
|
||||
const promptBounds = promptEditorBounds(false, activeReferenceCount > 0);
|
||||
const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0);
|
||||
@@ -187,7 +201,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<Tooltip title="放大编辑">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md transition hover:bg-white/[.06] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
|
||||
className="canvas-node-composer-icon-button grid size-6 shrink-0 place-items-center rounded-md transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
|
||||
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
|
||||
onClick={() => setExpandedPromptOpen(true)}
|
||||
aria-label="放大编辑提示词"
|
||||
@@ -333,6 +347,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
return (
|
||||
<div
|
||||
className="canvas-node-composer"
|
||||
style={composerSurfaceStyle}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
@@ -346,7 +361,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<div className="canvas-node-composer-parameters overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-[var(--fs-micro)] font-medium transition-colors hover:bg-white/[.04]"
|
||||
className="canvas-node-composer-parameters-toggle flex w-full items-center gap-1.5 rounded-[var(--r-md)] px-2 py-1 text-[var(--fs-micro)] font-medium transition-colors"
|
||||
style={{ color: theme.node.muted }}
|
||||
onClick={() => setParamsExpanded(!paramsExpanded)}
|
||||
aria-expanded={paramsExpanded}
|
||||
@@ -379,11 +394,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
setExpandedPromptOpen(false);
|
||||
}}
|
||||
styles={{
|
||||
container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: "var(--canvas-composer-surface)", boxShadow: "var(--canvas-composer-shadow)" },
|
||||
container: { border: `1px solid ${theme.node.edge}`, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: theme.node.panel, boxShadow: theme.node.shadow },
|
||||
body: { minHeight: 0, padding: 0 },
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
|
||||
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ ...composerTokens, color: theme.node.text }}>
|
||||
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
|
||||
{renderPromptEditor(true)}
|
||||
{mode === "video" && !simpleMode ? (
|
||||
|
||||
@@ -291,7 +291,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
data-node-state={nodeState}
|
||||
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
|
||||
style={{
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
border: `1px solid ${theme.node.edge}`,
|
||||
boxShadow: isSelected ? `0 0 0 2px ${theme.node.activeStroke}, ${theme.node.hoverShadow}` : hovered ? theme.node.hoverShadow : theme.node.shadow,
|
||||
}}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
@@ -329,7 +331,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
|
||||
style={
|
||||
{
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||
@@ -632,7 +634,17 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
|
||||
return (
|
||||
<div
|
||||
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
|
||||
style={{ maxWidth: maxHeaderWidth, color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
|
||||
style={{
|
||||
maxWidth: maxHeaderWidth,
|
||||
border: `1px solid ${theme.node.edge}`,
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: theme.node.panel,
|
||||
paddingInline: "var(--space-1-half)",
|
||||
color: active ? theme.node.text : theme.node.label,
|
||||
boxShadow: active ? theme.node.shadow : undefined,
|
||||
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
|
||||
transformOrigin: "left bottom",
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { externalAssetToInsertPayload, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
|
||||
@@ -13,6 +14,7 @@ type ProjectPickerItem = { id: string; category: string; folderId?: string; proj
|
||||
|
||||
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
|
||||
const mediaAssets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const items = useMemo<ProjectPickerItem[]>(() => {
|
||||
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
|
||||
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
|
||||
@@ -26,7 +28,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
|
||||
.map((media): ProjectPickerItem => ({ id: media.id, category: media.category || "other", media }));
|
||||
}, [detail?.assets, mediaAssets]);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const character = item.character;
|
||||
const project = item.project;
|
||||
const media = item.media;
|
||||
@@ -50,24 +52,30 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}), [items]);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
|
||||
|
||||
return (
|
||||
<AssetLibraryPickerModal
|
||||
open={open}
|
||||
items={pickerItems}
|
||||
categoryLabels={categoryLabels}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
initialCategory={initialCategory}
|
||||
initialFolderId={initialFolderId}
|
||||
folders={detail?.assetFolders || []}
|
||||
folders={externalAssetSources.folders}
|
||||
folderActionSource="local"
|
||||
title="项目资产"
|
||||
confirmLabel={(count) => `引入已选资产${count ? `(${count})` : ""}`}
|
||||
emptyTitle="此分类没有可引用资产"
|
||||
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
|
||||
footerNote="角色引用会在生成时解析当前角色版本"
|
||||
footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"}
|
||||
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
|
||||
onClose={onClose}
|
||||
onConfirm={async (ids) => {
|
||||
const payloads = await Promise.all(items.filter((item) => ids.includes(item.id)).map(async (item) => {
|
||||
const payloads = await Promise.all(ids.map(async (id) => {
|
||||
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
|
||||
if (external) return externalAssetToInsertPayload(external);
|
||||
const item = items.find((candidate) => candidate.id === id);
|
||||
if (!item) throw new Error("所选资产已不存在,请重新选择");
|
||||
if (item.media || item.character || !item.project) return toInsertPayload(item);
|
||||
const { asset } = await getRemoteAsset(item.project.id);
|
||||
return toInsertPayload({ ...item, media: asset });
|
||||
|
||||
@@ -56,6 +56,7 @@ function buildNav(features: FeatureAvailability, balance: string, isAdmin: boole
|
||||
heading: "工作台管理",
|
||||
items: [
|
||||
toolItem("skills", "/skills"),
|
||||
toolItem("plugins", "/plugins"),
|
||||
...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user