fix: 修复身份与形态图库历史图片布局
This commit is contained in:
@@ -89,6 +89,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
|
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
|
||||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||||
|
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
|
||||||
- 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。
|
- 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。
|
||||||
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
||||||
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
|
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
|
||||||
|
|||||||
+53
-1
@@ -792,10 +792,24 @@ body {
|
|||||||
height: auto;
|
height: auto;
|
||||||
overflow: hidden;
|
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 {
|
.image-history-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 4px 4px 8px;
|
padding: 4px 4px 12px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
}
|
}
|
||||||
.code-scroll.n-scrollbar {
|
.code-scroll.n-scrollbar {
|
||||||
@@ -876,6 +890,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -884,6 +899,12 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
/* 预览尺寸不随原图分辨率增长,历史缩略图也不能被按钮默认宽度覆盖。 */
|
||||||
|
.asset-image.asset-image-preview {
|
||||||
|
height: min(42dvh, 420px);
|
||||||
|
min-height: 0;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
}
|
||||||
.n-button__content {
|
.n-button__content {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -891,15 +912,46 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.n-button.image-history-item {
|
.n-button.image-history-item {
|
||||||
|
width: 128px;
|
||||||
|
min-width: 128px;
|
||||||
|
max-width: 128px;
|
||||||
|
flex: 0 0 128px;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
padding: 6px;
|
||||||
white-space: normal;
|
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 {
|
.n-button.image-history-item .n-button__content {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
overflow: hidden;
|
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 {
|
.casting-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ describe('管理后台组件边界', () => {
|
|||||||
const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8')
|
const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8')
|
||||||
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
|
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('选角面板保留说明间距,卡片按可用宽度排列并覆盖按钮默认高度', () => {
|
it('选角面板保留说明间距,卡片按可用宽度排列并覆盖按钮默认高度', () => {
|
||||||
// 保护间距与换行契约,真实宽高和暗色效果仍需浏览器验收。
|
// 保护间距与换行契约,真实宽高和暗色效果仍需浏览器验收。
|
||||||
const css = readFileSync('src/admin.css', 'utf8')
|
const css = readFileSync('src/admin.css', 'utf8')
|
||||||
|
|||||||
@@ -60,11 +60,53 @@ function chooseAnchor() {
|
|||||||
</NAlert>
|
</NAlert>
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<AssetImage
|
<AssetImage
|
||||||
|
:key="selected.id"
|
||||||
:src="selected.status === 'completed' ? selected.imageUrl : null"
|
:src="selected.status === 'completed' ? selected.imageUrl : null"
|
||||||
:alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`"
|
:alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`"
|
||||||
class="asset-image-preview mt-4"
|
class="asset-image-preview mt-4"
|
||||||
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'"
|
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'"
|
||||||
/>
|
/>
|
||||||
|
<div class="image-history-heading">
|
||||||
|
<h4 class="font-medium">历史记录 · {{ images.length }} 条</h4>
|
||||||
|
<span class="text-muted">点击缩略图切换预览,可横向滚动</span>
|
||||||
|
</div>
|
||||||
|
<NScrollbar
|
||||||
|
x-scrollable
|
||||||
|
trigger="none"
|
||||||
|
content-class="image-history-content"
|
||||||
|
:content-style="{ width: 'max-content' }"
|
||||||
|
class="image-history"
|
||||||
|
aria-label="身份图片历史"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
v-for="image in images"
|
||||||
|
:key="image.id"
|
||||||
|
:aria-label="`查看身份图片 ${image.id}`"
|
||||||
|
:aria-pressed="image.id === selected.id"
|
||||||
|
@click="selectedId = image.id"
|
||||||
|
class="image-history-item"
|
||||||
|
:class="{ selected: image.id === selected.id }"
|
||||||
|
><AssetImage
|
||||||
|
:src="image.status === 'completed' ? image.imageUrl : null"
|
||||||
|
:alt="identityViewLabels[image.viewType]"
|
||||||
|
:retryable="false"
|
||||||
|
:empty-text="
|
||||||
|
{ pending: '排队中', generating: '生成中', failed: '生成失败', completed: '图片缺失' }[
|
||||||
|
image.status
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/><span class="image-history-label">{{
|
||||||
|
image.isAnchor
|
||||||
|
? '当前母版'
|
||||||
|
: image.viewType === 'primary'
|
||||||
|
? '母版候选'
|
||||||
|
: identityViewLabels[image.viewType]
|
||||||
|
}}</span>
|
||||||
|
<span class="image-history-meta"
|
||||||
|
><StatusBadge :status="image.status" /><span>{{ image.enabled ? '启用' : '停用' }}</span></span
|
||||||
|
>
|
||||||
|
</NButton>
|
||||||
|
</NScrollbar>
|
||||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge :status="selected.status" /><NTag size="small" :bordered="false">{{
|
<StatusBadge :status="selected.status" /><NTag size="small" :bordered="false">{{
|
||||||
@@ -132,34 +174,6 @@ function chooseAnchor() {
|
|||||||
本次锚定图片 ID:{{ readImageProvenance(selected.rawJson).referenceImageId }}
|
本次锚定图片 ID:{{ readImageProvenance(selected.rawJson).referenceImageId }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
||||||
<NScrollbar
|
|
||||||
x-scrollable
|
|
||||||
content-class="image-history-content"
|
|
||||||
class="image-history mt-4"
|
|
||||||
aria-label="身份图片历史"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
v-for="image in images"
|
|
||||||
:key="image.id"
|
|
||||||
:aria-label="`查看身份图片 ${image.id}`"
|
|
||||||
:aria-pressed="image.id === selected.id"
|
|
||||||
@click="selectedId = image.id"
|
|
||||||
class="image-history-item"
|
|
||||||
:class="{ selected: image.id === selected.id }"
|
|
||||||
><AssetImage
|
|
||||||
:src="image.status === 'completed' ? image.imageUrl : null"
|
|
||||||
:alt="identityViewLabels[image.viewType]"
|
|
||||||
:retryable="false"
|
|
||||||
:empty-text="image.status === 'failed' ? '失败' : '无图'"
|
|
||||||
/><span class="mt-1 block text-[10px]">{{
|
|
||||||
image.isAnchor
|
|
||||||
? '当前母版'
|
|
||||||
: image.viewType === 'primary'
|
|
||||||
? '母版候选'
|
|
||||||
: identityViewLabels[image.viewType]
|
|
||||||
}}</span></NButton
|
|
||||||
>
|
|
||||||
</NScrollbar>
|
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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 IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||||
import IdentityGallery from './components/IdentityGallery.vue'
|
import IdentityGallery from './components/IdentityGallery.vue'
|
||||||
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
||||||
@@ -43,6 +45,129 @@ function input(selector: string, value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('身份图与母版契约', () => {
|
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<HTMLElement>('.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 候选停用时仍可选为母版', () => {
|
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
|
||||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||||
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
|
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
|
||||||
|
|||||||
@@ -102,6 +102,44 @@ async function choosePrimary() {
|
|||||||
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'"
|
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'"
|
||||||
class="asset-image-preview"
|
class="asset-image-preview"
|
||||||
/>
|
/>
|
||||||
|
<div class="image-history-heading">
|
||||||
|
<h4 class="font-medium">历史记录 · {{ images.length }} 条</h4>
|
||||||
|
<span class="text-muted">点击缩略图切换预览,可横向滚动</span>
|
||||||
|
</div>
|
||||||
|
<NScrollbar
|
||||||
|
x-scrollable
|
||||||
|
trigger="none"
|
||||||
|
content-class="image-history-content"
|
||||||
|
:content-style="{ width: 'max-content' }"
|
||||||
|
class="image-history"
|
||||||
|
aria-label="图片历史"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
v-for="image in images"
|
||||||
|
:key="image.id"
|
||||||
|
:aria-label="`查看图片 ${image.id}`"
|
||||||
|
:aria-pressed="image.id === selected.id"
|
||||||
|
@click="selectedId = image.id"
|
||||||
|
class="image-history-item"
|
||||||
|
:class="{ selected: image.id === selected.id }"
|
||||||
|
><AssetImage
|
||||||
|
:src="image.status === 'completed' ? image.imageUrl : null"
|
||||||
|
:alt="`${form?.name}候选图`"
|
||||||
|
:retryable="false"
|
||||||
|
:empty-text="
|
||||||
|
{ pending: '排队中', generating: '生成中', failed: '生成失败', completed: '图片缺失' }[
|
||||||
|
image.status
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/><span class="image-history-label">{{
|
||||||
|
image.isPrimary
|
||||||
|
? '主参考图'
|
||||||
|
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
|
||||||
|
image.status
|
||||||
|
]
|
||||||
|
}}</span></NButton
|
||||||
|
>
|
||||||
|
</NScrollbar>
|
||||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<StatusBadge :status="selected.status" /><NTag
|
<StatusBadge :status="selected.status" /><NTag
|
||||||
@@ -159,38 +197,6 @@ async function choosePrimary() {
|
|||||||
: '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。'
|
: '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<NScrollbar
|
|
||||||
x-scrollable
|
|
||||||
content-class="image-history-content"
|
|
||||||
class="image-history mt-5"
|
|
||||||
aria-label="图片历史"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
v-for="image in images"
|
|
||||||
:key="image.id"
|
|
||||||
:aria-label="`查看图片 ${image.id}`"
|
|
||||||
:aria-pressed="image.id === selected.id"
|
|
||||||
@click="selectedId = image.id"
|
|
||||||
class="image-history-item"
|
|
||||||
:class="{ selected: image.id === selected.id }"
|
|
||||||
><AssetImage
|
|
||||||
:src="image.status === 'completed' ? image.imageUrl : null"
|
|
||||||
:alt="`${form?.name}候选图`"
|
|
||||||
:retryable="false"
|
|
||||||
:empty-text="
|
|
||||||
{ pending: '排队中', generating: '生成中', failed: '生成失败', completed: '图片缺失' }[
|
|
||||||
image.status
|
|
||||||
]
|
|
||||||
"
|
|
||||||
/><span class="mt-1 block text-[10px]">{{
|
|
||||||
image.isPrimary
|
|
||||||
? '主参考图'
|
|
||||||
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
|
|
||||||
image.status
|
|
||||||
]
|
|
||||||
}}</span></NButton
|
|
||||||
>
|
|
||||||
</NScrollbar>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="py-10 text-center text-sm text-muted">
|
<p v-else class="py-10 text-center text-sm text-muted">
|
||||||
{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
|
{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
|
||||||
|
|||||||
@@ -24,6 +24,40 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('形态图片选择与操作', () => {
|
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<typeof fetch>().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<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 => {
|
it.each(['character', 'scene', 'prop'])('按后端最新 %s 模块展示母版继承范围', async module => {
|
||||||
const form = formFixture()
|
const form = formFixture()
|
||||||
form.subject.module = module
|
form.subject.module = module
|
||||||
|
|||||||
@@ -496,24 +496,9 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.asset-image-preview {
|
|
||||||
aspect-ratio: auto;
|
|
||||||
height: min(48vh, 480px);
|
|
||||||
}
|
|
||||||
.image-history {
|
.image-history {
|
||||||
overflow: hidden;
|
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 {
|
.image-history-item .asset-image-empty {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user