115 lines
4.7 KiB
Vue
115 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
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'
|
|
import RevealImage from '../../../components/ui/RevealImage.vue'
|
|
|
|
/** 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">
|
|
<RevealImage v-if="url && !imageFailed" :src="url" :alt="label" @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>
|
|
|
|
<style>
|
|
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
|
@reference "../../../styles/styles.css";
|
|
.identity-thumbnail {
|
|
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
|
|
}
|
|
.identity-thumbnail .n-image,
|
|
.identity-thumbnail .n-image img,
|
|
.identity-thumbnail .reveal-image {
|
|
@apply w-full h-full;
|
|
}
|
|
.identity-thumbnail-placeholder {
|
|
@apply flex flex-col items-center justify-center w-full h-full gap-[5px] text-muted text-[10px];
|
|
}
|
|
</style>
|