feat: 支持图库瀑布流切换并调整列表间距
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
<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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user