fix: render prompt museum media

This commit is contained in:
2026-08-18 12:32:34 +08:00
parent 11b19832a3
commit f8d82e6c19
9 changed files with 609 additions and 37 deletions

View File

@@ -15,6 +15,21 @@ type PromptMuseumEnvelope<T> = {
data?: T;
};
type PromptMuseumMedia = {
dataBase64: string;
mimeType: string;
};
const PROMPT_MUSEUM_MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/;
const PROMPT_MUSEUM_MEDIA_MIME_TYPES = new Set([
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
const MAX_MEDIA_BASE64_LENGTH = Math.ceil((10 * 1024 * 1024) / 3) * 4;
export class PromptMuseumApiError extends Error {
readonly status: number;
readonly code: string;
@@ -92,3 +107,23 @@ export async function fetchPromptMuseumPage(query: PromptMuseumListQuery = {}):
export async function fetchPromptMuseumEntry(entryId: string): Promise<PromptMuseumEntry> {
return await requestData(`${IMAGE_PROMPT_MUSEUM_API_PATH}/${encodeURIComponent(entryId)}`);
}
export async function fetchPromptMuseumMedia(mediaUrl: string): Promise<string> {
if (!PROMPT_MUSEUM_MEDIA_URL_PATTERN.test(mediaUrl)) {
throw new PromptMuseumApiError(400, 'PROMPT_MUSEUM_INVALID_MEDIA_URL', '提示词图片地址无效');
}
const localPath = mediaUrl.replace('/api/image-prompt-museum/', `${IMAGE_PROMPT_MUSEUM_API_PATH}/`);
const payload = await hostApiFetch<PromptMuseumMedia>(localPath);
const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : '';
const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : '';
if (
!PROMPT_MUSEUM_MEDIA_MIME_TYPES.has(mimeType)
|| !dataBase64
|| dataBase64.length > MAX_MEDIA_BASE64_LENGTH
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64)
|| dataBase64.length % 4 === 1
) {
throw new PromptMuseumApiError(502, 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE', '提示词图片返回了无效数据');
}
return `data:${mimeType};base64,${dataBase64}`;
}

View File

@@ -26,6 +26,7 @@ import {
} from '@/components/ui/sheet';
import {
fetchPromptMuseumEntry,
fetchPromptMuseumMedia,
fetchPromptMuseumPage,
PromptMuseumApiError,
} from '@/lib/image-prompt-museum';
@@ -84,6 +85,17 @@ function errorMessage(error: unknown): string {
return '获取灵感暂时不可用,请稍后再试';
}
function directHttpsUrl(value: string): string | null {
try {
const parsed = new URL(value);
return parsed.protocol === 'https:' && !parsed.username && !parsed.password
? parsed.toString()
: null;
} catch {
return null;
}
}
function MuseumImage({
image,
className,
@@ -93,22 +105,52 @@ function MuseumImage({
className?: string;
sizes?: string;
}) {
const [failed, setFailed] = useState(false);
if (failed || !image.url) {
const directSource = directHttpsUrl(image.url);
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const [mediaState, setMediaState] = useState<{
imageUrl: string;
source: string | null;
failed: boolean;
}>({ imageUrl: '', source: null, failed: false });
useEffect(() => {
if (directHttpsUrl(image.url)) return;
let cancelled = false;
void fetchPromptMuseumMedia(image.url).then(
(dataUrl) => {
if (!cancelled) setMediaState({ imageUrl: image.url, source: dataUrl, failed: false });
},
() => {
if (!cancelled) setMediaState({ imageUrl: image.url, source: null, failed: true });
},
);
return () => {
cancelled = true;
};
}, [image.url]);
const relativeState = mediaState.imageUrl === image.url ? mediaState : null;
const source = directSource ?? relativeState?.source ?? null;
const failed = failedUrl === image.url || relativeState?.failed === true;
if (failed || !source) {
return (
<div className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}>
<div
aria-label={failed ? `${image.alt}加载失败` : `${image.alt}正在加载`}
className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}
>
<ImageIcon className="h-8 w-8" aria-hidden="true" />
</div>
);
}
return (
<img
src={image.url}
src={source}
alt={image.alt}
sizes={sizes}
loading="lazy"
className={className}
onError={() => setFailed(true)}
onError={() => setFailedUrl(image.url)}
/>
);
}
@@ -227,15 +269,17 @@ function AttributionBlock({ entry }: { entry: PromptMuseumEntry }) {
<div className="flex gap-3">
<dt className="w-12 shrink-0 text-muted-foreground"></dt>
<dd className="min-w-0 truncate font-medium text-foreground">
<a
href={entry.attribution.source.url}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full items-center gap-1 text-brand hover:underline"
>
<span className="truncate">{entry.attribution.source.name}</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
{entry.attribution.source.url ? (
<a
href={entry.attribution.source.url}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full items-center gap-1 text-brand hover:underline"
>
<span className="truncate">{entry.attribution.source.name}</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
) : entry.attribution.source.name}
</dd>
</div>
<div className="flex gap-3">