From 0f8b2074e4505eaa54cc786010f10939747ea422 Mon Sep 17 00:00:00 2001 From: GJ Date: Mon, 7 Sep 2026 14:17:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(short-drama-agent-front):=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=B8=83=E5=B1=80=E4=B8=8E?= =?UTF-8?q?=E5=9B=BE=E5=BA=93=E7=80=91=E5=B8=83=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/App.vue | 8 +- src/components/ui/WorkspaceTools.vue | 5 +- .../components/AdvancedProduction.vue | 2 +- .../production/components/QualityDialog.vue | 2 +- src/features/projects/ProjectLayout.vue | 2 +- .../subject-identity/SubjectIdentityPage.vue | 57 ++++++++++-- .../subject-images/SubjectImagesPage.vue | 91 +++++++++++++++++-- src/features/subject-images/layout.ts | 26 ++---- src/features/visual-style/VisualStylePage.vue | 84 +++++++++-------- src/features/workflows/ConfirmAction.vue | 3 - tests/components/ui/flatTheme.test.ts | 9 +- .../subject-images/asset-impact.test.ts | 2 +- tests/features/subject-images/layout.test.ts | 34 ++----- 14 files changed, 209 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 85825e8..86dbfca 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec 11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。 12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId`/`subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。 -13. 筛选栏右侧可切换网格/瀑布流,默认保留原网格。两种布局复用同一份卡片和筛选结果,切换不发请求、不清除筛选;瀑布流按列从上到下排列,列数随内容宽度适配,使用封面元数据预留图片比例,缺少有效尺寸时回退 4:3。搜索区与列表的 12px 间距放在列表 `padding-top`,随正文滚走,不在吸顶栏底部保留固定色带。 +13. 筛选栏右侧可切换网格/瀑布流,默认保留原网格。两种布局复用同一份卡片和筛选结果,切换不发请求、不清除筛选;瀑布流按从左到右的素材顺序进入当前可用位置,列数随内容宽度适配,图片按自身原始比例自然撑开,不设置统一高度。搜索区与列表的 12px 间距放在列表 `padding-top`,随正文滚走,不在吸顶栏底部保留固定色带。 ### 视觉风格、身份与形态的衔接 diff --git a/src/App.vue b/src/App.vue index 33b2593..ee9f7cf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -229,11 +229,7 @@ const menuOptions = computed(() => [
- +
API 地址
@@ -244,7 +240,7 @@ const menuOptions = computed(() => [
生产环境
为 /api、/storage 配置反向代理,并为长工作流设置足够的超时。
状态更新
-
已有刷新按钮的页面使用手动更新;剧本创作和拆解仍每 6 秒同步项目状态。关闭页面不会取消后端任务。
+
已有刷新按钮的页面使用手动更新;剧本创作和拆解仍每 6 秒同步项目状态。
diff --git a/src/components/ui/WorkspaceTools.vue b/src/components/ui/WorkspaceTools.vue index a2b1896..3d70b4d 100644 --- a/src/components/ui/WorkspaceTools.vue +++ b/src/components/ui/WorkspaceTools.vue @@ -44,7 +44,6 @@ const open = defineModel('open', { default: false }) > - @@ -52,8 +51,7 @@ const open = defineModel('open', { default: false }) diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue index a3baf4a..4dd3c39 100644 --- a/src/features/subject-images/SubjectImagesPage.vue +++ b/src/features/subject-images/SubjectImagesPage.vue @@ -15,7 +15,7 @@ import { NSelect, NTag } from 'naive-ui' -import { computed, ref, watch } from 'vue' +import { computed, ref, watch, type Directive } from 'vue' import { useRoute, useRouter } from 'vue-router' import { Search, @@ -40,7 +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' +import { masonryRowSpan, type GalleryLayout } from './layout' /** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */ const route = useRoute() @@ -139,6 +139,75 @@ const layout = ref('grid') const module = ref('all') const onlyMissing = ref(false) const onlyStale = ref(false) +/** 保存每张卡片的尺寸观察器,切换回网格时及时释放。 */ +const masonryCardObservers = new WeakMap() + +/** + * 按素材顺序重新计算整个瀑布流的位置。 + * @param container 瀑布流容器元素 + */ +function syncMasonryLayout(container: HTMLElement) { + if (!container.classList.contains('form-image-masonry')) return + const styles = getComputedStyle(container) + const rowHeight = Number.parseFloat(styles.gridAutoRows) + const rowGap = Number.parseFloat(styles.rowGap) + const columnCount = Math.max(1, styles.gridTemplateColumns.split(/\s+/).filter(Boolean).length) + const nextRows = Array(columnCount).fill(1) + const cards = Array.from(container.children).filter( + (element): element is HTMLElement => + element instanceof HTMLElement && element.classList.contains('form-image-card') + ) + cards.forEach((card, index) => { + const column = index % columnCount + const span = masonryRowSpan(card.getBoundingClientRect().height, rowHeight, rowGap) + card.style.setProperty('--form-image-column', String(column + 1)) + const rowStart = nextRows[column] ?? 1 + card.style.setProperty('--form-image-row-start', String(rowStart)) + card.style.setProperty('--form-image-row-span', String(span)) + nextRows[column] = rowStart + span + }) +} + +/** + * 开始监听瀑布流卡片尺寸变化。 + * @param element 形态图片卡片元素 + */ +function observeMasonryCard(element: HTMLElement) { + if (masonryCardObservers.has(element) || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(() => { + if (element.parentElement) syncMasonryLayout(element.parentElement) + }) + observer.observe(element) + masonryCardObservers.set(element, observer) + if (element.parentElement) syncMasonryLayout(element.parentElement) +} + +/** + * 停止监听瀑布流卡片并清理网格跨度。 + * @param element 形态图片卡片元素 + */ +function unobserveMasonryCard(element: HTMLElement) { + masonryCardObservers.get(element)?.disconnect() + masonryCardObservers.delete(element) + element.style.removeProperty('--form-image-column') + element.style.removeProperty('--form-image-row-start') + element.style.removeProperty('--form-image-row-span') +} + +/** 让卡片按实际高度占据网格行,保持从左到右的 DOM 排列顺序。 */ +const vMasonryCard: Directive = { + mounted(element, binding) { + if (binding.value) observeMasonryCard(element) + }, + updated(element, binding) { + if (binding.value === binding.oldValue) return + if (binding.value) observeMasonryCard(element) + else unobserveMasonryCard(element) + }, + beforeUnmount(element) { + unobserveMasonryCard(element) + } +} const generateOpen = ref(false) const galleryOpen = ref(false) const generateId = ref('') @@ -593,7 +662,7 @@ async function showStaleForms() { {{ query.error.value }} 当前展示上次读取的数据。 - 数据库中仍有排队或生成中的图片,已暂停新的生图操作。长时间无变化时,请先核对后台任务;刷新或关闭页面不会取消任务。 + 数据库中仍有排队或生成中的图片,已暂停新的生图操作。长时间无变化时请先核对任务状态。

正在读取形态和已有图片… @@ -605,6 +674,7 @@ async function showStaleForms() { >

.form-image-card { - @apply break-inside-avoid mb-5; + grid-column-start: var(--form-image-column, auto); + grid-row-start: var(--form-image-row-start, auto); + grid-row-end: span var(--form-image-row-span, 1); } .form-image-masonry .asset-image { - @apply aspect-[var(--form-image-aspect,4/3)]; + @apply aspect-auto; +} +.form-image-masonry .asset-image .n-image, +.form-image-masonry .asset-image .n-image img { + @apply h-auto; } .gallery-sticky-controls > .form-image-filter-region { @apply my-0; diff --git a/src/features/subject-images/layout.ts b/src/features/subject-images/layout.ts index 5a914c0..34984b2 100644 --- a/src/features/subject-images/layout.ts +++ b/src/features/subject-images/layout.ts @@ -1,20 +1,14 @@ -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' +/** + * 计算瀑布流卡片需要跨越的网格行数。 + * @param height 卡片实际高度 + * @param rowHeight 隐式网格行高 + * @param rowGap 网格行间距 + * @returns 卡片需要跨越的网格行数 + */ +export function masonryRowSpan(height: number, rowHeight: number, rowGap: number): number { + if (![height, rowHeight, rowGap].every(Number.isFinite) || height <= 0 || rowHeight <= 0 || rowGap < 0) return 1 + return Math.max(1, Math.ceil((height + rowGap) / (rowHeight + rowGap))) } diff --git a/src/features/visual-style/VisualStylePage.vue b/src/features/visual-style/VisualStylePage.vue index eb09d98..972fc77 100644 --- a/src/features/visual-style/VisualStylePage.vue +++ b/src/features/visual-style/VisualStylePage.vue @@ -106,44 +106,39 @@ function removeImage(image: VisualStyleImage) { -

- 此操作会提交到真实后端,可能调用模型并产生费用。请勿在其他页面或终端同时启动相同工作流。 -

{ ) }) - it('瀑布流自适应列宽,卡片保持完整并按封面尺寸预留高度', () => { + it('瀑布流按行填充自适应列宽,卡片与图片均保留实际高度', () => { const css = readAllStyles() expect(css).toMatch( - /\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*block;[^}]*columns:\s*260px;[^}]*column-gap:\s*20px;/ + /\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fill, minmax\(min\(260px, 100%\), 1fr\)\);[^}]*grid-auto-flow:\s*row dense;[^}]*grid-auto-rows:\s*1px;/ ) expect(css).toMatch( - /\.form-image-masonry > \.form-image-card\s*\{[^}]*break-inside:\s*avoid;[^}]*margin-bottom:\s*20px;/ + /\.form-image-masonry > \.form-image-card\s*\{[^}]*grid-column-start:\s*var\(--form-image-column,\s*auto\);[^}]*grid-row-start:\s*var\(--form-image-row-start,\s*auto\);[^}]*grid-row-end:\s*span var\(--form-image-row-span,\s*1\);/ ) + expect(css).toMatch(/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*auto;/) expect(css).toMatch( - /\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*var\(--form-image-aspect,\s*4\s*\/\s*3\);/ + /\.form-image-masonry \.asset-image \.n-image,\s*\.form-image-masonry \.asset-image \.n-image img\s*\{[^}]*height:\s*auto;/ ) }) diff --git a/tests/features/subject-images/asset-impact.test.ts b/tests/features/subject-images/asset-impact.test.ts index a21c15d..1d7f859 100644 --- a/tests/features/subject-images/asset-impact.test.ts +++ b/tests/features/subject-images/asset-impact.test.ts @@ -149,7 +149,7 @@ describe('过期首帧到具体素材定位', () => { expect(scroll.scrollTop).toBe(240) expect( wrapper!.get('.form-image-card').element.style.getPropertyValue('--form-image-aspect') - ).toBe('2048 / 2048') + ).toBe('') await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜') await wrapper!.get('[aria-label="网格布局"]').trigger('click') expect(wrapper!.find('.form-image-masonry').exists()).toBe(false) diff --git a/tests/features/subject-images/layout.test.ts b/tests/features/subject-images/layout.test.ts index 23afe68..305a104 100644 --- a/tests/features/subject-images/layout.test.ts +++ b/tests/features/subject-images/layout.test.ts @@ -1,31 +1,11 @@ import { describe, expect, it } from 'vitest' -import { formCoverAspectRatio } from '@/features/subject-images/layout' -import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures' +import { masonryRowSpan } from '@/features/subject-images/layout' -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') +describe('图库瀑布流卡片布局', () => { + it('按卡片实际高度计算网格跨度', () => { + expect(masonryRowSpan(420, 1, 20)).toBe(21) + expect(masonryRowSpan(421, 1, 20)).toBe(21) + expect(masonryRowSpan(422, 1, 20)).toBe(22) + expect(masonryRowSpan(0, 1, 20)).toBe(1) }) })