feat: 主体目录增加母版缩略图与紧凑图文布局

This commit is contained in:
GouJ
2026-09-01 19:34:59 +08:00
parent ad37c8ab7c
commit 9e3bff7997
8 changed files with 470 additions and 8 deletions
@@ -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>