fix: 统一图库滚动区域并吸顶镜头筛选栏

This commit is contained in:
GouJ
2026-09-04 11:21:39 +08:00
parent 12f7e69e88
commit acc0a1e9ad
4 changed files with 265 additions and 139 deletions
+30 -1
View File
@@ -455,8 +455,19 @@ body {
.production-pipeline-card > .confirm-action { .production-pipeline-card > .confirm-action {
margin-block: 16px 4px; margin-block: 16px 4px;
} }
.gallery-workspace-page .workspace-scroll-content {
padding-top: 8px;
}
/* 图库只使用正文纵向滚动;筛选栏在同一滚动上下文吸顶,不再被头部高度限制。 */
.gallery-workspace-page .form-image-filter-region { .gallery-workspace-page .form-image-filter-region {
margin-block: 10px 0; position: sticky;
top: 0;
z-index: 10;
background: var(--app-subtle);
margin-block: 10px 12px;
}
.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
overflow-anchor: none;
} }
.workspace-heading h2 { .workspace-heading h2 {
font-size: 16px; font-size: 16px;
@@ -1501,6 +1512,24 @@ body {
padding: 14px 16px; padding: 14px 16px;
background: var(--app-subtle); background: var(--app-subtle);
margin-bottom: 12px; margin-bottom: 12px;
min-width: 0;
}
/* 关联素材始终单行横向浏览,数量和长名称不会撑高定位区或压缩图片区域。 */
.asset-impact-links-scroll.n-scrollbar {
height: auto;
min-width: 0;
max-width: 100%;
}
.asset-impact-links {
display: flex;
align-items: center;
gap: 12px;
width: max-content;
white-space: nowrap;
padding-bottom: 8px;
}
.asset-impact-links > * {
flex-shrink: 0;
} }
.asset-impact-select { .asset-impact-select {
min-width: 0; min-width: 0;
+16
View File
@@ -141,6 +141,22 @@ describe('全站平面主题', () => {
expect(tools).not.toContain(':focusable="false"') expect(tools).not.toContain(':focusable="false"')
}) })
it('图库筛选吸顶,关联内容横向单行滚动,不给头部和图片区制造双重滚动边界', () => {
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
const css = readFileSync('src/admin.css', 'utf8')
expect(css).toMatch(
/\.gallery-workspace-page \.form-image-filter-region\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*10;[^}]*background:\s*var\(--app-subtle\);/
)
expect(css).toMatch(
/\.gallery-workspace-page \.workspace-scroll > \.n-scrollbar-container\s*\{[^}]*overflow-anchor:\s*none;/
)
expect(css).toMatch(/\.asset-impact-links\s*\{[^}]*width:\s*max-content;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/)
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
/<WorkspacePage\b[^>]*>\s*<template #default>/
)
})
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => { it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。 // DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
const css = readFileSync('src/admin.css', 'utf8') const css = readFileSync('src/admin.css', 'utf8')
@@ -3,7 +3,18 @@ import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import ActionMenu from '../../components/ui/ActionMenu.vue' import ActionMenu from '../../components/ui/ActionMenu.vue'
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue' import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue' import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui' import {
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NInput,
NInputNumber,
NScrollbar,
NSelect,
NTag
} from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Search, RefreshCw, Download, ImagePlus, ArrowLeft, ArrowRight, LayoutGrid, AlertTriangle } from '@lucide/vue' import { Search, RefreshCw, Download, ImagePlus, ArrowLeft, ArrowRight, LayoutGrid, AlertTriangle } from '@lucide/vue'
@@ -211,7 +222,8 @@ async function showStaleForms() {
<template> <template>
<WorkspacePage compact class="gallery-workspace-page" <WorkspacePage compact class="gallery-workspace-page"
><template #header> ><template #default>
<!-- 筛选关联说明和图片共用正文滚动区避免镜头切换时两块滚动区域高度不同步 -->
<div class="workspace-toolbar"> <div class="workspace-toolbar">
<span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span> <span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span>
<NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true" <NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true"
@@ -495,9 +507,15 @@ async function showStaleForms() {
<p v-if="targetUnlinked" class="text-xs text-danger"> <p v-if="targetUnlinked" class="text-xs text-danger">
指定形态不在该镜头当前的有效引用列表中关联可能已调整或主图缺失请从下方关联素材重新核对 指定形态不在该镜头当前的有效引用列表中关联可能已调整或主图缺失请从下方关联素材重新核对
</p> </p>
<div <NScrollbar
v-if="linkedReferences.length && !impact.references.error.value" v-if="linkedReferences.length && !impact.references.error.value"
class="flex flex-wrap items-center gap-3" :key="sourceShotId"
class="asset-impact-links-scroll"
content-class="asset-impact-links"
x-scrollable
trigger="none"
role="region"
aria-label="本镜头关联素材可横向滚动"
> >
<span class="text-xs text-muted">本镜头当前关联素材</span <span class="text-xs text-muted">本镜头当前关联素材</span
><RouterLink ><RouterLink
@@ -507,7 +525,7 @@ async function showStaleForms() {
class="text-button" class="text-button"
>{{ target.label }} </RouterLink >{{ target.label }} </RouterLink
> >
</div> </NScrollbar>
<p v-if="impact.references.loading.value" class="text-xs text-muted">正在读取镜头关联素材</p> <p v-if="impact.references.loading.value" class="text-xs text-muted">正在读取镜头关联素材</p>
<p v-if="impact.references.error.value" class="text-xs text-danger"> <p v-if="impact.references.error.value" class="text-xs text-danger">
{{ impact.references.error.value }} {{ impact.references.error.value }}
@@ -536,7 +554,6 @@ async function showStaleForms() {
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4"> ><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务 数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务
</NAlert> </NAlert>
</template>
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status"> <p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
正在读取形态和已有图片 正在读取形态和已有图片
</p> </p>
@@ -571,7 +588,9 @@ async function showStaleForms() {
</p> </p>
<div class="mt-3 flex flex-wrap items-center gap-2"> <div class="mt-3 flex flex-wrap items-center gap-2">
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" /> <StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
<NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false">身份母版已变更</NTag> <NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false"
>身份母版已变更</NTag
>
<NTag v-else-if="primaryImage(form.images)" size="small" :bordered="false">主参考图</NTag> <NTag v-else-if="primaryImage(form.images)" size="small" :bordered="false">主参考图</NTag>
<NTag v-else-if="coverImage(form)" size="small" :bordered="false">候选图 · 尚未设主图</NTag> <NTag v-else-if="coverImage(form)" size="small" :bordered="false">候选图 · 尚未设主图</NTag>
<span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger" <span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger"
@@ -671,6 +690,6 @@ async function showStaleForms() {
:project-id="id" :project-id="id"
:form="galleryForm" :form="galleryForm"
:disabled="blocked || !galleryForm" :disabled="blocked || !galleryForm"
@changed="refreshAssets" @changed="refreshAssets" /></template
/></WorkspacePage> ></WorkspacePage>
</template> </template>
@@ -132,6 +132,68 @@ async function gallery(query = '') {
} }
describe('过期首帧到具体素材定位', () => { describe('过期首帧到具体素材定位', () => {
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
const content = wrapper!.get('.workspace-scroll-content').element
const toolbar = wrapper!.get('.form-image-filter-region').element
expect(toolbar.parentElement).toBe(content)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(content)
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
scroll.scrollTop = 480
keyframes.items.push({ ...keyframes.items[0]!, shotId: 'shot-next', shotNo: 2 })
videos.items.push({ ...videos.items[0]!, shotId: 'shot-next', shotNo: 2 })
keyframes.total = videos.total = 2
let finish!: (response: Response) => void
const original = fetcher.getMockImplementation()!
fetcher.mockImplementation((url, init) =>
String(url).endsWith('/shot-next/references')
? new Promise(resolve => {
finish = resolve
})
: original(url, init)
)
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
wrapper!
.findAllComponents(NSelect)
.find(item => item.attributes('aria-label') === '选择关联镜头')!
.vm.$emit('update:value', 'shot-next')
await flushPromises()
expect(wrapper!.text()).toContain('正在读取镜头关联素材')
finish(
new Response(
JSON.stringify({
data: {
...references,
shotId: 'shot-next',
references: Array.from({ length: 24 }, (_, index) => ({
...references.references[0]!,
subjectFormId: `long-form-${index}`,
subjectFormName: `很长的关联素材名称${index}`
}))
}
})
)
)
await flushPromises()
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
expect(wrapper!.get('.form-image-filter-region').element).toBe(toolbar)
expect(scroll.scrollTop).toBe(480)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(content)
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
const links = wrapper!.get('.asset-impact-links-scroll')
expect(links.findAll('a')).toHaveLength(24)
expect(links.text()).toContain('很长的关联素材名称23')
expect(links.classes()).toContain('n-scrollbar')
expect(links.find('.n-scrollbar-container > .asset-impact-links').exists()).toBe(true)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('切换镜头取消旧参考图查询,迟到响应不会产生旧素材链接', async () => { it('切换镜头取消旧参考图查询,迟到响应不会产生旧素材链接', async () => {
const { references } = fixture() const { references } = fixture()
let finish!: (response: Response) => void let finish!: (response: Response) => void