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
@@ -25,6 +25,8 @@ import IdentityEditor from './components/IdentityEditor.vue'
import IdentityGallery from './components/IdentityGallery.vue'
import IdentityImageDialog from './components/IdentityImageDialog.vue'
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
import IdentityThumbnail from './components/IdentityThumbnail.vue'
import type { IdentitySubject } from './types'
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
const route = useRoute()
@@ -71,10 +73,21 @@ const imageOpen = ref(false)
const castingOpen = ref(false)
const pendingSelection = ref('')
const anchor = computed(() => currentAnchor(images.value))
const thumbnailRefreshKey = ref(0)
const casting = computed(() => {
const value = castingQuery.data.value
return value && Array.isArray(value.items) ? value : null
})
const castingBySubject = computed(() => new Map(casting.value?.items.map(item => [item.subjectId, item]) ?? []))
/** 详情与选角数据已有母版地址时直接复用,避免对每个角色重复查询。 */
function thumbnailSource(item: IdentitySubject): string | null | undefined {
if (item.id === selectedId.value && detail.data.value) return anchor.value?.imageUrl ?? null
const row = castingBySubject.value.get(item.id)
if (row) return row.anchorImageUrl || (row.anchorImageId ? undefined : null)
if (item.id === selectedId.value) return null
return undefined
}
const filtered = computed(() =>
subjects.value.filter(
item =>
@@ -121,6 +134,7 @@ function exportReceipt() {
/** 目录刷新同时读取正式形态和 Character Casting,避免无形态角色状态滞后。 */
function refreshDirectory() {
thumbnailRefreshKey.value++
void Promise.all([catalog.refresh(), castingQuery.refresh()])
}
@@ -438,16 +452,22 @@ watch(
@click="select(item.id)"
class="identity-subject-item"
>{{ item.name }}
<template #leading>
<IdentityThumbnail
:project-id="projectId"
:subject-id="item.id"
:name="item.name"
:module="item.module"
:identity-id="item.identity === null ? null : item.identity?.id"
:source="thumbnailSource(item)"
:refresh-key="thumbnailRefreshKey"
/>
</template>
<template #eyebrow>{{ item.ref }}</template>
<template #meta>
<span>{{ item.forms.length }} 个形态</span
><span
v-if="casting?.items.find(row => row.subjectId === item.id)"
class="directory-status"
>
{{
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
}}</span
><span v-if="castingBySubject.has(item.id)" class="directory-status">
{{ castingStatusLabel(castingBySubject.get(item.id)!.status) }}</span
>
</template></DirectoryItem
>
@@ -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>
@@ -0,0 +1,182 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NImage } from 'naive-ui'
import IdentityThumbnail from './components/IdentityThumbnail.vue'
import { identityFixture, identityImageFixture } from './testing/fixtures'
let wrapper: VueWrapper | undefined
let observers: {
callback: IntersectionObserverCallback
observe: ReturnType<typeof vi.fn<(element: Element) => void>>
disconnect: ReturnType<typeof vi.fn<() => void>>
}[] = []
const props = {
projectId: 'project',
subjectId: 'subject-db-1',
name: '书店',
module: 'scene',
identityId: 'identity-db-1',
refreshKey: 0
}
beforeEach(() => {
observers = []
vi.stubGlobal(
'IntersectionObserver',
class {
observe = vi.fn<(element: Element) => void>()
disconnect = vi.fn<() => void>()
unobserve = vi.fn<(element: Element) => void>()
constructor(callback: IntersectionObserverCallback) {
observers.push({ callback, observe: this.observe, disconnect: this.disconnect })
}
}
)
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 仅触发主体缩略图的可见事件,不触发 NImage 内部图片懒加载观察器。 */
async function reveal() {
const observer = observers.find(item => item.observe.mock.calls.some(([element]) => element === wrapper!.element))!
observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
await flushPromises()
expect(observer.disconnect).toHaveBeenCalled()
}
/** 测试接口返回独立 Response,读取图片元数据不消耗生成额度。 */
function response(data: unknown) {
return new Response(JSON.stringify({ data }))
}
describe('主体目录母版缩略图', () => {
it.each([
['character', '人物'],
['scene', '场景'],
['prop', '道具']
])('没有母版的 %s 显示类型占位,不补查或生成图片', async (module, label) => {
const fetcher = vi.fn<typeof fetch>()
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props: { ...props, module, source: null } })
await reveal()
expect(wrapper.text()).toBe(label)
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
expect(wrapper.findComponent(NImage).exists()).toBe(false)
expect(fetcher).not.toHaveBeenCalled()
})
it('直接复用目录母版地址,图片不抢占选择点击,加载失败显示默认占位', async () => {
const fetcher = vi.fn<typeof fetch>()
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, {
attachTo: document.body,
props: { ...props, source: '/storage/anchor.png' }
})
await reveal()
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
await wrapper.get('img').trigger('click')
expect(document.querySelector('.n-image-preview-container')).toBeNull()
await wrapper.get('img').trigger('error')
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
await wrapper.setProps({ source: '/storage/new-anchor.png' })
expect(wrapper.get('img').attributes('src')).toContain('/storage/new-anchor.png')
expect(fetcher).not.toHaveBeenCalled()
})
it('进入可视区域后才 GET 图库,仅选择权威母版,刷新时重新读取', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
response([
identityImageFixture({
id: 'candidate',
isAnchor: false,
enabled: false,
imageUrl: '/storage/candidate.png'
}),
identityImageFixture({
id: 'front',
isAnchor: false,
viewType: 'front',
imageUrl: '/storage/front.png'
}),
identityImageFixture({ imageUrl: '/storage/anchor.png' })
])
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props })
await flushPromises()
expect(fetcher).not.toHaveBeenCalled()
await reveal()
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subjects/subject-db-1/identity/images')
expect(wrapper.get('img').attributes('src')).toContain('/storage/anchor.png')
await wrapper.setProps({ refreshKey: 1 })
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('目录未附带身份摘要时先校验正式身份,再读取母版', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async url =>
response(String(url).endsWith('/images') ? [identityImageFixture()] : identityFixture())
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props: { ...props, identityId: undefined } })
await reveal()
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/subjects/subject-db-1/identity',
'/api/subjects/subject-db-1/identity/images'
])
expect(wrapper.find('img').exists()).toBe(true)
})
it('切项目中止旧请求,即使迟到也不能覆盖新主体占位', async () => {
let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props })
await reveal()
await wrapper.setProps({
projectId: 'other-project',
subjectId: 'other-subject',
name: '另一主体',
source: null
})
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
finish(response([identityImageFixture()]))
await flushPromises()
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.get('[role="img"]').attributes('aria-label')).toBe('另一主体 · 暂无母版')
})
it('候选与辅助视角不能冒充母版,跨身份图片返回错误占位', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(response([identityImageFixture({ isAnchor: false })]))
.mockResolvedValueOnce(response([identityImageFixture({ identityId: 'wrong-identity' })]))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props })
await reveal()
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
await wrapper.setProps({ refreshKey: 1 })
await flushPromises()
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
expect(wrapper.find('img').exists()).toBe(false)
})
it('危险地址不写入图片,无效图片也不会触发生图', () => {
wrapper = mount(IdentityThumbnail, { props: { ...props, source: 'javascript:alert(1)' } })
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
})
})
+68
View File
@@ -220,6 +220,74 @@ describe('内容级滚动下的生产反馈', () => {
})
describe('视觉风格与主体身份工作区', () => {
it('主体目录显示已确认母版和默认占位,点缩略图只切换主体,不重复读取所有角色图库', async () => {
const first = formFixture()
const forms = [
first,
...[2, 3].map(index => ({
...first,
id: `form-${index}`,
subjectId: `subject-${index}`,
images: [],
subject: { ...first.subject, id: `subject-${index}`, ref: `@CH000${index}`, name: `角色${index}` }
}))
]
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse(forms)
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
if (path.endsWith('/character-casting/readiness'))
return jsonResponse({
total: 3,
ready: 2,
missingIdentity: 0,
missingAnchor: 1,
candidatePending: 0,
unlocked: 0,
locked: 2,
items: forms.map((form, index) => ({
subjectId: form.subjectId,
subjectRef: form.subject.ref,
subjectName: form.subject.name,
status: index === 2 ? 'missing_anchor' : 'ready',
isLocked: index !== 2,
anchorImageId: index === 2 ? undefined : `anchor-${index}`,
anchorImageUrl: index === 2 ? undefined : `/storage/anchor-${index}.png`,
candidateImages: [{ id: 'candidate', imageUrl: '/storage/candidate.png' }]
}))
})
const second = path.includes('/subject-2/')
if (path.endsWith('/identity/images'))
return jsonResponse([
identityImageFixture({
identityId: second ? 'identity-2' : 'identity-db-1',
imageUrl: `/storage/anchor-${second ? 1 : 0}.png`
})
])
return jsonResponse(
identityFixture({
subjectId: second ? 'subject-2' : first.subjectId,
id: second ? 'identity-2' : 'identity-db-1'
})
)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
const items = wrapper!.findAll('.identity-subject-item')
expect(items).toHaveLength(3)
expect(items[0]!.get('.identity-thumbnail img').attributes('src')).toContain('/storage/anchor-0.png')
expect(items[1]!.get('.identity-thumbnail img').attributes('src')).toContain('/storage/anchor-1.png')
expect(items[2]!.get('.identity-thumbnail [role="img"]').attributes('aria-label')).toContain('暂无母版')
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/identity/images'))).toHaveLength(1)
await items[1]!.get('.identity-thumbnail img').trigger('click')
await flushPromises()
expect(items[1]!.attributes('aria-pressed')).toBe('true')
expect(document.querySelector('.n-image-preview-container')).toBeNull()
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
const form = formFixture()
const second = {