fix(canvas): 创作界面 - 统一素材选择并修复画布顶栏与暗色交互

This commit is contained in:
ddCat
2026-08-14 18:35:43 +08:00
parent 920cd92c16
commit acbbbe1078
18 changed files with 673 additions and 448 deletions

View 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 />;
}