Files
ttstd-infinite-canvas/web/src/components/canvas/character-media-library.tsx
ddcat666 080dbcbabe fix(web): 音频与角色素材 - 修正格式检查未通过的代码风格
角色库、音频参数和音频测试在上次提交后不再符合 Prettier,质量检查因此失败。只调整格式,不改变交互。
2026-10-01 13:18:06 +08:00

248 lines
14 KiB
TypeScript

import { Input } from "antd";
import { Check, ImagePlus, Loader2, Pause, Play, Search, Upload } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey } from "@/services/api/resources";
import { loadAssetLibraryPage } from "@/services/user-data-sync";
import type { AudioAsset, ImageAsset } from "@/stores/use-asset-store";
const PAGE_SIZE = { image: 18, audio: 20 } as const;
/** 素材库分页查询:只保留已同步到云端、可被角色卡绑定的素材,滚到底部自动加载下一页。 */
function useCharacterMediaPages<K extends "image" | "audio">(kind: K, keyword: string) {
const query = useDebouncedValue(keyword.trim(), 250);
const pages = useInfiniteQuery({
queryKey: ["character-media-library", kind, query],
initialPageParam: 1,
queryFn: ({ signal, pageParam }) => loadAssetLibraryPage({ page: pageParam, pageSize: PAGE_SIZE[kind], kind, status: "active", query: query || undefined, signal }),
getNextPageParam: (page, all) => (all.reduce((count, item) => count + item.assets.length, 0) < page.total ? all.length + 1 : undefined),
});
const items = (pages.data?.pages.flatMap((page) => page.assets) || []).filter(
(asset): asset is K extends "image" ? ImageAsset : AudioAsset => asset.kind === kind && Boolean(resourceIdFromStorageKey((asset as ImageAsset | AudioAsset).data.storageKey)),
);
const sentinelRef = useRef<HTMLDivElement>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = pages;
useEffect(() => {
const target = sentinelRef.current;
if (!target || !hasNextPage || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting) && !isFetchingNextPage) void fetchNextPage();
},
{ rootMargin: "160px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return { items, query, loading: pages.isLoading, loadingMore: isFetchingNextPage, sentinelRef };
}
function SearchField({ value, placeholder, onChange }: { value: string; placeholder: string; onChange: (value: string) => void }) {
return <Input allowClear variant="filled" value={value} prefix={<Search className="size-3.5 text-foreground/40" />} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
}
function FilePicker({ accept, onFile, children }: { accept: string; onFile: (file: File) => void; children: (open: () => void) => ReactNode }) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
{children(() => inputRef.current?.click())}
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) onFile(file);
}}
/>
</>
);
}
function ListState({ loading, empty, loadingMore }: { loading: boolean; empty: string; loadingMore: boolean }) {
if (loading) return <p className="py-10 text-center text-xs text-foreground/40">正在读取素材库</p>;
if (empty) return <p className="py-10 text-center text-xs leading-5 text-foreground/40">{empty}</p>;
return loadingMore ? <p className="py-4 text-center text-xs text-foreground/40">正在加载更多</p> : null;
}
/** 同一资源在当前画布上有节点时,用节点名(用户改过的名字)代替素材库里的默认标题。 */
export type CanvasMediaTitles = ReadonlyMap<string, string>;
/** 形象选择:缩略图网格,点选即绑定;第一格为本地上传。 */
export function CharacterImageLibrary({ currentResourceId, busyKey, canvasTitles, onPick, onUpload }: { currentResourceId: string; canvasTitles?: CanvasMediaTitles; busyKey: string; onPick: (resourceId: string) => void; onUpload: (file: File) => void }) {
const [keyword, setKeyword] = useState("");
const { items, query, loading, loadingMore, sentinelRef } = useCharacterMediaPages("image", keyword);
return (
<div className="space-y-4">
<SearchField value={keyword} placeholder="搜索素材库图片" onChange={setKeyword} />
<div className="grid grid-cols-3 gap-2.5">
<FilePicker accept="image/*" onFile={onUpload}>
{(open) => (
<button
type="button"
onClick={open}
disabled={Boolean(busyKey)}
className="grid aspect-[3/4] place-items-center rounded-lg border border-dashed border-foreground/15 text-foreground/45 outline-none transition-colors hover:border-foreground/35 hover:text-foreground/75 focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] disabled:cursor-wait"
>
<span className="flex flex-col items-center gap-2 text-xs">{busyKey === "upload" ? <Loader2 className="size-5 animate-spin" /> : <ImagePlus className="size-5 stroke-[1.5]" />}上传本地图片</span>
</button>
)}
</FilePicker>
{items.map((asset) => {
const resourceId = resourceIdFromStorageKey(asset.data.storageKey);
const current = resourceId === currentResourceId;
const busy = busyKey === resourceId;
const title = canvasTitles?.get(resourceId) || asset.title;
return (
<button
key={asset.id}
type="button"
title={title}
aria-pressed={current}
disabled={Boolean(busyKey) || current}
onClick={() => onPick(resourceId)}
className={`group relative aspect-[3/4] overflow-hidden rounded-lg bg-foreground/[.05] outline-none transition focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] disabled:cursor-default ${current ? "ring-2 ring-[var(--workspace-accent)] ring-offset-2 ring-offset-background" : ""}`}
>
<CachedResourceImage
storageKey={asset.data.storageKey}
src={asset.coverUrl}
variant="thumbnail"
alt={title}
className="h-full w-full object-cover transition-transform duration-300 [transition-timing-function:cubic-bezier(.16,1,.3,1)] group-hover:scale-[1.04] motion-reduce:transition-none"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 bg-gradient-to-t from-black/75 to-transparent px-2 pb-1.5 pt-6 text-left text-[11px] text-white opacity-0 transition duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<span className="block truncate">{title}</span>
</span>
{current ? (
<span className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded-full bg-[var(--workspace-accent)] text-background">
<Check className="size-3" />
</span>
) : null}
{busy ? (
<span className="absolute inset-0 grid place-items-center bg-black/45 text-white">
<Loader2 className="size-5 animate-spin" />
</span>
) : null}
</button>
);
})}
</div>
<ListState loading={loading} loadingMore={loadingMore} empty={!loading && !items.length ? (query ? "没有匹配的图片" : "素材库里还没有图片,可以直接上传本地图片") : ""} />
<div ref={sentinelRef} />
</div>
);
}
/** 共用一个播放器试听,避免每行一个原生控件把列表挤乱。 */
function useAudition() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState("");
useEffect(() => () => audioRef.current?.pause(), []);
const toggle = async (resourceId: string, storageKey?: string) => {
const audio = audioRef.current || (audioRef.current = new Audio());
if (playing === resourceId) {
audio.pause();
setPlaying("");
return;
}
audio.pause();
setPlaying(resourceId);
try {
audio.src = resolveResourceAccessURL((await getResourceAccess(storageKey, "display", "playback")).url);
audio.onended = () => setPlaying("");
await audio.play();
} catch {
setPlaying("");
}
};
return { playing, toggle };
}
/** 声音选择:列表 + 单击试听,右侧“使用”即绑定。 */
export function CharacterVoiceLibrary({
currentResourceId,
busyKey,
canvasTitles,
onPick,
onUpload,
}: {
currentResourceId: string;
canvasTitles?: CanvasMediaTitles;
busyKey: string;
onPick: (resourceId: string, title: string) => void;
onUpload: (file: File) => void;
}) {
const [keyword, setKeyword] = useState("");
const { items, query, loading, loadingMore, sentinelRef } = useCharacterMediaPages("audio", keyword);
const { playing, toggle } = useAudition();
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<SearchField value={keyword} placeholder="搜索素材库音频" onChange={setKeyword} />
</div>
<FilePicker accept="audio/*" onFile={onUpload}>
{(open) => (
<button
type="button"
onClick={open}
disabled={Boolean(busyKey)}
aria-label="上传本地音频"
title="上传本地音频"
className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.05] text-foreground/60 outline-none transition-colors hover:bg-foreground/[.09] hover:text-foreground focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] disabled:cursor-wait"
>
{busyKey === "upload" ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
</button>
)}
</FilePicker>
</div>
<ul className="divide-y divide-border/60">
{items.map((asset) => {
const resourceId = resourceIdFromStorageKey(asset.data.storageKey);
const current = resourceId === currentResourceId;
const seconds = asset.data.durationMs ? Math.round(asset.data.durationMs / 1000) : 0;
const title = canvasTitles?.get(resourceId) || asset.title;
return (
<li key={asset.id} className="group flex items-center gap-3 py-2.5">
<button
type="button"
onClick={() => void toggle(resourceId, asset.data.storageKey)}
aria-label={`${playing === resourceId ? "暂停" : "试听"} ${title}`}
className={`grid size-8 shrink-0 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] ${playing === resourceId ? "bg-[var(--workspace-accent)] text-background" : "bg-foreground/[.06] text-foreground/70 hover:bg-foreground/[.1]"}`}
>
{playing === resourceId ? <Pause className="size-3.5" /> : <Play className="size-3.5 translate-x-px" />}
</button>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{title}</span>
<span className="text-[11px] text-foreground/40">{seconds ? `${seconds} 秒` : "音频"}</span>
</span>
{current ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-foreground/70">
<Check className="size-3.5" />
使用中
</span>
) : (
<button
type="button"
disabled={Boolean(busyKey)}
onClick={() => onPick(resourceId, title)}
className="rounded-md px-2.5 py-1 text-xs font-medium text-foreground/55 opacity-0 outline-none transition hover:bg-foreground/[.07] hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] group-hover:opacity-100 disabled:cursor-wait"
>
{busyKey === resourceId ? <Loader2 className="size-3.5 animate-spin" /> : "使用"}
</button>
)}
</li>
);
})}
</ul>
<ListState loading={loading} loadingMore={loadingMore} empty={!loading && !items.length ? (query ? "没有匹配的音频" : "素材库里还没有音频,可以直接上传本地音频") : ""} />
<div ref={sentinelRef} />
</div>
);
}