fix: render prompt museum media
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user