Files
Genarrative/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx
T
kdletters 387a1c26e3 完成全量检查整改
清理已跟踪原始日志与个人本地配置
修复前后端有效门禁、测试和构建问题
补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束
收紧图片编辑器状态、附件与媒体引用测试
排除已下线旧创作入口测试并清理 warning
2026-07-17 16:56:46 +08:00

1171 lines
36 KiB
TypeScript

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<AdminEditorAssetPayload[]>([]);
const [keyword, setKeyword] = useState('');
const [ownerUserId, setOwnerUserId] = useState('');
const [createdAfter, setCreatedAfter] = useState('');
const [createdBefore, setCreatedBefore] = useState('');
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [committedQueryKey, setCommittedQueryKey] = useState<string | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [detailEntry, setDetailEntry] =
useState<AdminEditorAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorAssetPayload | null>(null);
const [expandedTaskIds, setExpandedTaskIds] = useState<Set<string>>(
() => new Set(),
);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
} | null>(null);
const listRequestGenerationRef = useRef(0);
const listRequestAbortControllerRef = useRef<AbortController | null>(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 (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2>素材查询</h2>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<section className="admin-panel admin-stack">
<div className="admin-form-row admin-asset-query-filter-row">
<label className="admin-field">
<span>开始时间</span>
<input
type="date"
value={createdAfter}
onChange={(event) => setCreatedAfter(event.target.value)}
/>
</label>
<label className="admin-field">
<span>结束时间</span>
<input
type="date"
value={createdBefore}
onChange={(event) => setCreatedBefore(event.target.value)}
/>
</label>
<label className="admin-field">
<span>用户 ID / 陶泥号</span>
<input
value={ownerUserId}
onChange={(event) => setOwnerUserId(event.target.value)}
/>
</label>
<label className="admin-field">
<span>搜索</span>
<input
placeholder="素材名 / 用户 / 生成器 / 模型 / 提示词"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</label>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide admin-asset-query-table">
<thead>
<tr>
<th>资源图</th>
<th>创建时间</th>
<th>用户</th>
<th>提示词</th>
<th>生成器</th>
<th>生成成本</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{visibleEntries.map((entry) => {
const promptText = entry.prompt || entry.actualPrompt || '-';
const expanded = expandedTaskIds.has(entry.assetId);
const children = entry.children ?? [];
return (
<Fragment key={entry.assetId}>
<tr>
<td>
<div className="admin-asset-query-resource-cell">
{children.length ? (
<button
aria-label={expanded ? '收起中间产物' : '展开中间产物'}
className="admin-asset-query-expand-button"
type="button"
onClick={() =>
setExpandedTaskIds((current) => {
const next = new Set(current);
if (next.has(entry.assetId)) {
next.delete(entry.assetId);
} else {
next.add(entry.assetId);
}
return next;
})
}
>
{expanded ? (
<ChevronDown size={17} aria-hidden="true" />
) : (
<ChevronRight size={17} aria-hidden="true" />
)}
</button>
) : (
<span className="admin-asset-query-expand-spacer" />
)}
<div>
<button
className="admin-asset-query-thumb-button"
title="预览素材"
type="button"
onClick={() => setPreviewEntry(entry)}
>
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</div>
</div>
</td>
<td>{formatDateTime(entry.createdAt)}</td>
<td>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<AdminAssetPromptButton
entry={entry}
promptText={promptText}
onOpen={setPromptPreview}
/>
</td>
<td>{entry.taskGenerator || entry.generator || '-'}</td>
<td>{taskGenerationCostLabel(entry)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(entry)}
>
<Eye size={16} aria-hidden="true" />
<span>详情</span>
</button>
</td>
</tr>
{expanded
? children.map((child) => {
const childPrompt =
child.prompt || child.actualPrompt || '-';
return (
<tr
className="admin-asset-query-child-row"
key={child.assetId}
>
<td>
<div className="admin-asset-query-resource-cell admin-asset-query-resource-cell-child">
<span className="admin-asset-query-child-branch" />
<div>
<button
className="admin-asset-query-thumb-button"
title="预览中间产物"
type="button"
onClick={() => setPreviewEntry(child)}
>
<AdminAssetThumbnail
entry={child}
token={token}
/>
</button>
<small>{child.label || '-'}</small>
</div>
</div>
</td>
<td>{formatDateTime(child.createdAt)}</td>
<td>{authorDisplayName(child)}</td>
<td>
<AdminAssetPromptButton
entry={child}
promptText={childPrompt}
onOpen={setPromptPreview}
/>
</td>
<td>{child.generator || '-'}</td>
<td>{stageGenerationCostLabel(child)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(child)}
>
<Eye size={16} aria-hidden="true" />
<span>详情</span>
</button>
</td>
</tr>
);
})
: null}
</Fragment>
);
})}
</tbody>
</table>
</div>
{!isLoading && !errorMessage && visibleEntries.length === 0 ? (
<div className="admin-empty-state">暂无生成素材</div>
) : null}
{visibleNextCursor ? (
<button
className="admin-secondary-button"
disabled={isLoading || isLoadingMore}
type="button"
onClick={loadMore}
>
<span>{isLoadingMore ? '读取中' : '读取更多'}</span>
</button>
) : null}
</section>
{detailEntry ? (
<AdminAssetDetailDialog
entry={detailEntry}
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
title: entry.label || entry.assetId,
prompt,
})
}
/>
) : null}
{previewEntry ? (
<AdminAssetPreviewDialog
entry={previewEntry}
token={token}
onClose={() => setPreviewEntry(null)}
/>
) : null}
{promptPreview ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
className="admin-detail-panel admin-asset-query-prompt-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="admin-asset-query-prompt-dialog-title"
>
<div className="admin-panel-heading">
<div>
<h3 id="admin-asset-query-prompt-dialog-title">完整提示词</h3>
<span>{promptPreview.title}</span>
</div>
<button
aria-label="关闭完整提示词"
className="admin-ghost-button"
type="button"
onClick={() => setPromptPreview(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<pre className="admin-code-block admin-asset-query-prompt-full">
{promptPreview.prompt}
</pre>
</section>
</div>
) : null}
</section>
);
}
function AdminAssetPromptButton({
entry,
promptText,
onOpen,
}: {
entry: AdminEditorAssetPayload;
promptText: string;
onOpen: (value: { title: string; prompt: string }) => void;
}) {
return (
<button
className="admin-text-button admin-asset-query-prompt-text"
disabled={promptText === '-'}
title={promptText}
type="button"
onClick={() =>
onOpen({
title: entry.label || entry.assetId,
prompt: promptText,
})
}
>
{promptText}
</button>
);
}
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 ? (
<img
ref={observeElement}
alt={alt}
className="admin-asset-query-thumb"
src={imageSrc}
/>
) : (
<div
ref={observeElement}
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
/>
);
}
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<HTMLElement | null>(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 (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="素材详情"
className="admin-detail-panel admin-asset-query-detail-dialog"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h3>{entry.label || entry.assetId}</h3>
<span>{entry.assetId}</span>
</div>
<button
aria-label="关闭详情"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="admin-asset-query-detail-layout">
<button
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
title="预览素材"
type="button"
onClick={() => onPreview(entry)}
>
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</AdminInfoItem>
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
<AdminInfoItem label="尺寸">
{entry.width} x {entry.height}
</AdminInfoItem>
<AdminInfoItem label="成本">
{entry.children?.length
? taskGenerationCostLabel(entry)
: stageGenerationCostLabel(entry)}
</AdminInfoItem>
<AdminInfoItem label="生成器">
{entry.children?.length
? entry.taskGenerator || entry.generator || '-'
: entry.generator || '-'}
</AdminInfoItem>
{entry.children?.length ? (
<AdminInfoItem label="当前产物阶段">
{entry.generator || '-'}
</AdminInfoItem>
) : null}
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">
{entry.provider || '-'}
</AdminInfoItem>
<AdminInfoItem label="Task ID">{entry.taskId || '-'}</AdminInfoItem>
{entry.groupTaskId?.trim() &&
entry.groupTaskId.trim() !== entry.taskId?.trim() ? (
<AdminInfoItem label="归组 Task ID">
{entry.groupTaskId.trim()}
</AdminInfoItem>
) : null}
<AdminInfoItem label="Object Key">
{entry.objectKey || '-'}
</AdminInfoItem>
<AdminInfoItem label="创建时间">
{formatDateTime(entry.createdAt)}
</AdminInfoItem>
<AdminInfoItem label="更新时间">
{formatDateTime(entry.updatedAt)}
</AdminInfoItem>
<AdminInfoItem label="提示词">
{promptText ? (
<button
className="admin-text-button"
type="button"
onClick={() => onPromptPreview(entry, promptText)}
>
<FileText size={13} aria-hidden="true" />
<span>完整提示词</span>
</button>
) : (
'-'
)}
</AdminInfoItem>
<AdminInfoItem label="生成输入">
<pre className="admin-code-block">
{formatGenerationInputs(entry.generationInputs)}
</pre>
</AdminInfoItem>
</dl>
</div>
</section>
</div>
);
}
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 (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="素材预览"
className="admin-detail-panel admin-asset-query-preview-dialog"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h3>{entry.label || entry.assetId}</h3>
<span>{entry.assetId}</span>
</div>
<button
aria-label="关闭素材预览"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<AdminAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
);
}
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 (
<div className="admin-asset-query-preview-audio">
<img
alt={`音频封面:${label}`}
className="admin-asset-query-preview-cover"
src={AUDIO_ASSET_COVER_SRC}
/>
{mediaSrc ? (
<audio
aria-label={`音频预览:${label}`}
className="admin-asset-query-preview-player"
controls
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
)}
</div>
);
}
if (isVideo) {
return mediaSrc ? (
<video
aria-label={`视频预览:${label}`}
className="admin-asset-query-preview-media"
controls
playsInline
poster={posterSrc || undefined}
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
return mediaSrc ? (
<img
alt={`图片预览:${label}`}
className="admin-asset-query-preview-media"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
function AdminInfoItem({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function useAdminResolvedAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
enabled = true,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
const shouldResolve =
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
return;
}
if (!enabled) {
setResolvedImageSrc('');
return;
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryIndex = 0;
const dispatchController = new AbortController();
setResolvedImageSrc('');
const resolveReadUrl = async () => {
try {
await waitForAdminAssetReadDispatch(dispatchController.signal);
if (cancelled) {
return;
}
const response = await getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
);
if (!cancelled) {
setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response));
}
} catch (error: unknown) {
if (cancelled) {
return;
}
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
if (
isAdminApiError(error) &&
error.status === 429 &&
typeof retryDelay === 'number'
) {
retryIndex += 1;
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
return;
}
setResolvedImageSrc('');
}
};
void resolveReadUrl();
return () => {
cancelled = true;
dispatchController.abort();
if (retryTimer !== null) {
clearTimeout(retryTimer);
}
};
}, [
enabled,
normalizedImageSrc,
normalizedLegacyPublicPath,
normalizedObjectKey,
shouldResolve,
token,
]);
return resolvedImageSrc;
}
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
const dispatch = adminAssetReadDispatchTail.then(
() => waitForAdminAssetReadDispatchSpacing(signal),
() => waitForAdminAssetReadDispatchSpacing(signal),
);
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
await dispatch;
}
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', handleAbort);
resolve();
}, ADMIN_ASSET_READ_DISPATCH_SPACING_MS);
function handleAbort() {
clearTimeout(timer);
reject(new DOMException('The operation was aborted.', 'AbortError'));
}
signal.addEventListener('abort', handleAbort, { once: true });
});
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function adminAssetPathsMatch(left: string, right: string) {
return (
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
right.trim().replace(/^\/+|[?#].*$/gu, '')
);
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function resolveAdminGeneratedLegacyPathFromUrl(value: string) {
try {
const parsedUrl = new URL(value);
if (
parsedUrl.protocol !== 'https:' ||
!/^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(parsedUrl.hostname)
) {
return '';
}
const legacyPublicPath = decodeURIComponent(parsedUrl.pathname);
return isGeneratedLegacyPath(legacyPublicPath) ? legacyPublicPath : '';
} catch {
return '';
}
}
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
const read = response.read ?? response;
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
}
function mergeAssetEntries(
current: AdminEditorAssetPayload[],
incoming: AdminEditorAssetPayload[],
) {
const byId = new Map<string, AdminEditorAssetPayload>();
[...current, ...incoming].forEach((entry) => byId.set(entry.assetId, entry));
return [...byId.values()].sort(
(left, right) =>
parseAdminTimestamp(right.createdAt) -
parseAdminTimestamp(left.createdAt) ||
right.assetId.localeCompare(left.assetId),
);
}
function editorAssetListQueryKey(
token: string,
query: AdminEditorAssetListQuery,
) {
return JSON.stringify([
token.trim(),
query.ownerUserId?.trim() || null,
query.keyword?.trim() || null,
query.createdAfter?.trim() || null,
query.createdBefore?.trim() || null,
query.limit ?? null,
]);
}
function isAbortError(error: unknown) {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError'
);
}
function dateInputToStartRfc3339(value: string) {
return value ? `${value}T00:00:00+08:00` : null;
}
function dateInputToEndRfc3339(value: string) {
return value ? `${value}T23:59:59.999+08:00` : null;
}
function formatDateTime(value: string) {
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) {
return value || '-';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp);
}
function parseAdminTimestamp(value: string | null | undefined) {
const normalizedValue = value?.trim() ?? '';
const secondsMicrosMatch = normalizedValue.match(/^(-?\d+)\.(\d{6})Z$/u);
if (secondsMicrosMatch) {
const seconds = Number(secondsMicrosMatch[1]);
const micros = Number(secondsMicrosMatch[2]);
if (Number.isFinite(seconds) && Number.isFinite(micros)) {
return seconds * 1000 + Math.floor(micros / 1000);
}
}
return Date.parse(normalizedValue);
}
function authorDisplayName(entry: AdminEditorAssetPayload) {
return (
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function formatGenerationInputs(
value: Record<string, unknown> | null | undefined,
) {
if (!value) {
return '-';
}
return JSON.stringify(value, null, 2);
}