Files
short-drama-agent-front/src/features/subject-identity/components/IdentityThumbnail.vue
T

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>