feat: 主体目录增加母版缩略图与紧凑图文布局
This commit is contained in:
@@ -95,6 +95,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
||||||
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
|
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
|
||||||
- 侧栏导航与主体/剧集/镜头目录采用直角满行背景,移除列表两侧的内缩,悬停和选中态都铺满所在列。条目内部保留文字间距;窄屏横向目录仍保持独立滚动,不影响表单与弹窗的圆角。
|
- 侧栏导航与主体/剧集/镜头目录采用直角满行背景,移除列表两侧的内缩,悬停和选中态都铺满所在列。条目内部保留文字间距;窄屏横向目录仍保持独立滚动,不影响表单与弹窗的圆角。
|
||||||
|
- 主体目录以母版缩略图搭配名称、编号与状态,默认保留行背景和细分隔线。人物优先复用选角接口的母版;缺少地址的条目进入可视区域后才读取正式身份图库,不为整份目录轮询。无母版或加载失败显示人物/场景/道具图标,候选与形态图不会替代母版;点击缩略图只切换主体,刷新目录可重试读取。
|
||||||
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
|
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
|
||||||
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
||||||
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
||||||
|
|||||||
@@ -494,6 +494,74 @@ body {
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* 可选前导内容用于主体母版,不改变剧集与镜头目录的原有排版。 */
|
||||||
|
.n-button.directory-item.has-leading .n-button__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.directory-item-leading {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.directory-item.has-leading .directory-item-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.identity-subject-list-content {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 0 8px;
|
||||||
|
}
|
||||||
|
.n-button.identity-subject-item {
|
||||||
|
min-height: 86px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
.n-button.identity-subject-item:not(.selected) {
|
||||||
|
background: var(--app-surface);
|
||||||
|
}
|
||||||
|
.n-button.identity-subject-item:not(.selected):hover {
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
.identity-subject-item .directory-item-body {
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.identity-subject-item .directory-item-title {
|
||||||
|
order: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.identity-subject-item .directory-item-eyebrow {
|
||||||
|
order: 1;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.identity-subject-item .directory-item-meta {
|
||||||
|
order: 2;
|
||||||
|
gap: 4px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.identity-thumbnail {
|
||||||
|
display: flex;
|
||||||
|
width: 48px;
|
||||||
|
height: 64px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
.identity-thumbnail .n-image,
|
||||||
|
.identity-thumbnail .n-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.identity-thumbnail-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
.directory-item-eyebrow {
|
.directory-item-eyebrow {
|
||||||
font:
|
font:
|
||||||
11px/1.5 ui-monospace,
|
11px/1.5 ui-monospace,
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ let wrapper: VueWrapper | undefined
|
|||||||
afterEach(() => wrapper?.unmount())
|
afterEach(() => wrapper?.unmount())
|
||||||
|
|
||||||
describe('统一目录条目', () => {
|
describe('统一目录条目', () => {
|
||||||
|
it('主体缩略图使用可选前导槽位,与标题一起保留满行点击区域', () => {
|
||||||
|
wrapper = mount(DirectoryItem, {
|
||||||
|
props: { active: true },
|
||||||
|
slots: { leading: '<span>母版缩略图</span>', default: '林默', eyebrow: '@CH0001' }
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('has-leading')
|
||||||
|
expect(wrapper.get('.directory-item-leading').text()).toBe('母版缩略图')
|
||||||
|
expect(wrapper.get('.directory-item-title').text()).toBe('林默')
|
||||||
|
expect(wrapper.get('.directory-item-leading').element.closest('button')).toBe(wrapper.element)
|
||||||
|
})
|
||||||
|
|
||||||
it('编号、长标题和多项状态各占独立层级,沿用 Naive 按钮', () => {
|
it('编号、长标题和多项状态各占独立层级,沿用 Naive 按钮', () => {
|
||||||
const title = '雨夜霓虹街全景与记忆当铺外的人群和闪烁灯牌'
|
const title = '雨夜霓虹街全景与记忆当铺外的人群和闪烁灯牌'
|
||||||
wrapper = mount(DirectoryItem, {
|
wrapper = mount(DirectoryItem, {
|
||||||
@@ -48,6 +59,10 @@ describe('统一目录条目', () => {
|
|||||||
expect(css).toMatch(/\.directory-item-title\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
expect(css).toMatch(/\.directory-item-title\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
||||||
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
|
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
|
||||||
expect(css).toContain('grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr)')
|
expect(css).toContain('grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr)')
|
||||||
|
expect(css).toMatch(/\.identity-subject-list-content\s*\{[^}]*gap:\s*0;[^}]*padding:\s*0 0 8px/)
|
||||||
|
expect(css).toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*min-height:\s*86px;[^}]*border-bottom:/)
|
||||||
|
expect(css).toMatch(/\.identity-thumbnail\s*\{[^}]*width:\s*48px;[^}]*height:\s*64px/)
|
||||||
|
expect(css).toMatch(/\.n-button\.identity-subject-item:not\(\.selected\)\s*\{[^}]*var\(--app-surface\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('侧栏与主体、镜头、剧集目录统一直角满行,保留文字内边距和移动端滚动', () => {
|
it('侧栏与主体、镜头、剧集目录统一直角满行,保留文字内边距和移动端滚动', () => {
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ defineProps<{ active: boolean }>()
|
|||||||
quaternary
|
quaternary
|
||||||
block
|
block
|
||||||
class="directory-item"
|
class="directory-item"
|
||||||
:class="{ selected: active }"
|
:class="{ selected: active, 'has-leading': !!$slots.leading }"
|
||||||
:aria-current="active ? 'true' : undefined"
|
:aria-current="active ? 'true' : undefined"
|
||||||
>
|
>
|
||||||
|
<span v-if="$slots.leading" class="directory-item-leading"><slot name="leading" /></span>
|
||||||
<span class="directory-item-body">
|
<span class="directory-item-body">
|
||||||
<span v-if="$slots.eyebrow" class="directory-item-eyebrow"><slot name="eyebrow" /></span>
|
<span v-if="$slots.eyebrow" class="directory-item-eyebrow"><slot name="eyebrow" /></span>
|
||||||
<span class="directory-item-title"><slot /></span>
|
<span class="directory-item-title"><slot /></span>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import IdentityEditor from './components/IdentityEditor.vue'
|
|||||||
import IdentityGallery from './components/IdentityGallery.vue'
|
import IdentityGallery from './components/IdentityGallery.vue'
|
||||||
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||||
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
||||||
|
import IdentityThumbnail from './components/IdentityThumbnail.vue'
|
||||||
|
import type { IdentitySubject } from './types'
|
||||||
|
|
||||||
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
|
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -71,10 +73,21 @@ const imageOpen = ref(false)
|
|||||||
const castingOpen = ref(false)
|
const castingOpen = ref(false)
|
||||||
const pendingSelection = ref('')
|
const pendingSelection = ref('')
|
||||||
const anchor = computed(() => currentAnchor(images.value))
|
const anchor = computed(() => currentAnchor(images.value))
|
||||||
|
const thumbnailRefreshKey = ref(0)
|
||||||
const casting = computed(() => {
|
const casting = computed(() => {
|
||||||
const value = castingQuery.data.value
|
const value = castingQuery.data.value
|
||||||
return value && Array.isArray(value.items) ? value : null
|
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(() =>
|
const filtered = computed(() =>
|
||||||
subjects.value.filter(
|
subjects.value.filter(
|
||||||
item =>
|
item =>
|
||||||
@@ -121,6 +134,7 @@ function exportReceipt() {
|
|||||||
|
|
||||||
/** 目录刷新同时读取正式形态和 Character Casting,避免无形态角色状态滞后。 */
|
/** 目录刷新同时读取正式形态和 Character Casting,避免无形态角色状态滞后。 */
|
||||||
function refreshDirectory() {
|
function refreshDirectory() {
|
||||||
|
thumbnailRefreshKey.value++
|
||||||
void Promise.all([catalog.refresh(), castingQuery.refresh()])
|
void Promise.all([catalog.refresh(), castingQuery.refresh()])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,16 +452,22 @@ watch(
|
|||||||
@click="select(item.id)"
|
@click="select(item.id)"
|
||||||
class="identity-subject-item"
|
class="identity-subject-item"
|
||||||
>{{ item.name }}
|
>{{ 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 #eyebrow>{{ item.ref }}</template>
|
||||||
<template #meta>
|
<template #meta>
|
||||||
<span>{{ item.forms.length }} 个形态</span
|
<span>{{ item.forms.length }} 个形态</span
|
||||||
><span
|
><span v-if="castingBySubject.has(item.id)" class="directory-status">
|
||||||
v-if="casting?.items.find(row => row.subjectId === item.id)"
|
{{ castingStatusLabel(castingBySubject.get(item.id)!.status) }}</span
|
||||||
class="directory-status"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
|
|
||||||
}}</span
|
|
||||||
>
|
>
|
||||||
</template></DirectoryItem
|
</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('母版暂不可用')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -220,6 +220,74 @@ describe('内容级滚动下的生产反馈', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
|
||||||
const form = formFixture()
|
const form = formFixture()
|
||||||
const second = {
|
const second = {
|
||||||
|
|||||||
Reference in New Issue
Block a user