You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
275 lines
7.8 KiB
275 lines
7.8 KiB
<template>
|
|
<div ref="rootRef" :class="['dynamic-image', { loading: isLoading, error: hasError, clickable }]">
|
|
<img :src="currentSrc" :alt="alt" :class="imageClass" :style="imageStyle" @load="handleLoad" @error="handleError"
|
|
@click="handleClick" v-bind="attrs" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, useAttrs } from 'vue'
|
|
import imageService from '@/utils/imageService'
|
|
import errorImg from '@/assets/images/error2.png'
|
|
import loadingImg from '@/assets/images/loading2.gif'
|
|
|
|
// ── inheritAttrs ────────────────────────────────────
|
|
defineOptions({ inheritAttrs: false })
|
|
|
|
// ── Props ───────────────────────────────────────────
|
|
const props = defineProps({
|
|
imageKey: { type: String, default: '' },
|
|
fallback: { type: String, default: '' },
|
|
alt: { type: String, default: '' },
|
|
useCache: { type: Boolean, default: true },
|
|
lazy: { type: Boolean, default: false },
|
|
placeholder: { type: String, default: '' },
|
|
loading: { type: String, default: '' },
|
|
preload: { type: Boolean, default: false },
|
|
timeout: { type: Number, default: 10000 },
|
|
retryCount: { type: Number, default: 1 },
|
|
imageClass: { type: [String, Array, Object], default: '' },
|
|
imageStyle: { type: [String, Object], default: '' },
|
|
clickable: { type: Boolean, default: false },
|
|
})
|
|
|
|
// ── Emits ───────────────────────────────────────────
|
|
const emit = defineEmits(['load', 'error', 'click'])
|
|
|
|
// ── Attrs ───────────────────────────────────────────
|
|
const attrs = useAttrs()
|
|
|
|
// ── 模板引用 ────────────────────────────────────────
|
|
const rootRef = ref(null)
|
|
|
|
// ── 响应式状态 ──────────────────────────────────────
|
|
const currentSrc = ref(getInitialSrc())
|
|
const isLoading = ref(true)
|
|
const hasError = ref(false)
|
|
const isIntersecting = ref(false)
|
|
|
|
// IntersectionObserver 实例
|
|
let observer = null
|
|
|
|
// ── 计算属性 ────────────────────────────────────────
|
|
const loadOptions = computed(() => ({
|
|
useCache: props.useCache,
|
|
fallback: props.fallback || errorImg,
|
|
timeout: props.timeout,
|
|
retryCount: props.retryCount,
|
|
}))
|
|
|
|
// ── 辅助函数 ────────────────────────────────────────
|
|
function getInitialSrc() {
|
|
if (props.lazy) {
|
|
return props.placeholder || props.loading || loadingImg
|
|
}
|
|
return props.loading || loadingImg
|
|
}
|
|
|
|
// ── 懒加载 ──────────────────────────────────────────
|
|
function setupLazyLoading() {
|
|
if (!window.IntersectionObserver) {
|
|
loadImage()
|
|
return
|
|
}
|
|
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
isIntersecting.value = true
|
|
loadImage()
|
|
observer.unobserve(rootRef.value)
|
|
}
|
|
})
|
|
},
|
|
{ rootMargin: '50rem' },
|
|
)
|
|
|
|
nextTick(() => {
|
|
if (rootRef.value) {
|
|
observer.observe(rootRef.value)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── 图片加载 ────────────────────────────────────────
|
|
async function loadImage() {
|
|
if (!props.imageKey) {
|
|
handleError(new Error('No image key provided'))
|
|
return
|
|
}
|
|
|
|
isLoading.value = true
|
|
hasError.value = false
|
|
|
|
try {
|
|
const imageUrl = await imageService.getImageByKey(props.imageKey, loadOptions.value)
|
|
if (
|
|
imageUrl &&
|
|
(imageUrl.startsWith('data:') ||
|
|
imageUrl.startsWith('blob:') ||
|
|
imageUrl.startsWith('http'))
|
|
) {
|
|
currentSrc.value = imageUrl
|
|
isLoading.value = false
|
|
emit('load', { src: imageUrl, key: props.imageKey })
|
|
} else {
|
|
throw new Error('Invalid image URL format')
|
|
}
|
|
} catch (error) {
|
|
console.error('DynamicImage: 加载图片失败:', error)
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
// ── 事件处理 ────────────────────────────────────────
|
|
function handleLoad(event) {
|
|
isLoading.value = false
|
|
hasError.value = false
|
|
emit('load', { event, src: currentSrc.value, key: props.imageKey })
|
|
}
|
|
|
|
function handleError(error) {
|
|
isLoading.value = false
|
|
hasError.value = true
|
|
|
|
const fallbackSrc = props.fallback || errorImg
|
|
currentSrc.value = fallbackSrc
|
|
|
|
emit('error', { error, key: props.imageKey, fallback: fallbackSrc })
|
|
|
|
console.warn('DynamicImage: Load failed', {
|
|
key: props.imageKey,
|
|
error: error.message || error,
|
|
})
|
|
}
|
|
|
|
function handleClick(event) {
|
|
if (props.clickable) {
|
|
emit('click', { event, src: currentSrc.value, key: props.imageKey })
|
|
}
|
|
}
|
|
|
|
// ── 重置 & 重新加载 ─────────────────────────────────
|
|
function reset() {
|
|
currentSrc.value = getInitialSrc()
|
|
isLoading.value = true
|
|
hasError.value = false
|
|
isIntersecting.value = false
|
|
}
|
|
|
|
function reload() {
|
|
reset()
|
|
loadImage()
|
|
}
|
|
|
|
// ── 清理 ────────────────────────────────────────────
|
|
function cleanup() {
|
|
if (observer) {
|
|
observer.disconnect()
|
|
observer = null
|
|
}
|
|
imageService.revokeImageUrl(currentSrc.value)
|
|
}
|
|
|
|
// ── 生命周期 ────────────────────────────────────────
|
|
onMounted(() => {
|
|
if (props.lazy) {
|
|
setupLazyLoading()
|
|
} else {
|
|
loadImage()
|
|
}
|
|
|
|
if (props.preload && props.imageKey) {
|
|
imageService.preloadImages([props.imageKey], loadOptions.value)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
cleanup()
|
|
})
|
|
|
|
// ── 监听 imageKey 变化 ──────────────────────────────
|
|
watch(
|
|
() => props.imageKey,
|
|
(newKey, oldKey) => {
|
|
if (newKey !== oldKey) {
|
|
reset()
|
|
if (props.lazy) {
|
|
if (isIntersecting.value) {
|
|
loadImage()
|
|
}
|
|
} else {
|
|
loadImage()
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// ── 调试日志(currentSrc 变化时)─────────────────────
|
|
watch(currentSrc, (newSrc, oldSrc) => {
|
|
// console.log('DynamicImage: currentSrc changed', {
|
|
// imageKey: props.imageKey,
|
|
// oldSrc: oldSrc?.substring(0, 50),
|
|
// newSrc: newSrc?.substring(0, 50),
|
|
// srcLength: newSrc?.length,
|
|
// })
|
|
})
|
|
|
|
// ── 暴露方法给父组件 ────────────────────────────────
|
|
defineExpose({ reload, reset })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dynamic-image {
|
|
position: relative;
|
|
display: inline-block;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
}
|
|
|
|
.dynamic-image img {
|
|
display: block;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
width: auto;
|
|
height: auto;
|
|
border-radius: inherit;
|
|
}
|
|
|
|
.dynamic-image.loading {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.dynamic-image.error {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.dynamic-image.clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.dynamic-image.loading::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 20rem;
|
|
height: 20rem;
|
|
margin: -10rem 0 0 -10rem;
|
|
border: 2rem solid #f3f3f3;
|
|
border-top: 2rem solid #409eff;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% {
|
|
transform: rotate(0deg);
|
|
}
|
|
|
|
100% {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style> |