feat: 支持图库瀑布流切换并调整列表间距

This commit is contained in:
GouJ
2026-09-04 12:41:04 +08:00
parent de421f985e
commit 989f2c1834
7 changed files with 177 additions and 10 deletions
+2
View File
@@ -234,6 +234,8 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。
12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId``subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。
13. 筛选栏右侧可切换网格/瀑布流,默认保留原网格。两种布局复用同一份卡片和筛选结果,切换不发请求、不清除筛选;瀑布流按列从上到下排列,列数随内容宽度适配,使用封面元数据预留图片比例,缺少有效尺寸时回退 4:3。搜索区与列表的 12px 间距放在列表 `padding-top`,随正文滚走,不在吸顶栏底部保留固定色带。
### 视觉风格、身份与形态的衔接
建议操作顺序:**剧本创作 → 剧本拆解 → 视觉风格 → 角色身份与选角 → 形态图片 → 分镜设计 → 镜头生产**。视觉风格也可以在拆解前准备;页面不会自动重生成已存在的下游资产。
+31 -2
View File
@@ -458,14 +458,29 @@ body {
.gallery-workspace-page .workspace-scroll-content {
padding-top: 8px;
}
/* 筛选关联行各自保留面板底色;外层用页面底色绘制与列表的间隔,吸顶时不透图。 */
/* 筛选关联行整体吸顶,底部不留固定分隔带;列表自己的顶部间距随内容滚走。 */
.gallery-workspace-page .gallery-sticky-controls {
position: sticky;
top: 0;
z-index: 10;
background: var(--app-body);
margin-block: 10px 0;
padding-bottom: 12px;
}
.gallery-workspace-page .form-image-grid {
padding-top: 12px;
}
/* 使用成熟的多列排版实现瀑布流;完整卡片不跨列,窄容器自动减少列数。 */
.gallery-workspace-page .form-image-masonry {
display: block;
columns: 260px;
column-gap: 20px;
}
.form-image-masonry > .form-image-card {
break-inside: avoid;
margin-bottom: 20px;
}
.form-image-masonry .asset-image {
aspect-ratio: var(--form-image-aspect, 4 / 3);
}
.gallery-sticky-controls > .form-image-filter-region {
margin-block: 0;
@@ -930,6 +945,17 @@ body {
font-size: 12px;
white-space: nowrap;
}
.form-image-display-controls {
display: flex;
align-items: center;
justify-self: end;
gap: 12px;
}
.gallery-layout-switch {
display: flex;
align-items: center;
gap: 4px;
}
@container (max-width: 1200px) {
.form-image-toolbar.has-impact-picker,
.form-image-toolbar.has-impact-picker.has-impact-actions {
@@ -940,6 +966,9 @@ body {
.form-image-filters {
grid-template-columns: minmax(0, 1fr) 152px;
}
.form-image-display-controls {
grid-column: 1 / -1;
}
}
@container (max-width: 460px) {
.form-image-filters {
+17 -4
View File
@@ -152,10 +152,10 @@ describe('全站平面主题', () => {
)
expect(css).toMatch(/\.asset-impact-links\s*\{[^}]*width:\s*max-content;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(/\.gallery-sticky-controls > \.form-image-filter-region\s*\{[^}]*margin-block:\s*0;/)
// 间隔使用页面底色,与搜索面板明显分开;随容器吸顶,不会露出滚过的图片
expect(css).toMatch(
/\.gallery-workspace-page \.gallery-sticky-controls\s*\{[^}]*margin-block:\s*10px 0;[^}]*padding-bottom:\s*12px;/
)
// 间距归列表所有,吸顶时底部不留固定色带
expect(css).toMatch(/\.gallery-workspace-page \.form-image-grid\s*\{[^}]*padding-top:\s*12px;/)
const sticky = css.match(/\.gallery-workspace-page \.gallery-sticky-controls\s*\{([^}]*)\}/)?.[1]
expect(sticky).not.toMatch(/padding|border-bottom|::after/)
expect(css).toMatch(
/\.asset-impact-links\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*32px;[^}]*padding-block:\s*6px;/
)
@@ -165,6 +165,19 @@ describe('全站平面主题', () => {
)
})
it('瀑布流自适应列宽,卡片保持完整并按封面尺寸预留高度', () => {
const css = readFileSync('src/admin.css', 'utf8')
expect(css).toMatch(
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*block;[^}]*columns:\s*260px;[^}]*column-gap:\s*20px;/
)
expect(css).toMatch(
/\.form-image-masonry > \.form-image-card\s*\{[^}]*break-inside:\s*avoid;[^}]*margin-bottom:\s*20px;/
)
expect(css).toMatch(
/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*var\(--form-image-aspect, 4 \/ 3\);/
)
})
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
const css = readFileSync('src/admin.css', 'utf8')
@@ -17,7 +17,17 @@ import {
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
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,
Columns3,
AlertTriangle
} from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
@@ -30,6 +40,7 @@ import FormPromptDialog from './components/FormPromptDialog.vue'
import FormPromptBatch from './components/FormPromptBatch.vue'
import { useKeyframeImpact } from './useKeyframeImpact'
import { queryText, referenceLocation, referenceTargets } from '../production/asset-links'
import { formCoverAspectRatio, type GalleryLayout } from './layout'
/** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */
const route = useRoute()
@@ -123,6 +134,8 @@ watch(
}
)
const search = ref('')
/** 切换排版复用同一份卡片和查询结果,不重新读取图库或清空筛选。 */
const layout = ref<GalleryLayout>('grid')
const module = ref('all')
const onlyMissing = ref(false)
const onlyStale = ref(false)
@@ -491,7 +504,33 @@ async function showStaleForms() {
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
</div>
<div class="form-image-display-controls">
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
<div class="gallery-layout-switch" role="group" aria-label="图库显示方式">
<NButton
class="icon-button"
size="small"
:type="layout === 'grid' ? 'primary' : 'default'"
:aria-pressed="layout === 'grid'"
aria-label="网格布局"
title="网格布局"
@click="layout = 'grid'"
>
<template #icon><LayoutGrid :size="16" /></template>
</NButton>
<NButton
class="icon-button"
size="small"
:type="layout === 'masonry' ? 'primary' : 'default'"
:aria-pressed="layout === 'masonry'"
aria-label="瀑布流布局"
title="瀑布流布局"
@click="layout = 'masonry'"
>
<template #icon><Columns3 :size="16" /></template>
</NButton>
</div>
</div>
</div>
</div>
</div>
@@ -559,7 +598,11 @@ async function showStaleForms() {
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
正在读取形态和已有图片
</p>
<div v-else-if="filtered.length" class="form-image-grid">
<div
v-else-if="filtered.length"
class="form-image-grid"
:class="{ 'form-image-masonry': layout === 'masonry' }"
>
<article
v-for="form in filtered"
:key="form.id"
@@ -569,11 +612,12 @@ async function showStaleForms() {
'form-image-card-focused': targetFormId === form.id
}"
:data-form-id="form.id"
:style="{ '--form-image-aspect': formCoverAspectRatio(form) }"
>
<AssetImage
:src="coverImage(form)?.imageUrl"
:alt="`${form.subject.name} · ${form.name}`"
object-fit="cover"
:object-fit="layout === 'masonry' ? 'contain' : 'cover'"
preview
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
/>
@@ -133,6 +133,34 @@ async function gallery(query = '') {
}
describe('过期首帧到具体素材定位', () => {
it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => {
const { fetcher } = await gallery()
const requestCount = fetcher.mock.calls.length
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
const grid = wrapper!.get('.form-image-grid').element
const cards = wrapper!.findAll('.form-image-card').map(card => card.element)
scroll.scrollTop = 240
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
expect(wrapper!.findAll('.form-image-card').map(card => card.element)).toEqual(cards)
expect(scroll.scrollTop).toBe(240)
expect(
wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect')
).toBe('2048 / 2048')
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
await wrapper!.get('[aria-label="网格布局"]').trigger('click')
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
expect(wrapper!.get('.form-image-card').text()).toContain('雨夜形态')
expect(wrapper!.get('.form-image-card').text()).toContain('查看图片与记录')
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
expect(fetcher).toHaveBeenCalledTimes(requestCount)
})
it('图库与关联检查不定时刷新,手动刷新仍能更新数据', async () => {
vi.useFakeTimers()
const { fetcher } = await gallery('?sourceShotId=shot-target')
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { formCoverAspectRatio } from './layout'
import { formFixture, imageFixture } from './testing/fixtures'
describe('图库瀑布流图片比例', () => {
it.each([
[800, 1200],
[1600, 900],
[1024, 1024]
])('保留正式图片尺寸 %s × %s', (width, height) => {
const form = formFixture()
form.images = [imageFixture({ width, height })]
expect(formCoverAspectRatio(form)).toBe(`${width} / ${height}`)
})
it.each([null, 0, -1, NaN, Infinity])('尺寸 %s 无效时使用稳定占位比例', width => {
const form = formFixture()
form.images = [imageFixture({ width })]
expect(formCoverAspectRatio(form)).toBe('4 / 3')
form.images = [imageFixture({ height: width })]
expect(formCoverAspectRatio(form)).toBe('4 / 3')
})
it('无图片时保留占位,候选封面也使用自己的尺寸', () => {
const form = formFixture()
form.images = []
expect(formCoverAspectRatio(form)).toBe('4 / 3')
form.images = [imageFixture({ isPrimary: false, width: 800, height: 1200 })]
expect(formCoverAspectRatio(form)).toBe('800 / 1200')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { coverImage } from './model'
import type { SubjectFormAsset } from './types'
/** 图库展示方式;仅改变卡片排版,不影响素材顺序、筛选及后端查询。 */
export type GalleryLayout = 'grid' | 'masonry'
/** 按正式封面尺寸预留瀑布流图片高度,避免懒加载时卡片跳动;旧记录缺少尺寸时使用 4:3。 */
export function formCoverAspectRatio(form: SubjectFormAsset): string {
const image = coverImage(form)
const width = image?.width
const height = image?.height
return typeof width === 'number' &&
typeof height === 'number' &&
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
? `${width} / ${height}`
: '4 / 3'
}