From bf19128580a8c777002baf294f7365bb760bf286 Mon Sep 17 00:00:00 2001 From: GouJ Date: Tue, 1 Sep 2026 18:26:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E4=B8=8E=E5=BD=A2=E6=80=81=E5=9B=BE=E5=BA=93=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=9B=BE=E7=89=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 | 54 +++++++- src/components/ui/layout.test.ts | 14 ++ .../components/IdentityGallery.vue | 70 ++++++---- .../subject-identity/identity.test.ts | 125 ++++++++++++++++++ .../components/ImageGalleryDialog.vue | 70 +++++----- src/features/subject-images/images.test.ts | 34 +++++ src/styles.css | 15 --- 8 files changed, 307 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 7348c6d..12e3415 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S - 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。 - 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。 - 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。 +- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。 - 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。 - 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。 - 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。 diff --git a/src/admin.css b/src/admin.css index 29b25c3..883931c 100644 --- a/src/admin.css +++ b/src/admin.css @@ -792,10 +792,24 @@ body { height: auto; overflow: hidden; } +.image-history.n-scrollbar { + width: 100%; + max-width: 100%; + min-width: 0; +} +.image-history-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px 16px; + margin-block: 16px 8px; + font-size: 12px; +} .image-history-content { display: flex; gap: 12px; - padding: 4px 4px 8px; + padding: 4px 4px 12px; width: max-content; } .code-scroll.n-scrollbar { @@ -876,6 +890,7 @@ body { width: 100%; height: 100%; min-height: 0; + min-width: 0; display: flex; justify-content: center; } @@ -884,6 +899,12 @@ body { height: 100%; object-fit: contain; } +/* 预览尺寸不随原图分辨率增长,历史缩略图也不能被按钮默认宽度覆盖。 */ +.asset-image.asset-image-preview { + height: min(42dvh, 420px); + min-height: 0; + aspect-ratio: auto; +} .n-button__content { gap: 6px; } @@ -891,15 +912,46 @@ body { opacity: 1; } .n-button.image-history-item { + width: 128px; + min-width: 128px; + max-width: 128px; + flex: 0 0 128px; height: auto; + padding: 6px; white-space: normal; } +.n-button.image-history-item.selected { + background: var(--app-subtle); + box-shadow: inset 0 0 0 2px var(--app-ink); +} .n-button.image-history-item .n-button__content { display: block; width: 100%; text-align: left; overflow: hidden; } +.image-history-item .asset-image { + height: 88px; + min-height: 0; + aspect-ratio: auto; +} +.image-history-label { + display: block; + margin-top: 6px; + font-size: 11px; + line-height: 1.5; + overflow-wrap: anywhere; +} +.image-history-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + color: var(--app-muted); + font-size: 10px; + line-height: 1.5; +} /* 选角进度按抽屉实际宽度排列;按钮尺寸显式覆盖组件默认值。 */ .casting-list { display: grid; diff --git a/src/components/ui/layout.test.ts b/src/components/ui/layout.test.ts index f830d13..3e7c05b 100644 --- a/src/components/ui/layout.test.ts +++ b/src/components/ui/layout.test.ts @@ -32,6 +32,20 @@ describe('管理后台组件边界', () => { const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8') expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/) }) + it('历史缩略图尺寸在非分层样式中覆盖 Naive 按钮,原图不能撑大预览或横向条目', () => { + // happy-dom 不计算几何;专门保护此前失效的层叠和固定尺寸约束。 + const css = readFileSync('src/admin.css', 'utf8') + expect(css).not.toContain('@layer') + expect(css).toMatch( + /\.n-button\.image-history-item\s*\{[^}]*width:\s*128px;[^}]*min-width:\s*128px;[^}]*max-width:\s*128px;[^}]*flex:\s*0 0 128px;[^}]*padding:\s*6px/ + ) + expect(css).toMatch(/\.image-history-item \.asset-image\s*\{[^}]*height:\s*88px;[^}]*min-height:\s*0/) + expect(css).toMatch( + /\.asset-image\.asset-image-preview\s*\{[^}]*height:\s*min\(42dvh, 420px\);[^}]*aspect-ratio:\s*auto/ + ) + expect(css).toMatch(/\.image-history\.n-scrollbar\s*\{[^}]*max-width:\s*100%;[^}]*min-width:\s*0/) + expect(css).toMatch(/\.image-history-content\s*\{[^}]*display:\s*flex;[^}]*width:\s*max-content/) + }) it('选角面板保留说明间距,卡片按可用宽度排列并覆盖按钮默认高度', () => { // 保护间距与换行契约,真实宽高和暗色效果仍需浏览器验收。 const css = readFileSync('src/admin.css', 'utf8') diff --git a/src/features/subject-identity/components/IdentityGallery.vue b/src/features/subject-identity/components/IdentityGallery.vue index b5a3c4c..10482b3 100644 --- a/src/features/subject-identity/components/IdentityGallery.vue +++ b/src/features/subject-identity/components/IdentityGallery.vue @@ -60,11 +60,53 @@ function chooseAnchor() {

尚无身份参考图。先保存身份提示词,再生成第一张母版。

diff --git a/src/features/subject-identity/identity.test.ts b/src/features/subject-identity/identity.test.ts index eed05c2..a7bad3c 100644 --- a/src/features/subject-identity/identity.test.ts +++ b/src/features/subject-identity/identity.test.ts @@ -1,5 +1,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' +import { NScrollbar } from 'naive-ui' +import { AssetImage } from '../../components/ui' import IdentityImageDialog from './components/IdentityImageDialog.vue' import IdentityGallery from './components/IdentityGallery.vue' import CastingCandidateDialog from './components/CastingCandidateDialog.vue' @@ -43,6 +45,129 @@ function input(selector: string, value: string) { } describe('身份图与母版契约', () => { + it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => { + const images = [ + identityImageFixture({ + id: 'candidate', + imageUrl: '/storage/candidate.png', + isAnchor: false, + enabled: false + }), + identityImageFixture({ + id: 'failed', + status: 'failed', + imageUrl: null, + isAnchor: false, + enabled: false, + error: '图片模型超时' + }), + identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }), + identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }), + identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }), + identityImageFixture({ + id: 'generating', + status: 'generating', + imageUrl: null, + isAnchor: false, + enabled: false + }) + ] + wrapper = mount(IdentityGallery, { + attachTo: document.body, + props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images } + }) + const history = wrapper.get('[aria-label="身份图片历史"]') + expect(history.findAll('.image-history-item')).toHaveLength(6) + expect(wrapper.getComponent(NScrollbar).props()).toMatchObject({ + xScrollable: true, + trigger: 'none', + contentStyle: { width: 'max-content' } + }) + expect(wrapper.get('.asset-image-preview').element.nextElementSibling?.textContent).toContain('历史记录 · 6 条') + expect(wrapper.get('.image-history-heading').element.nextElementSibling).toBe(history.element) + expect(history.text()).toContain('当前母版') + expect(history.text()).toContain('母版候选') + expect(history.text()).toContain('正面') + expect(history.text()).toContain('生成失败') + expect(history.text()).toContain('排队中') + expect(history.text()).toContain('生成中') + expect(history.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true') + for (const image of images) { + await history.get(`[aria-label="查看身份图片 ${image.id}"]`).trigger('click') + expect(history.findAll('[aria-pressed="true"]')).toHaveLength(1) + expect(history.get(`[aria-label="查看身份图片 ${image.id}"]`).attributes('aria-pressed')).toBe('true') + const preview = wrapper + .findAllComponents(AssetImage) + .find(item => item.classes().includes('asset-image-preview'))! + expect(preview.props('src')).toBe(image.status === 'completed' ? image.imageUrl : null) + expect(wrapper.text()).toContain(`Identity image ID · ${image.id}`) + } + await history.get('[aria-label="查看身份图片 failed"]').trigger('click') + expect(wrapper.get('.asset-image-preview').text()).toContain('本次生成失败') + expect(wrapper.findAll('[role="alert"]').map(item => item.text())).toContain('图片模型超时') + expect(button('确认选角并锁定').disabled).toBe(true) + expect(wrapper.emitted('anchor')).toBeUndefined() + }) + + it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => { + const anchor = identityImageFixture({ id: 'anchor' }) + const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false }) + wrapper = mount(IdentityGallery, { + props: { + subjectName: '林夏', + module: 'character', + identityLocked: true, + disabled: false, + images: [anchor, front] + } + }) + await wrapper.get('[aria-label="查看身份图片 front"]').trigger('click') + const viewport = wrapper.get('.image-history .n-scrollbar-container').element + viewport.scrollLeft = 120 + await wrapper.setProps({ images: [identityImageFixture({ id: 'new', isAnchor: false }), anchor, front] }) + expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true') + expect(wrapper.get('.image-history .n-scrollbar-container').element).toBe(viewport) + expect(viewport.scrollLeft).toBe(120) + await wrapper.setProps({ images: [anchor] }) + expect(wrapper.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true') + expect(wrapper.get('.image-history-heading').text()).toContain('历史记录 · 1 条') + }) + + it('切换历史取消上一张的确认,只有再次确认才能发出当前候选 ID', async () => { + wrapper = mount(IdentityGallery, { + attachTo: document.body, + props: { + subjectName: '林夏', + module: 'character', + identityLocked: false, + disabled: false, + images: ['candidate-a', 'candidate-b'].map(id => + identityImageFixture({ id, isAnchor: false, enabled: false }) + ) + } + }) + button('确认选角并锁定').click() + await flushPromises() + expect(button('确认演员选择').disabled).toBe(false) + await wrapper.get('[aria-label="查看身份图片 candidate-b"]').trigger('click') + expect(wrapper.text()).not.toContain('确认选择这张图片作为正式演员') + expect(wrapper.emitted('anchor')).toBeUndefined() + button('确认选角并锁定').click() + await flushPromises() + button('确认演员选择').click() + expect(wrapper.emitted('anchor')).toEqual([['candidate-b']]) + }) + + it('无历史图片时只展示空状态,不伪造缩略图或母版', () => { + wrapper = mount(IdentityGallery, { + props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [] } + }) + expect(wrapper.text()).toContain('身份参考图 · 0') + expect(wrapper.text()).toContain('尚无身份参考图') + expect(wrapper.find('.image-history').exists()).toBe(false) + expect(wrapper.find('.asset-image-preview').exists()).toBe(false) + }) + it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => { const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false }) const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false }) diff --git a/src/features/subject-images/components/ImageGalleryDialog.vue b/src/features/subject-images/components/ImageGalleryDialog.vue index c3b0ac4..dabe0e9 100644 --- a/src/features/subject-images/components/ImageGalleryDialog.vue +++ b/src/features/subject-images/components/ImageGalleryDialog.vue @@ -102,6 +102,44 @@ async function choosePrimary() { :empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'" class="asset-image-preview" /> +
+

历史记录 · {{ images.length }} 条

+ 点击缩略图切换预览,可横向滚动 +
+ + {{ + image.isPrimary + ? '主参考图' + : { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[ + image.status + ] + }} +
- - {{ - image.isPrimary - ? '主参考图' - : { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[ - image.status - ] - }} -

{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }} diff --git a/src/features/subject-images/images.test.ts b/src/features/subject-images/images.test.ts index de3d55d..f0edd24 100644 --- a/src/features/subject-images/images.test.ts +++ b/src/features/subject-images/images.test.ts @@ -24,6 +24,40 @@ afterEach(() => { }) describe('形态图片选择与操作', () => { + it('形态图库复用有界历史栏,多张候选与失败记录切换只更新预览', async () => { + const rows = [ + imageFixture(), + imageFixture({ id: 'candidate', isPrimary: false, imageUrl: '/storage/candidate.png' }), + imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null, error: '生成失败详情' }) + ] + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: rows }))) + vi.stubGlobal('fetch', fetcher) + wrapper = mount(ImageGalleryDialog, { + attachTo: document.body, + props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false } + }) + await flushPromises() + const history = document.querySelector('[aria-label="图片历史"]')! + expect(history.querySelectorAll('.image-history-item')).toHaveLength(3) + expect(history.previousElementSibling?.textContent).toContain('历史记录 · 3 条') + expect(history.previousElementSibling?.previousElementSibling?.classList.contains('asset-image-preview')).toBe( + true + ) + history.querySelector('[aria-label="查看图片 candidate"]')!.click() + await flushPromises() + expect(document.querySelector('.asset-image-preview img')?.getAttribute('src')).toContain( + '/storage/candidate.png' + ) + expect(history.querySelector('[aria-label="查看图片 candidate"]')?.getAttribute('aria-pressed')).toBe('true') + history.querySelector('[aria-label="查看图片 failed"]')!.click() + await flushPromises() + expect(document.querySelector('.asset-image-preview')?.textContent).toContain('本次生成失败') + expect(button('设为主参考图').disabled).toBe(true) + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]![1]?.method).toBe('GET') + expect(wrapper.emitted('changed')).toBeUndefined() + }) + it.each(['character', 'scene', 'prop'])('按后端最新 %s 模块展示母版继承范围', async module => { const form = formFixture() form.subject.module = module diff --git a/src/styles.css b/src/styles.css index 67d804e..330b4e8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -496,24 +496,9 @@ font-size: 11px; text-align: center; } - .asset-image-preview { - aspect-ratio: auto; - height: min(48vh, 480px); - } .image-history { overflow: hidden; } - .image-history-item { - width: 110px; - flex-shrink: 0; - padding: 4px; - border: 1px solid var(--color-line); - border-radius: 4px; - } - .image-history-item.selected { - border-color: var(--color-accent); - background: var(--app-subtle); - } .image-history-item .asset-image-empty { font-size: 9px; padding: 6px;