blog / src /components /common /CoverImage.astro
cacode's picture
Upload 434 files
96dd062 verified
---
import { Picture } from "astro:assets";
import * as path from "node:path";
import type { ImageMetadata } from "astro";
import { coverImageConfig } from "@/config/coverImageConfig";
import type { ImageFormat, ResponsiveImageLayout } from "@/types/config";
import {
getFallbackFormat,
getImageFormats,
getImageQuality,
} from "@/utils/image-utils";
import { url } from "@/utils/url-utils";
interface Props {
id?: string;
src: string;
class?: string;
alt?: string;
position?: string;
basePath?: string;
preview?: boolean;
layout?: ResponsiveImageLayout;
formats?: ImageFormat[];
loading?: "lazy" | "eager";
}
const {
id,
src,
alt,
position = "center",
basePath = "/",
preview = false,
layout = "constrained",
formats = getImageFormats(),
loading = "lazy",
} = Astro.props;
const configQuality = getImageQuality();
const fallbackFormat = getFallbackFormat();
const className = Astro.props.class;
// 判断图片类型
const isLocal = !(
src.startsWith("/") ||
src.startsWith("http") ||
src.startsWith("https") ||
src.startsWith("data:")
);
const isPublic = src.startsWith("/");
// 动态导入本地图片
let img: ImageMetadata | null = null;
if (isLocal) {
const files = import.meta.glob<ImageMetadata>(
"../../**/*.{png,jpg,jpeg,webp,avif}",
{
import: "default",
},
);
const normalizedPath = path
.normalize(path.join("../../", basePath, src))
.replace(/\\/g, "/");
const file = files[normalizedPath];
if (file) {
img = await file();
} else {
console.error(
`[ERROR] Image not found: ${normalizedPath.replace("../../", "src/")}`,
);
}
}
// 加载回退图片
let fallbackImg: ImageMetadata | null = null;
const fallbackPath = coverImageConfig.randomCoverImage.fallback;
if (fallbackPath && !isLocal) {
const files = import.meta.glob<ImageMetadata>(
"../../**/*.{png,jpg,jpeg,webp,avif}",
{
import: "default",
},
);
const normalizedFallbackPath = path
.normalize(path.join("../../", fallbackPath))
.replace(/\\/g, "/");
const file = files[normalizedFallbackPath];
if (file) {
fallbackImg = await file();
}
}
// 图片样式
const imageClass = "w-full h-full object-cover";
const imageStyle = `object-position: ${position};`;
// 响应式配置
const widths = preview ? [320, 480, 640, 960] : [800, 1200, 1600, 2000];
const sizes = preview
? "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 320px"
: "(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px";
const quality = preview ? Math.round(configQuality * 0.9) : configQuality;
// 是否显示加载动画
const showLoading = coverImageConfig.randomCoverImage.showLoading ?? true;
---
<div
id={id}
class:list={[className, "cover-image-container overflow-hidden relative"]}
data-loading={showLoading ? "true" : "false"}
>
<!-- 加载动画 -->
{showLoading && (
<div class="loading-spinner absolute inset-0 flex items-center justify-center z-10" style="background-color: var(--card-bg);">
<div class="spinner"></div>
</div>
)}
<!-- 错误提示(覆盖在回退图片上) -->
<div class="error-message absolute inset-0 flex items-center justify-center z-20 hidden pointer-events-none">
<span class="text-white text-sm px-3 py-1 rounded bg-black/50">Image API Error</span>
</div>
<!-- 回退图片(错误时显示) -->
{fallbackImg && (
<div class="fallback-image absolute inset-0 hidden">
<Picture
src={fallbackImg}
alt="Fallback cover"
class={imageClass}
style={imageStyle}
width={preview ? 400 : 1200}
height={preview ? 300 : 800}
loading="lazy"
formats={formats}
fallbackFormat={fallbackFormat}
quality={quality}
widths={widths}
sizes={sizes}
layout={layout}
/>
</div>
)}
<!-- 本地图片 -->
{isLocal && img && (
<Picture
src={img}
alt={alt || ""}
class={imageClass}
style={imageStyle}
width={preview ? 400 : 1200}
height={preview ? 300 : 800}
loading={loading}
formats={formats}
fallbackFormat={fallbackFormat}
quality={quality}
widths={widths}
sizes={sizes}
layout={layout}
data-cover-img
/>
)}
<!-- 远程图片 -->
{!isLocal && (
<img
src={isPublic ? url(src) : src}
alt={alt || ""}
class={imageClass}
style={imageStyle}
loading={loading}
decoding="async"
data-cover-img
data-remote="true"
/>
)}
</div>
<style>
.cover-image-container {
min-height: 150px;
}
@media (min-width: 768px) {
.cover-image-container {
min-height: 0;
}
}
/* 加载动画容器 */
.loading-spinner {
transition: opacity 0.3s ease-out;
}
/* 加载完成后隐藏 */
.cover-image-container[data-loading="false"] .loading-spinner {
opacity: 0;
pointer-events: none;
}
/* 错误状态隐藏加载动画 */
.cover-image-container[data-error="true"] .loading-spinner {
display: none;
}
/* 错误状态显示错误信息和回退图片 */
.cover-image-container[data-error="true"] .error-message {
display: flex;
}
.cover-image-container[data-error="true"] .fallback-image {
display: block;
}
/* 错误状态隐藏失败的远程图片 */
.cover-image-container[data-error="true"] img[data-remote="true"] {
display: none;
}
/* 旋转加载动画 */
.spinner {
width: 40px;
height: 40px;
border: 3px solid oklch(0.9 0.05 var(--hue));
border-top-color: oklch(0.6 0.15 var(--hue));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 图片样式 */
.cover-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
<script>
function initCoverImages() {
// 处理所有封面图容器,无论是否显示加载动画
const containers = document.querySelectorAll('.cover-image-container');
containers.forEach((container) => {
// 跳过已处理过的容器
if (container.hasAttribute('data-initialized')) return;
container.setAttribute('data-initialized', 'true');
const img = container.querySelector('img[data-cover-img]') as HTMLImageElement | null;
if (!img) return;
const hideLoading = () => {
container.setAttribute('data-loading', 'false');
};
const showError = () => {
container.setAttribute('data-loading', 'false');
container.setAttribute('data-error', 'true');
};
if (img.complete) {
if (img.naturalWidth > 0) {
hideLoading();
} else if (img.dataset.remote === 'true') {
showError();
}
} else {
img.addEventListener('load', hideLoading, { once: true });
img.addEventListener('error', () => {
if (img.dataset.remote === 'true') {
showError();
}
}, { once: true });
}
});
}
// 初始化
initCoverImages();
// 支持 Swup 等页面切换
document.addEventListener('astro:page-load', initCoverImages);
</script>