190 lines
5.0 KiB
Vue
190 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { fetchAdminFileBlob, fetchFileBlobByURL } from '@/shared/api/files'
|
|
import { isAdminPath } from '@/shared/utils/adminPath'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
source: string
|
|
alt?: string
|
|
admin?: boolean
|
|
imageClass?: string
|
|
fallbackClass?: string
|
|
loading?: 'eager' | 'lazy'
|
|
decoding?: 'async' | 'sync' | 'auto'
|
|
previewSrcList?: string[]
|
|
previewTeleported?: boolean
|
|
previewInitialIndex?: number
|
|
fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
|
|
imageStyle?: any
|
|
}>(),
|
|
{
|
|
alt: '',
|
|
admin: false,
|
|
imageClass: '',
|
|
fallbackClass: '',
|
|
loading: 'lazy',
|
|
decoding: 'async',
|
|
previewTeleported: true,
|
|
previewInitialIndex: 0,
|
|
}
|
|
)
|
|
|
|
const imageURL = ref('')
|
|
const previewURLs = ref<string[]>([])
|
|
const failed = ref(false)
|
|
const createdURLs: string[] = []
|
|
|
|
const maxConcurrentAuthImageLoads = 6
|
|
let activeAuthImageLoads = 0
|
|
const authImageLoadQueue: Array<() => void> = []
|
|
|
|
const usePreview = computed(() => !!props.previewSrcList?.length)
|
|
const effectiveAdmin = computed(() => props.admin || isAdminPath(window.location.pathname))
|
|
const fallbackText = computed(() => (failed.value ? '图片加载失败' : '图片加载中'))
|
|
const imageStyleValue = computed(() => {
|
|
if (!props.fit) return props.imageStyle
|
|
return [props.imageStyle, { objectFit: props.fit }]
|
|
})
|
|
const previewSignature = computed(() => props.previewSrcList?.join('\u0000') || '')
|
|
|
|
function withAuthImageLoadSlot<T>(task: () => Promise<T>): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const run = () => {
|
|
activeAuthImageLoads++
|
|
task()
|
|
.then(resolve, reject)
|
|
.finally(() => {
|
|
activeAuthImageLoads--
|
|
authImageLoadQueue.shift()?.()
|
|
})
|
|
}
|
|
if (activeAuthImageLoads < maxConcurrentAuthImageLoads) {
|
|
run()
|
|
return
|
|
}
|
|
authImageLoadQueue.push(run)
|
|
})
|
|
}
|
|
|
|
function extractObjectKey(value: string) {
|
|
try {
|
|
const parsed = new URL(value, window.location.origin)
|
|
return parsed.searchParams.get('key') || ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function shouldFetchWithAuth(value: string) {
|
|
return value.includes('/api/files/object') || value.includes('/api/admin/files/object')
|
|
}
|
|
|
|
function shouldFetchAsAdmin(value: string) {
|
|
return effectiveAdmin.value || value.includes('/api/admin/files/object')
|
|
}
|
|
|
|
async function resolveURL(url: string): Promise<string> {
|
|
if (!url || !shouldFetchWithAuth(url)) return url
|
|
const key = extractObjectKey(url)
|
|
const blob = await withAuthImageLoadSlot(() =>
|
|
shouldFetchAsAdmin(url) && key ? fetchAdminFileBlob(key) : fetchFileBlobByURL(url)
|
|
)
|
|
const blobURL = URL.createObjectURL(blob)
|
|
createdURLs.push(blobURL)
|
|
return blobURL
|
|
}
|
|
|
|
function cleanup() {
|
|
createdURLs.forEach(u => URL.revokeObjectURL(u))
|
|
createdURLs.length = 0
|
|
}
|
|
|
|
async function loadImage() {
|
|
cleanup()
|
|
imageURL.value = ''
|
|
previewURLs.value = []
|
|
failed.value = false
|
|
|
|
if (!props.source) {
|
|
failed.value = true
|
|
return
|
|
}
|
|
|
|
try {
|
|
const sourceURL = await resolveURL(props.source)
|
|
imageURL.value = sourceURL
|
|
if (props.previewSrcList?.length) {
|
|
// 先填充公开或当前已解析的主图 URL,让预览组件立即可用
|
|
const initialPreviews = props.previewSrcList.map(url =>
|
|
url === props.source ? sourceURL : url
|
|
)
|
|
previewURLs.value = initialPreviews
|
|
|
|
// 若包含需要鉴权转 Blob 的私有 URL,在后台异步静默解析,不阻塞主图呈现
|
|
const hasAuthPreviews = props.previewSrcList.some(
|
|
url => url !== props.source && shouldFetchWithAuth(url)
|
|
)
|
|
if (hasAuthPreviews) {
|
|
Promise.all(
|
|
props.previewSrcList.map(url => (url === props.source ? sourceURL : resolveURL(url)))
|
|
)
|
|
.then(urls => {
|
|
previewURLs.value = urls
|
|
})
|
|
.catch(() => {
|
|
// 预览图静默降级,不破坏主图正常显示
|
|
})
|
|
}
|
|
}
|
|
} catch {
|
|
failed.value = true
|
|
}
|
|
}
|
|
|
|
watch(() => [props.source, effectiveAdmin.value, previewSignature.value] as const, loadImage, {
|
|
immediate: true,
|
|
})
|
|
|
|
onBeforeUnmount(cleanup)
|
|
</script>
|
|
|
|
<template>
|
|
<el-image
|
|
v-if="imageURL && usePreview"
|
|
:class="imageClass"
|
|
:src="imageURL"
|
|
:alt="alt"
|
|
:fit="fit"
|
|
:style="imageStyle"
|
|
:preview-src-list="previewURLs"
|
|
:preview-teleported="previewTeleported"
|
|
:initial-index="previewInitialIndex"
|
|
/>
|
|
<img
|
|
v-else-if="imageURL"
|
|
:class="imageClass"
|
|
:src="imageURL"
|
|
:style="imageStyleValue"
|
|
:alt="alt"
|
|
:loading="loading"
|
|
:decoding="decoding"
|
|
/>
|
|
<span v-else :class="['auth-image-fallback', fallbackClass]">{{ fallbackText }}</span>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.auth-image-fallback {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 120px;
|
|
border: 1px dashed #d8dee8;
|
|
border-radius: 12px;
|
|
background: #f8fafc;
|
|
color: #94a3b8;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}
|
|
</style>
|