fix(canvas): 创作界面 - 统一素材选择并修复画布顶栏与暗色交互
This commit is contained in:
230
web/src/components/assets/asset-library-picker-modal.tsx
Normal file
230
web/src/components/assets/asset-library-picker-modal.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Modal } from "antd";
|
||||
import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
export type AssetLibraryPickerItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
kindLabel: string;
|
||||
asset?: Asset;
|
||||
imageUrl?: string;
|
||||
imageFit?: "cover" | "contain";
|
||||
description?: string;
|
||||
searchText?: string;
|
||||
disabledReason?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
items: AssetLibraryPickerItem[];
|
||||
categoryLabels: Record<string, string>;
|
||||
initialCategory?: string;
|
||||
initialSelectedIds?: Iterable<string>;
|
||||
multiple?: boolean;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
confirmLabel?: (count: number) => string;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
footerNote?: string;
|
||||
upload?: {
|
||||
accept: string;
|
||||
description: string;
|
||||
onUpload: (files: FileList) => Promise<string[]>;
|
||||
};
|
||||
onClose: () => void;
|
||||
onConfirm: (ids: string[]) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function AssetLibraryPickerModal({
|
||||
open,
|
||||
items,
|
||||
categoryLabels,
|
||||
initialCategory = "all",
|
||||
initialSelectedIds,
|
||||
multiple = true,
|
||||
title = "素材库",
|
||||
eyebrow = "参考内容",
|
||||
confirmLabel = (count) => `使用已选素材${count ? `(${count})` : ""}`,
|
||||
emptyTitle = "这个分类还没有素材",
|
||||
emptyDescription = "换个分类后再试。",
|
||||
footerNote,
|
||||
upload,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const [category, setCategory] = useState(initialCategory);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [working, setWorking] = useState(false);
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
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 visibleItems = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (category !== "all" && item.category !== category) return false;
|
||||
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
|
||||
});
|
||||
}, [category, items, keyword]);
|
||||
const selectedIds = useMemo(
|
||||
() => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
|
||||
[items, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCategory(initialCategory);
|
||||
setKeyword("");
|
||||
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, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (category === "all" || categories.includes(category)) return;
|
||||
setCategory("all");
|
||||
}, [categories, category]);
|
||||
|
||||
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 handleUpload = async (files: FileList | null) => {
|
||||
if (!upload || !files?.length || working) 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)));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "素材上传失败,请重试");
|
||||
} finally {
|
||||
if (uploadInputRef.current) uploadInputRef.current.value = "";
|
||||
setWorking(false);
|
||||
setUploadingCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length;
|
||||
const uploading = uploadingCount > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
title={null}
|
||||
destroyOnHidden
|
||||
closable={!working}
|
||||
maskClosable={!working}
|
||||
keyboard={!working}
|
||||
onCancel={() => {
|
||||
if (!working) onClose();
|
||||
}}
|
||||
width="min(1040px, calc(100vw - 24px))"
|
||||
className="asset-library-picker-modal"
|
||||
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
|
||||
>
|
||||
<div className="asset-picker-shell">
|
||||
<header className="asset-picker-toolbar">
|
||||
<div className="asset-picker-heading"><span>{eyebrow}</span><strong>{title}</strong></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="素材分类">
|
||||
{categories.map((value) => (
|
||||
<button key={value} type="button" className={category === value ? "is-active" : ""} onClick={() => setCategory(value)}>
|
||||
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<footer className={cn("asset-picker-footer", !upload && "is-compact")}>
|
||||
{upload ? (
|
||||
<>
|
||||
<input ref={uploadInputRef} type="file" hidden accept={upload.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>
|
||||
</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">
|
||||
<button type="button" onClick={onClose} disabled={working}>取消</button>
|
||||
<button type="button" className="is-primary" disabled={working || !selectedIds.length} onClick={() => void confirm()}>
|
||||
{working && !uploading ? <LoaderCircle className="animate-spin" /> : <Check />}{confirmLabel(selectedIds.length)}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? <img src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} /> : <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>
|
||||
);
|
||||
}
|
||||
|
||||
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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user