统一后台精选审核素材预览

抽取后台素材媒体共享组件并统一懒换签与限流重试
为精选审核补充列表和详情中的素材预览交互
透传精选素材缩略图字段并覆盖历史 OSS 地址回归
同步后台预览数据契约与排障文档
This commit is contained in:
2026-07-22 18:49:09 +08:00
parent 2c134d87da
commit 5d6a426013
10 changed files with 722 additions and 558 deletions
+1
View File
@@ -372,6 +372,7 @@ export interface AdminEditorShowcaseAssetPayload {
taskId?: string | null;
assetKind?: string | null;
generationInputs?: Record<string, unknown> | null;
thumbnailSrc?: string | null;
generationCostMudPoints: number;
refundMudPoints: number;
reviewStatus: 'pending' | 'approved' | 'rejected' | string;
@@ -0,0 +1,436 @@
import { X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
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 AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
let adminAssetReadDispatchTail = Promise.resolve();
export interface AdminPreviewableEditorAsset {
assetId: string;
label: string;
imageSrc: string;
objectKey?: string | null;
assetKind?: string | null;
thumbnailSrc?: string | null;
}
export function AdminEditorAssetThumbnail({
entry,
token,
altPrefix = '素材',
}: {
entry: AdminPreviewableEditorAsset;
token: string;
altPrefix?: string;
}) {
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
const imageSrc = useAdminResolvedAssetUrl(
token,
thumbnailSource.src,
thumbnailSource.objectKey,
shouldLoad,
);
const alt = `${altPrefix}:${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"
/>
);
}
export function AdminEditorAssetPreviewDialog({
entry,
token,
onClose,
}: {
entry: AdminPreviewableEditorAsset;
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>
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
);
}
function AdminEditorAssetPreviewMedia({
entry,
token,
}: {
entry: AdminPreviewableEditorAsset;
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 resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) {
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: AdminPreviewableEditorAsset,
): 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 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() : '';
}
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,14 @@
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
getAdminAssetReadUrl,
@@ -23,6 +24,13 @@ import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
getAdminEditorShowcaseCampaign: vi.fn(),
listAdminEditorShowcaseAssets: vi.fn(),
reviewAdminEditorShowcaseAsset: vi.fn(),
@@ -31,8 +39,87 @@ vi.mock('../api/adminApiClient', () => ({
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
interface MockIntersectionObserverController {
enter: (target: Element) => void;
isObserved: (target: Element) => boolean;
}
function installIntersectionObserverMock(): MockIntersectionObserverController {
const observed = new Map<
Element,
{
callback: IntersectionObserverCallback;
observer: IntersectionObserver;
}
>();
class MockIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin: string;
readonly thresholds = [0];
private readonly targets = new Set<Element>();
constructor(
private readonly callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {},
) {
this.rootMargin = options.rootMargin ?? '0px';
}
observe(target: Element) {
this.targets.add(target);
observed.set(target, {
callback: this.callback,
observer: this as unknown as IntersectionObserver,
});
}
unobserve(target: Element) {
this.targets.delete(target);
observed.delete(target);
}
disconnect() {
this.targets.forEach((target) => observed.delete(target));
this.targets.clear();
}
takeRecords() {
return [];
}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
return {
enter(target) {
const record = observed.get(target);
if (!record) {
throw new Error('目标精选缩略图尚未进入 IntersectionObserver');
}
act(() => {
record.callback(
[
{
isIntersecting: true,
target,
} as IntersectionObserverEntry,
],
record.observer,
);
});
},
isObserved(target) {
return observed.has(target);
},
};
}
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string | null;
}) => (
@@ -160,6 +247,10 @@ beforeEach(() => {
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('后台精选审核展示待审核素材和活动卡配置', async () => {
render(
<AdminEditorShowcaseReviewPage
@@ -184,10 +275,114 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
submittedBefore: null,
limit: 80,
});
await waitFor(() => {
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
});
test('后台精选审核缩略图进入视口后换签并可打开图片预览', async () => {
const observer = installIntersectionObserverMock();
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
const previewButton = screen.getByTitle('预览素材');
const thumbnail = previewButton.querySelector('.admin-asset-query-thumb');
expect(thumbnail).not.toBeNull();
await waitFor(() => expect(observer.isObserved(thumbnail!)).toBe(true));
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
observer.enter(thumbnail!);
const image = await screen.findByRole('img', {
name: '精选素材:角色形象 1',
});
expect(image.getAttribute('src')).toBe('https://signed.example.com/spec.png');
fireEvent.click(previewButton);
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
const previewImage = await within(dialog).findByRole('img', {
name: '图片预览:角色形象 1',
});
await waitFor(() => {
expect(previewImage.getAttribute('src')).toBe(
'https://signed.example.com/spec.png',
);
});
expect(screen.queryByRole('dialog', { name: '精选素材详情' })).toBeNull();
});
test('后台精选审核将无 objectKey 的绝对 OSS 图片地址换签后预览', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
imageSrc:
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320',
objectKey: null,
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-character-drafts/editor/absolute.png',
signedUrl: 'https://signed.example.com/absolute.png',
expiresAt: '2026-07-04T11:00:00Z',
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
const image = await screen.findByRole('img', {
name: '精选素材:角色形象 1',
});
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/absolute.png',
);
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
expireSeconds: 300,
});
fireEvent.click(screen.getByTitle('预览素材'));
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await within(dialog).findByRole('img', {
name: '图片预览:角色形象 1',
}),
).toHaveProperty('src', 'https://signed.example.com/absolute.png');
});
test('后台精选审核详情中的缩略图也可打开素材预览', async () => {
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
const detail = await screen.findByRole('dialog', { name: '精选素材详情' });
fireEvent.click(within(detail).getByTitle('预览素材'));
const preview = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await within(preview).findByRole('img', {
name: '图片预览:角色形象 1',
}),
).toBeTruthy();
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
@@ -2,9 +2,7 @@ import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import {
getAdminAssetReadUrl,
getAdminEditorShowcaseCampaign,
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
@@ -17,6 +15,10 @@ import type {
AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseListQuery,
} from '../api/adminApiTypes';
import {
AdminEditorAssetPreviewDialog,
AdminEditorAssetThumbnail,
} from '../components/AdminEditorAssetMedia';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
@@ -25,9 +27,6 @@ interface AdminEditorShowcaseReviewPageProps {
onUnauthorized: (message?: string) => void;
}
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
const showcaseCategoryOptions = [
{ value: 'characters', label: '角色' },
{ value: 'ui', label: 'UI' },
@@ -58,6 +57,8 @@ export function AdminEditorShowcaseReviewPage({
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
const [detailEntry, setDetailEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
@@ -342,11 +343,15 @@ export function AdminEditorShowcaseReviewPage({
<td>
<button
className="admin-asset-query-thumb-button"
title="查看详情"
title="预览素材"
type="button"
onClick={() => setDetailEntry(entry)}
onClick={() => setPreviewEntry(entry)}
>
<AdminShowcaseThumbnail entry={entry} token={token} />
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<small>{entry.label || '-'}</small>
</td>
@@ -355,7 +360,9 @@ export function AdminEditorShowcaseReviewPage({
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
@@ -611,6 +618,7 @@ export function AdminEditorShowcaseReviewPage({
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
title: entry.label || entry.showcaseId,
@@ -620,6 +628,14 @@ export function AdminEditorShowcaseReviewPage({
/>
) : null}
{previewEntry ? (
<AdminEditorAssetPreviewDialog
entry={previewEntry}
token={token}
onClose={() => setPreviewEntry(null)}
/>
) : null}
{promptPreview ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
@@ -652,48 +668,18 @@ export function AdminEditorShowcaseReviewPage({
);
}
function AdminShowcaseThumbnail({
entry,
token,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
}) {
const isAudio = isAdminShowcaseAudioAsset(entry);
const imageSrc = useAdminResolvedAssetImageSrc(
token,
isAudio ? AUDIO_ASSET_COVER_SRC : entry.imageSrc,
isAudio ? null : entry.objectKey,
);
const alt = `精选素材:${entry.label || entry.showcaseId}`;
return imageSrc ? (
<img alt={alt} className="admin-asset-query-thumb" src={imageSrc} />
) : (
<div className="admin-asset-query-thumb admin-asset-query-thumb-placeholder" />
);
}
function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
const assetKind = entry.assetKind?.trim() ?? '';
return (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio' ||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
);
}
function AdminShowcaseDetailDialog({
entry,
token,
onClose,
onPreview,
onPromptPreview,
onUnauthorized,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
onClose: () => void;
onPreview: (entry: AdminEditorShowcaseAssetPayload) => void;
onUnauthorized: (message?: string) => void;
onPromptPreview: (
entry: AdminEditorShowcaseAssetPayload,
@@ -723,7 +709,18 @@ function AdminShowcaseDetailDialog({
</button>
</div>
<div className="admin-asset-query-detail-layout">
<AdminShowcaseThumbnail entry={entry} token={token} />
<button
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
title="预览素材"
type="button"
onClick={() => onPreview(entry)}
>
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
<div className="admin-inline-identity">
@@ -815,77 +812,6 @@ function AdminInfoItem({
);
}
function useAdminResolvedAssetImageSrc(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const shouldResolve =
Boolean(normalizedObjectKey) || isGeneratedLegacyPath(normalizedImageSrc);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
return;
}
let cancelled = false;
setResolvedImageSrc('');
void getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedImageSrc,
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
},
)
.then(resolveAdminAssetReadSignedUrl)
.then((signedUrl) => {
if (!cancelled) {
setResolvedImageSrc(signedUrl);
}
})
.catch(() => {
if (!cancelled) {
setResolvedImageSrc('');
}
});
return () => {
cancelled = true;
};
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve, token]);
return resolvedImageSrc;
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
const read = response.read ?? response;
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
}
function mergeShowcaseEntries(
current: AdminEditorShowcaseAssetPayload[],
incoming: AdminEditorShowcaseAssetPayload[],
@@ -95,13 +95,13 @@
- 验证:API 测试覆盖陶泥号组合条件、未知陶泥号、可信来源归组、`seconds.microsZ` / 极值游标和真实 / 归组 Task ID;SpacetimeDB 测试覆盖资源删除后稳定归组、部分失败批次不抢占根任务、重复拆分有界无丢失和 owner 索引分支;后台页面测试覆盖逐字输入防抖、请求取消、刷新失败保留结果、筛选请求乱序、旧分页响应失效和“用户 ID / 陶泥号”请求。再运行 `cargo test -p api-server admin_editor_asset --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-module admin_editor_asset --manifest-path server-rs/Cargo.toml` 和 `npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`。
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`、`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`。
## 后台素材缩略图不要在首次挂载时全量换签
## 后台素材查询与精选审核缩略图不要在首次挂载时全量换签
- 现象:后台“素材查询”首批缩略图正常,继续向下滚动或读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`。
- 现象:后台“素材查询”或“精选审核”首批缩略图正常,继续向下滚动、读取更多或一次加载较多审核项后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`。
- 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 `genarrative_admin_rps` 为 `30r/s burst=16`,超出部分在进入 api-server 前已返回 `429`,因此仅查 api-server 日志会漏掉失败请求。
- 处理:缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`、`apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`。
- 处理:素材查询与精选审核共用缩略图和预览组件;缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。无 `objectKey` 的绝对 OSS generated 地址先提取 legacy path 再换签。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖两页共用换签组件、首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试、绝对 OSS 地址换签和点击缩略图打开媒体预览;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/components/AdminEditorAssetMedia.tsx`、`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`、`apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx` 及对应测试。
## 陶泥儿精选重复先查同源同媒体画布副本
File diff suppressed because one or more lines are too long
@@ -122,6 +122,7 @@ Tab:
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。素材不可提交或后端拒绝提交时,菜单必须显示具体原因,例如非生成素材、素材尚未保存、没有媒体内容或审核未通过不能重复提交。
- 后台新增纯素材查询页,读取账号级 `editor_asset` 中 `sourceType="generated"` 的素材,用于查看所有用户生成素材。该页只提供时间、用户 ID 和关键词查询,以及缩略图详情、提示词全文、生成成本、作者展示名和陶泥号查看;不提供分类筛选,也不承载审核、返还、展示开关或活动卡配置操作。
- 后台“素材查询”和“精选审核”必须共用同一套素材缩略图、媒体类型判断、私有地址换签与放大预览弹窗:缩略图只在进入视口附近后错峰换签,遇到管理端限流时执行有上限的退避重试;点击缩略图直接打开图片、音频或视频预览,详情继续由独立“详情”按钮进入。无 `objectKey` 的历史绝对 OSS 地址也必须先归一为 generated legacy path 再换签,不能把私有裸地址直接交给浏览器。
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
+9 -4
View File
@@ -1056,6 +1056,7 @@ fn admin_editor_showcase_asset_payload_from_record(
task_id: record.task_id,
asset_kind: record.asset_kind,
generation_inputs: record.generation_inputs,
thumbnail_src: record.thumbnail_src,
generation_cost_mud_points: record.generation_cost_mud_points,
refund_mud_points: record.refund_mud_points,
review_status: record.review_status,
@@ -4223,10 +4224,10 @@ mod tests {
.ensure_orphan_work_owner_user("user-author-1", "author-1", "作者昵称", "SY-00000042")
.expect("author should be inserted");
let payload = admin_editor_showcase_asset_payload_from_record(
&state,
test_admin_editor_showcase_asset_record("user-author-1"),
);
let mut record = test_admin_editor_showcase_asset_record("user-author-1");
record.thumbnail_src =
Some("/generated-character-drafts/editor/spec-thumb.png".to_string());
let payload = admin_editor_showcase_asset_payload_from_record(&state, record);
assert_eq!(payload.owner_user_id, "user-author-1");
assert_eq!(payload.author_display_name.as_deref(), Some("作者昵称"));
@@ -4234,6 +4235,10 @@ mod tests {
payload.author_public_user_code.as_deref(),
Some("SY-00000042")
);
assert_eq!(
payload.thumbnail_src.as_deref(),
Some("/generated-character-drafts/editor/spec-thumb.png")
);
}
#[test]
@@ -278,6 +278,7 @@ pub struct AdminEditorShowcaseAssetPayload {
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs: Option<Value>,
pub thumbnail_src: Option<String>,
pub generation_cost_mud_points: u64,
pub refund_mud_points: u64,
pub review_status: String,
@@ -1060,6 +1061,7 @@ mod tests {
task_id: None,
asset_kind: Some("character".to_string()),
generation_inputs: None,
thumbnail_src: Some("/generated-character-drafts/editor/spec-thumb.png".to_string()),
generation_cost_mud_points: 12,
refund_mud_points: 6,
review_status: "pending".to_string(),
@@ -1082,7 +1084,12 @@ mod tests {
assert_eq!(value["authorDisplayName"], json!("作者昵称"));
assert_eq!(value["authorPublicUserCode"], json!("SY-00000042"));
assert_eq!(
value["thumbnailSrc"],
json!("/generated-character-drafts/editor/spec-thumb.png")
);
assert!(value.get("author_display_name").is_none());
assert!(value.get("author_public_user_code").is_none());
assert!(value.get("thumbnail_src").is_none());
}
}