统一后台精选审核素材预览
抽取后台素材媒体共享组件并统一懒换签与限流重试 为精选审核补充列表和详情中的素材预览交互 透传精选素材缩略图字段并覆盖历史 OSS 地址回归 同步后台预览数据契约与排障文档
This commit is contained in:
@@ -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 入口,但内容区显示简洁空态。
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user