From 9e3bff7997f780de42e28550c26b6d4990853d2f Mon Sep 17 00:00:00 2001 From: GouJ Date: Tue, 1 Sep 2026 19:34:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BB=E4=BD=93=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=AF=8D=E7=89=88=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?=E4=B8=8E=E7=B4=A7=E5=87=91=E5=9B=BE=E6=96=87=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + src/admin.css | 68 +++++++ src/components/ui/DirectoryItem.test.ts | 15 ++ src/components/ui/DirectoryItem.vue | 3 +- .../subject-identity/SubjectIdentityPage.vue | 34 +++- .../components/IdentityThumbnail.vue | 107 ++++++++++ .../subject-identity/thumbnail.test.ts | 182 ++++++++++++++++++ src/features/workflows/pages.test.ts | 68 +++++++ 8 files changed, 470 insertions(+), 8 deletions(-) create mode 100644 src/features/subject-identity/components/IdentityThumbnail.vue create mode 100644 src/features/subject-identity/thumbnail.test.ts diff --git a/README.md b/README.md index dfd02c2..cc69a65 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S - 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。 - 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。 - 侧栏导航与主体/剧集/镜头目录采用直角满行背景,移除列表两侧的内缩,悬停和选中态都铺满所在列。条目内部保留文字间距;窄屏横向目录仍保持独立滚动,不影响表单与弹窗的圆角。 +- 主体目录以母版缩略图搭配名称、编号与状态,默认保留行背景和细分隔线。人物优先复用选角接口的母版;缺少地址的条目进入可视区域后才读取正式身份图库,不为整份目录轮询。无母版或加载失败显示人物/场景/道具图标,候选与形态图不会替代母版;点击缩略图只切换主体,刷新目录可重试读取。 - 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。 - 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。 - 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。 diff --git a/src/admin.css b/src/admin.css index 461e381..e1a8232 100644 --- a/src/admin.css +++ b/src/admin.css @@ -494,6 +494,74 @@ body { gap: 7px; 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 { font: 11px/1.5 ui-monospace, diff --git a/src/components/ui/DirectoryItem.test.ts b/src/components/ui/DirectoryItem.test.ts index 3da6e56..50d7b51 100644 --- a/src/components/ui/DirectoryItem.test.ts +++ b/src/components/ui/DirectoryItem.test.ts @@ -8,6 +8,17 @@ let wrapper: VueWrapper | undefined afterEach(() => wrapper?.unmount()) describe('统一目录条目', () => { + it('主体缩略图使用可选前导槽位,与标题一起保留满行点击区域', () => { + wrapper = mount(DirectoryItem, { + props: { active: true }, + slots: { leading: '母版缩略图', 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 按钮', () => { const title = '雨夜霓虹街全景与记忆当铺外的人群和闪烁灯牌' wrapper = mount(DirectoryItem, { @@ -48,6 +59,10 @@ describe('统一目录条目', () => { 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).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('侧栏与主体、镜头、剧集目录统一直角满行,保留文字内边距和移动端滚动', () => { diff --git a/src/components/ui/DirectoryItem.vue b/src/components/ui/DirectoryItem.vue index 28c332e..1690a0b 100644 --- a/src/components/ui/DirectoryItem.vue +++ b/src/components/ui/DirectoryItem.vue @@ -10,9 +10,10 @@ defineProps<{ active: boolean }>() quaternary block class="directory-item" - :class="{ selected: active }" + :class="{ selected: active, 'has-leading': !!$slots.leading }" :aria-current="active ? 'true' : undefined" > + diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue index 3acc3e2..e137850 100644 --- a/src/features/subject-identity/SubjectIdentityPage.vue +++ b/src/features/subject-identity/SubjectIdentityPage.vue @@ -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 }} + diff --git a/src/features/subject-identity/components/IdentityThumbnail.vue b/src/features/subject-identity/components/IdentityThumbnail.vue new file mode 100644 index 0000000..024b929 --- /dev/null +++ b/src/features/subject-identity/components/IdentityThumbnail.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/features/subject-identity/thumbnail.test.ts b/src/features/subject-identity/thumbnail.test.ts new file mode 100644 index 0000000..c7abf97 --- /dev/null +++ b/src/features/subject-identity/thumbnail.test.ts @@ -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 void>> + disconnect: ReturnType 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() + 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() + 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().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() + .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().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() + .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('母版暂不可用') + }) +}) diff --git a/src/features/workflows/pages.test.ts b/src/features/workflows/pages.test.ts index 4c7a505..398723b 100644 --- a/src/features/workflows/pages.test.ts +++ b/src/features/workflows/pages.test.ts @@ -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().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 = {