Merge pull request '修复首页失效封面与 Logo 的破图显示' (#8) from codex/home-image-recovery into main

This commit is contained in:
WuYongJie 2026-11-03 09:18:33 +08:00
commit c35f5fc5a8
3 changed files with 99 additions and 33 deletions

View File

@ -0,0 +1,52 @@
"use client";
import { useState, type ReactNode } from "react";
type HomeImageProps = {
sources: Array<string | null | undefined>;
alt: string;
className?: string;
imageClassName?: string;
fallback?: ReactNode;
loading?: "lazy" | "eager";
onUnavailable?: () => void;
};
/** Keep failed image elements out of view, trying each distinct source once. */
export function HomeImage(props: HomeImageProps) {
const sources = [...new Set(props.sources.filter((url): url is string => Boolean(url?.trim())))];
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
}
function HomeImageAttempt({
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
const [index, setIndex] = useState(0);
const [loaded, setLoaded] = useState(false);
const source = sources[index];
return (
<span className={`relative block overflow-hidden ${className || ""}`}>
{!loaded && (
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
{fallback}
</span>
)}
{source ? (
<img
key={source}
src={source}
alt={alt}
loading={loading}
decoding="async"
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
onLoad={() => setLoaded(true)}
onError={() => {
setLoaded(false);
setIndex(index + 1);
if (index + 1 >= sources.length) onUnavailable?.();
}}
/>
) : null}
</span>
);
}

View File

@ -1,6 +1,7 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
import type { Ref } from "react";
import { useState, type Ref } from "react";
import { HomeImage } from "./home-image";
import type { FeaturedImage } from "./types";
type LovartFeaturedGalleryProps = {
@ -30,6 +31,10 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
return `${source}?imageView2/2/w/900/q/78`;
}
function imageIdentity(item: FeaturedImage) {
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
}
export function LovartFeaturedGallery({
onOpenProjects,
onOpenMaterials,
@ -42,6 +47,12 @@ export function LovartFeaturedGallery({
isLoadingMoreFeatured,
setActiveCategory,
}: LovartFeaturedGalleryProps) {
const [unavailable, setUnavailable] = useState<Set<string>>(() => new Set());
const [retryVersion, setRetryVersion] = useState(0);
const visibleItems = featuredItems.filter((item) =>
Boolean(item.thumbnailUrl || item.originalUrl) && !unavailable.has(imageIdentity(item)),
);
const unavailableCount = featuredItems.length - visibleItems.length;
return (
<section
ref={featuredSectionRef}
@ -88,43 +99,44 @@ export function LovartFeaturedGallery({
))}
</div>
{featuredItems.length === 0 ? (
{unavailableCount > 0 && (
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
<span>{unavailableCount} 张灵感图片暂时无法加载,已收起。</span>
<button
type="button"
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
onClick={() => {
setUnavailable(new Set());
setRetryVersion((version) => version + 1);
}}
>
重试加载
</button>
</div>
)}
{visibleItems.length === 0 ? (
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
{isLoadingFeatured
? "灵感内容加载中..."
: "当前没有可展示的灵感数据。"}
: unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
</div>
) : (
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
{featuredItems.map((item, index) => (
{visibleItems.map((item) => (
<button
key={item.id}
key={`${imageIdentity(item)}:${retryVersion}`}
type="button"
onClick={() =>
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
}
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
>
<img
src={getFeaturedPreviewUrl(item)}
<HomeImage
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
alt={item.prompt || "灵感图"}
loading="lazy"
decoding="async"
onError={(event) => {
const image = event.currentTarget;
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
if (
!fallbackUrl ||
image.dataset.fallbackApplied === "true"
) {
return;
}
image.dataset.fallbackApplied = "true";
image.src = fallbackUrl;
}}
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
/>
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
<div className="flex min-w-0 items-center gap-[6px] pr-2">
@ -138,11 +150,11 @@ export function LovartFeaturedGallery({
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
<span className="inline-flex items-center gap-[4px]">
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
{(1200 + index * 37).toLocaleString()}
{(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
</span>
<span className="inline-flex items-center gap-[4px]">
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
{(20 + (index % 9) * 11).toLocaleString()}
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
</span>
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
</div>

View File

@ -13,6 +13,7 @@ import {
} from "@/components/ui/tooltip";
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
import type { NavLink, TutorialVideo } from "./types";
import { HomeImage } from "./home-image";
type SidebarItem = {
iconUrl?: string | null;
@ -183,17 +184,18 @@ export function LovartSidebar({
onClick={onHome}
title={siteName}
>
{siteLogo ? (
<img
src={siteLogo}
<HomeImage
sources={[siteLogo]}
alt={siteName}
loading="eager"
className="h-7 w-7 rounded-full object-cover"
imageClassName="h-7 w-7 rounded-full object-cover"
fallback={
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
{siteName.slice(0, 1).toUpperCase()}
</span>
}
/>
) : (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
{siteName.slice(0, 1).toUpperCase()}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{siteName}</TooltipContent>