import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react'; import { Fragment, type ReactNode } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import { getAdminAssetReadUrl, isAdminApiError, listAdminEditorAssets, } from '../api/adminApiClient'; import type { AdminEditorAssetListQuery, AdminEditorAssetPayload, } from '../api/adminApiTypes'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; interface AdminEditorAssetQueryPageProps { token: string; onUnauthorized: (message?: string) => void; } const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; const ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS = 300; const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; let adminAssetReadDispatchTail = Promise.resolve(); export function AdminEditorAssetQueryPage({ token, onUnauthorized, }: AdminEditorAssetQueryPageProps) { const [entries, setEntries] = useState([]); const [keyword, setKeyword] = useState(''); const [ownerUserId, setOwnerUserId] = useState(''); const [createdAfter, setCreatedAfter] = useState(''); const [createdBefore, setCreatedBefore] = useState(''); const [nextCursor, setNextCursor] = useState(null); const [committedQueryKey, setCommittedQueryKey] = useState( null, ); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [detailEntry, setDetailEntry] = useState(null); const [previewEntry, setPreviewEntry] = useState(null); const [expandedTaskIds, setExpandedTaskIds] = useState>( () => new Set(), ); const [promptPreview, setPromptPreview] = useState<{ title: string; prompt: string; } | null>(null); const listRequestGenerationRef = useRef(0); const listRequestAbortControllerRef = useRef(null); const automaticQueryInitializedRef = useRef(false); const automaticQueryTokenRef = useRef(token); const activeQuery = buildListQuery(); const activeQueryKey = editorAssetListQueryKey(token, activeQuery); const hasCurrentQuerySnapshot = committedQueryKey === activeQueryKey; const visibleEntries = hasCurrentQuerySnapshot ? entries : []; const visibleNextCursor = hasCurrentQuerySnapshot ? nextCursor : null; useEffect(() => { const runImmediately = !automaticQueryInitializedRef.current || automaticQueryTokenRef.current !== token; automaticQueryInitializedRef.current = true; automaticQueryTokenRef.current = token; if (runImmediately) { void refreshPage(); return; } invalidateListRequest(); setIsLoading(true); setIsLoadingMore(false); setErrorMessage(''); const timer = setTimeout( () => void refreshPage(), ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS, ); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [token, ownerUserId, keyword, createdAfter, createdBefore]); useEffect( () => () => { automaticQueryInitializedRef.current = false; invalidateListRequest(); }, [], ); async function refreshPage() { const query = buildListQuery(); const queryKey = editorAssetListQueryKey(token, query); const { requestGeneration, abortController } = beginListRequest(); setIsLoading(true); setIsLoadingMore(false); setErrorMessage(''); try { const response = await listAdminEditorAssets( token, query, abortController.signal, ); if (listRequestGenerationRef.current !== requestGeneration) { return; } setEntries(response.entries); setNextCursor(response.nextCursor ?? null); setCommittedQueryKey(queryKey); } catch (error: unknown) { if ( listRequestGenerationRef.current !== requestGeneration || isAbortError(error) ) { return; } handlePageError(error, onUnauthorized, setErrorMessage); } finally { if (listRequestGenerationRef.current === requestGeneration) { if (listRequestAbortControllerRef.current === abortController) { listRequestAbortControllerRef.current = null; } setIsLoading(false); } } } async function loadMore() { if (!visibleNextCursor || isLoading || isLoadingMore) { return; } const query = buildListQuery(); const queryKey = editorAssetListQueryKey(token, query); if (committedQueryKey !== queryKey) { return; } const cursor = visibleNextCursor; const { requestGeneration, abortController } = beginListRequest(); setIsLoadingMore(true); setErrorMessage(''); try { const response = await listAdminEditorAssets( token, { ...query, cursor, }, abortController.signal, ); if (listRequestGenerationRef.current !== requestGeneration) { return; } setEntries((current) => mergeAssetEntries(current, response.entries)); setNextCursor(response.nextCursor ?? null); } catch (error: unknown) { if ( listRequestGenerationRef.current !== requestGeneration || isAbortError(error) ) { return; } handlePageError(error, onUnauthorized, setErrorMessage); } finally { if (listRequestGenerationRef.current === requestGeneration) { if (listRequestAbortControllerRef.current === abortController) { listRequestAbortControllerRef.current = null; } setIsLoadingMore(false); } } } function beginListRequest() { invalidateListRequest(); const abortController = new AbortController(); listRequestAbortControllerRef.current = abortController; const requestGeneration = listRequestGenerationRef.current; return { requestGeneration, abortController }; } function invalidateListRequest() { listRequestGenerationRef.current += 1; listRequestAbortControllerRef.current?.abort(); listRequestAbortControllerRef.current = null; } function buildListQuery(): AdminEditorAssetListQuery { return { ownerUserId: ownerUserId.trim() || null, keyword: keyword.trim() || null, createdAfter: dateInputToStartRfc3339(createdAfter), createdBefore: dateInputToEndRfc3339(createdBefore), limit: 80, }; } return (

素材查询

{errorMessage ? (
{errorMessage}
) : null}
{visibleEntries.map((entry) => { const promptText = entry.prompt || entry.actualPrompt || '-'; const expanded = expandedTaskIds.has(entry.assetId); const children = entry.children ?? []; return ( {expanded ? children.map((child) => { const childPrompt = child.prompt || child.actualPrompt || '-'; return ( ); }) : null} ); })}
资源图 创建时间 用户 提示词 生成器 生成成本 详情
{children.length ? ( ) : ( )}
{entry.label || '-'}
{formatDateTime(entry.createdAt)}
{authorDisplayName(entry)} {entry.authorPublicUserCode?.trim() || '-'}
{entry.taskGenerator || entry.generator || '-'} {taskGenerationCostLabel(entry)}
{child.label || '-'}
{formatDateTime(child.createdAt)} {authorDisplayName(child)} {child.generator || '-'} {stageGenerationCostLabel(child)}
{!isLoading && !errorMessage && visibleEntries.length === 0 ? (
暂无生成素材
) : null} {visibleNextCursor ? ( ) : null}
{detailEntry ? ( setDetailEntry(null)} onPreview={(entry) => setPreviewEntry(entry)} onPromptPreview={(entry, prompt) => setPromptPreview({ title: entry.label || entry.assetId, prompt, }) } /> ) : null} {previewEntry ? ( setPreviewEntry(null)} /> ) : null} {promptPreview ? (

完整提示词

{promptPreview.title}
              {promptPreview.prompt}
            
) : null}
); } function AdminAssetPromptButton({ entry, promptText, onOpen, }: { entry: AdminEditorAssetPayload; promptText: string; onOpen: (value: { title: string; prompt: string }) => void; }) { return ( ); } function AdminAssetThumbnail({ entry, token, }: { entry: AdminEditorAssetPayload; token: string; }) { const thumbnailSource = resolveAdminAssetThumbnailSource(entry); const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); const imageSrc = useAdminResolvedAssetUrl( token, thumbnailSource.src, thumbnailSource.objectKey, shouldLoad, ); const alt = `素材:${entry.label || entry.assetId}`; return imageSrc ? ( {alt} ) : (
); } function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) { const mediaKind = resolveAdminAssetMediaKind(entry); if (mediaKind === 'audio') { return { src: AUDIO_ASSET_COVER_SRC, objectKey: null }; } if (mediaKind === 'video') { return { src: entry.thumbnailSrc || '', objectKey: null }; } if (entry.thumbnailSrc?.trim()) { return { src: entry.thumbnailSrc, objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc) ? entry.objectKey : null, }; } return { src: entry.imageSrc, objectKey: entry.objectKey, }; } function useAdminAssetThumbnailVisibility() { const [element, setElement] = useState(null); const [shouldLoad, setShouldLoad] = useState(false); const observeElement = useCallback((nextElement: HTMLElement | null) => { setElement(nextElement); }, []); useEffect(() => { if (shouldLoad || !element) { return; } if (typeof IntersectionObserver === 'undefined') { setShouldLoad(true); return; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setShouldLoad(true); observer.disconnect(); } }, { rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN }, ); observer.observe(element); return () => observer.disconnect(); }, [element, shouldLoad]); return { observeElement, shouldLoad }; } type AdminAssetMediaKind = 'image' | 'audio' | 'video'; function resolveAdminAssetMediaKind( entry: AdminEditorAssetPayload, ): AdminAssetMediaKind { const pathMediaKind = resolveAdminAssetMediaKindFromPath(entry.imageSrc) ?? resolveAdminAssetMediaKindFromPath(entry.objectKey ?? ''); if (pathMediaKind) { return pathMediaKind; } const assetKind = entry.assetKind?.trim() ?? ''; if ( assetKind === 'sound-effect' || assetKind === 'background-music' || assetKind === 'editor_uploaded_audio' ) { return 'audio'; } if ( assetKind === 'video' || assetKind === 'editor_video' || assetKind === 'editor-video' || assetKind === 'editor_uploaded_video' ) { return 'video'; } return 'image'; } function resolveAdminAssetMediaKindFromPath( value: string, ): AdminAssetMediaKind | null { const normalizedValue = value.trim(); if (/^data:image\//iu.test(normalizedValue)) { return 'image'; } if (/^data:audio\//iu.test(normalizedValue)) { return 'audio'; } if (/^data:video\//iu.test(normalizedValue)) { return 'video'; } if ( /\.(?:avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/iu.test(normalizedValue) ) { return 'image'; } if (/\.(?:aac|flac|m4a|mp3|ogg|opus|wav)(?:$|[?#])/iu.test(normalizedValue)) { return 'audio'; } if (/\.(?:m4v|mov|mp4|ogv|webm)(?:$|[?#])/iu.test(normalizedValue)) { return 'video'; } return null; } function AdminAssetDetailDialog({ entry, token, onClose, onPreview, onPromptPreview, onUnauthorized, }: { entry: AdminEditorAssetPayload; token: string; onUnauthorized: (message?: string) => void; onClose: () => void; onPreview: (entry: AdminEditorAssetPayload) => void; onPromptPreview: (entry: AdminEditorAssetPayload, prompt: string) => void; }) { const promptText = entry.prompt || entry.actualPrompt || ''; return (

{entry.label || entry.assetId}

{entry.assetId}
{authorDisplayName(entry)} {entry.authorPublicUserCode?.trim() || '-'}
{entry.ownerUserId} {entry.width} x {entry.height} {entry.children?.length ? taskGenerationCostLabel(entry) : stageGenerationCostLabel(entry)} {entry.children?.length ? entry.taskGenerator || entry.generator || '-' : entry.generator || '-'} {entry.children?.length ? ( {entry.generator || '-'} ) : null} {entry.model || '-'} {entry.provider || '-'} {entry.taskId || '-'} {entry.groupTaskId?.trim() && entry.groupTaskId.trim() !== entry.taskId?.trim() ? ( {entry.groupTaskId.trim()} ) : null} {entry.objectKey || '-'} {formatDateTime(entry.createdAt)} {formatDateTime(entry.updatedAt)} {promptText ? ( ) : ( '-' )}
                {formatGenerationInputs(entry.generationInputs)}
              
); } function taskGenerationCostLabel(entry: AdminEditorAssetPayload) { return `${entry.taskCostMudPoints ?? entry.generationCostMudPoints} 泥点`; } function stageGenerationCostLabel(entry: AdminEditorAssetPayload) { return entry.generationCostMudPoints > 0 ? `${entry.generationCostMudPoints} 泥点` : '本阶段不额外扣费'; } function AdminAssetPreviewDialog({ entry, token, onClose, }: { entry: AdminEditorAssetPayload; token: string; onClose: () => void; }) { return (

{entry.label || entry.assetId}

{entry.assetId}
); } function AdminAssetPreviewMedia({ entry, token, }: { entry: AdminEditorAssetPayload; token: string; }) { const mediaKind = resolveAdminAssetMediaKind(entry); const isAudio = mediaKind === 'audio'; const isVideo = mediaKind === 'video'; const mediaSrc = useAdminResolvedAssetUrl( token, entry.imageSrc, entry.objectKey, ); const posterSrc = useAdminResolvedAssetUrl( token, isVideo ? (entry.thumbnailSrc ?? '') : '', null, ); const label = entry.label || entry.assetId; if (isAudio) { return (
{`音频封面:${label}`} {mediaSrc ? (