5d6a426013
抽取后台素材媒体共享组件并统一懒换签与限流重试 为精选审核补充列表和详情中的素材预览交互 透传精选素材缩略图字段并覆盖历史 OSS 地址回归 同步后台预览数据契约与排障文档
763 lines
26 KiB
TypeScript
763 lines
26 KiB
TypeScript
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Eye,
|
|
FileText,
|
|
RefreshCcw,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { Fragment, type ReactNode } from 'react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import { listAdminEditorAssets } from '../api/adminApiClient';
|
|
import type {
|
|
AdminEditorAssetListQuery,
|
|
AdminEditorAssetPayload,
|
|
} from '../api/adminApiTypes';
|
|
import {
|
|
AdminEditorAssetPreviewDialog,
|
|
AdminEditorAssetThumbnail,
|
|
} from '../components/AdminEditorAssetMedia';
|
|
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
|
import { handlePageError } from './pageUtils';
|
|
|
|
interface AdminEditorAssetQueryPageProps {
|
|
token: string;
|
|
onUnauthorized: (message?: string) => void;
|
|
}
|
|
|
|
const ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS = 300;
|
|
|
|
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)}
|
|
>
|
|
<AdminEditorAssetThumbnail
|
|
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)}
|
|
>
|
|
<AdminEditorAssetThumbnail
|
|
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 ? (
|
|
<AdminEditorAssetPreviewDialog
|
|
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 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)}
|
|
>
|
|
<AdminEditorAssetThumbnail 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 AdminInfoItem({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<dt>{label}</dt>
|
|
<dd>{children}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|