feat: 主体目录增加母版缩略图与紧凑图文布局
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from 'naive-ui'
|
||||
import { Box, MapPin, UserRound } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { subjectIdentityApi } from '../api'
|
||||
import { currentAnchor } from '../model'
|
||||
|
||||
/** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
subjectId: string
|
||||
name: string
|
||||
module: string
|
||||
identityId?: string | null
|
||||
source?: string | null
|
||||
refreshKey: number
|
||||
}>()
|
||||
const target = ref<HTMLElement>()
|
||||
const visible = ref(false)
|
||||
const loadedUrl = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const queryFailed = ref(false)
|
||||
const imageFailed = ref(false)
|
||||
const url = computed(() => referenceImageUrl((props.source === undefined ? loadedUrl.value : props.source) ?? ''))
|
||||
const kind = computed(() => ({ character: '人物', scene: '场景', prop: '道具' })[props.module] ?? '主体')
|
||||
const icon = computed(() => ({ character: UserRound, scene: MapPin, prop: Box })[props.module] ?? Box)
|
||||
const label = computed(() => {
|
||||
if (imageFailed.value || queryFailed.value || (props.source && !url.value)) return `${props.name} · 母版暂不可用`
|
||||
if (url.value) return `${props.name} · 身份母版`
|
||||
if (loading.value || (!visible.value && props.source === undefined)) return `${props.name} · 母版待加载`
|
||||
return `${props.name} · 暂无母版`
|
||||
})
|
||||
let observer: IntersectionObserver | undefined
|
||||
onMounted(() => {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
visible.value = true
|
||||
return
|
||||
}
|
||||
// 使用本目录的滚动容器,未进入可视区域的条目不发起额外接口请求。
|
||||
observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) return
|
||||
visible.value = true
|
||||
observer?.disconnect()
|
||||
},
|
||||
{ root: target.value?.closest('.n-scrollbar-container') ?? null }
|
||||
)
|
||||
if (target.value) observer.observe(target.value)
|
||||
})
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
watch(url, () => {
|
||||
imageFailed.value = false
|
||||
})
|
||||
|
||||
/** 仅读取正式主体的身份图库;切项目、筛选或刷新时中止旧请求,不轮询每个缩略图。 */
|
||||
watch(
|
||||
() => [props.projectId, props.subjectId, props.identityId, props.source, props.refreshKey, visible.value],
|
||||
async (_value, _oldValue, onCleanup) => {
|
||||
loadedUrl.value = null
|
||||
queryFailed.value = false
|
||||
imageFailed.value = false
|
||||
loading.value = false
|
||||
if (!visible.value || props.source !== undefined || props.identityId === null) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
loading.value = true
|
||||
try {
|
||||
let identityId = props.identityId
|
||||
if (!identityId) {
|
||||
const identity = await subjectIdentityApi.get(props.subjectId, controller.signal)
|
||||
if (controller.signal.aborted || !identity) return
|
||||
if (identity.subjectId !== props.subjectId) throw new Error('主体身份不匹配')
|
||||
identityId = identity.id
|
||||
}
|
||||
const images = await subjectIdentityApi.listImages(props.subjectId, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
if (images.some(image => image.identityId !== identityId)) throw new Error('母版图片归属不匹配')
|
||||
loadedUrl.value = currentAnchor(images)?.imageUrl ?? null
|
||||
} catch {
|
||||
if (!controller.signal.aborted) queryFailed.value = true
|
||||
} finally {
|
||||
if (!controller.signal.aborted) loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="target" class="identity-thumbnail" :title="label">
|
||||
<NImage
|
||||
v-if="url && !imageFailed"
|
||||
:src="url"
|
||||
:alt="label"
|
||||
object-fit="cover"
|
||||
preview-disabled
|
||||
lazy
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label">
|
||||
<component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" />
|
||||
<span aria-hidden="true">{{ kind }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
Reference in New Issue
Block a user