修复后台素材预览连续加载
按视口懒加载并错峰执行素材换签请求 增加限流退避与卸载清理,避免后续素材永久占位 补充滚动、分页、大批量排队和缩略图路径回归测试 记录后台素材换签限流排障与验证口径
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
import { Eye, FileText, RefreshCcw, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
isAdminApiError,
|
||||
listAdminEditorAssets,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
|
||||
@@ -19,7 +20,11 @@ interface AdminEditorAssetQueryPageProps {
|
||||
}
|
||||
|
||||
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 function AdminEditorAssetQueryPage({
|
||||
token,
|
||||
@@ -295,17 +300,27 @@ function AdminAssetThumbnail({
|
||||
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 alt={alt} className="admin-asset-query-thumb" src={imageSrc} />
|
||||
<img
|
||||
ref={observeElement}
|
||||
alt={alt}
|
||||
className="admin-asset-query-thumb"
|
||||
src={imageSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-thumb admin-asset-query-thumb-placeholder" />
|
||||
<div
|
||||
ref={observeElement}
|
||||
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,12 +332,52 @@ function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) {
|
||||
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.thumbnailSrc || entry.imageSrc,
|
||||
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(
|
||||
@@ -604,6 +659,7 @@ function useAdminResolvedAssetUrl(
|
||||
token: string,
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
enabled = true,
|
||||
) {
|
||||
const normalizedImageSrc = imageSrc?.trim() ?? '';
|
||||
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
|
||||
@@ -625,38 +681,67 @@ function useAdminResolvedAssetUrl(
|
||||
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('');
|
||||
|
||||
void getAdminAssetReadUrl(
|
||||
token,
|
||||
normalizedObjectKey
|
||||
? {
|
||||
objectKey: normalizedObjectKey,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
}
|
||||
: {
|
||||
legacyPublicPath: normalizedLegacyPublicPath,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
)
|
||||
.then(resolveAdminAssetReadSignedUrl)
|
||||
.then((signedUrl) => {
|
||||
if (!cancelled) {
|
||||
setResolvedImageSrc(signedUrl);
|
||||
const resolveReadUrl = async () => {
|
||||
try {
|
||||
await waitForAdminAssetReadDispatch(dispatchController.signal);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
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('');
|
||||
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,
|
||||
@@ -667,10 +752,45 @@ function useAdminResolvedAssetUrl(
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
- 验证:`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs`、`server-rs/crates/api-server/src/admin.rs`。
|
||||
|
||||
## 后台素材缩略图不要在首次挂载时全量换签
|
||||
|
||||
- 现象:后台“素材查询”首批缩略图正常,继续向下滚动或读取更多后长期显示占位图;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`。
|
||||
|
||||
## 陶泥儿精选重复先查同源同媒体画布副本
|
||||
|
||||
- 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。
|
||||
|
||||
Reference in New Issue
Block a user