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
@@ -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>
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
<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'
}