diff --git a/README.md b/README.md index f788d4d..c3ef552 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,8 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec 11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。 12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId`/`subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。 +13. 筛选栏右侧可切换网格/瀑布流,默认保留原网格。两种布局复用同一份卡片和筛选结果,切换不发请求、不清除筛选;瀑布流按列从上到下排列,列数随内容宽度适配,使用封面元数据预留图片比例,缺少有效尺寸时回退 4:3。搜索区与列表的 12px 间距放在列表 `padding-top`,随正文滚走,不在吸顶栏底部保留固定色带。 + ### 视觉风格、身份与形态的衔接 建议操作顺序:**剧本创作 → 剧本拆解 → 视觉风格 → 角色身份与选角 → 形态图片 → 分镜设计 → 镜头生产**。视觉风格也可以在拆解前准备;页面不会自动重生成已存在的下游资产。 diff --git a/src/admin.css b/src/admin.css index 6d0972d..bb41e19 100644 --- a/src/admin.css +++ b/src/admin.css @@ -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 { diff --git a/src/components/ui/flatTheme.test.ts b/src/components/ui/flatTheme.test.ts index aee3c2b..d3557b8 100644 --- a/src/components/ui/flatTheme.test.ts +++ b/src/components/ui/flatTheme.test.ts @@ -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') diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue index 8bcb42a..8b57228 100644 --- a/src/features/subject-images/SubjectImagesPage.vue +++ b/src/features/subject-images/SubjectImagesPage.vue @@ -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('grid') const module = ref('all') const onlyMissing = ref(false) const onlyStale = ref(false) @@ -491,7 +504,33 @@ async function showStaleForms() { 仅看缺少主图 仅看身份过期 - {{ filtered.length }} 个形态 +
+ {{ filtered.length }} 个形态 + +
@@ -559,7 +598,11 @@ async function showStaleForms() {

正在读取形态和已有图片…

-
+
diff --git a/src/features/subject-images/asset-impact.test.ts b/src/features/subject-images/asset-impact.test.ts index e7bf0a6..c269ee0 100644 --- a/src/features/subject-images/asset-impact.test.ts +++ b/src/features/subject-images/asset-impact.test.ts @@ -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('.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('.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') diff --git a/src/features/subject-images/layout.test.ts b/src/features/subject-images/layout.test.ts new file mode 100644 index 0000000..8e06e47 --- /dev/null +++ b/src/features/subject-images/layout.test.ts @@ -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') + }) +}) diff --git a/src/features/subject-images/layout.ts b/src/features/subject-images/layout.ts new file mode 100644 index 0000000..5a914c0 --- /dev/null +++ b/src/features/subject-images/layout.ts @@ -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' +}