feat(short-drama-agent-front): 调整工作台布局与图库瀑布流

This commit is contained in:
GJ
2026-09-07 14:17:53 +08:00
parent a37b0b613f
commit 0f8b2074e4
14 changed files with 209 additions and 118 deletions
+1 -1
View File
@@ -236,7 +236,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。 11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。
12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId``subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。 12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId``subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。
13. 筛选栏右侧可切换网格/瀑布流,默认保留原网格。两种布局复用同一份卡片和筛选结果,切换不发请求、不清除筛选;瀑布流按列从上到下排列,列数随内容宽度适配,使用封面元数据预留图片比例,缺少有效尺寸时回退 4:3。搜索区与列表的 12px 间距放在列表 `padding-top`,随正文滚走,不在吸顶栏底部保留固定色带。 13. 筛选栏右侧可切换网格/瀑布流,默认保留原网格。两种布局复用同一份卡片和筛选结果,切换不发请求、不清除筛选;瀑布流按从左到右的素材顺序进入当前可用位置,列数随内容宽度适配,图片按自身原始比例自然撑开,不设置统一高度。搜索区与列表的 12px 间距放在列表 `padding-top`,随正文滚走,不在吸顶栏底部保留固定色带。
### 视觉风格、身份与形态的衔接 ### 视觉风格、身份与形态的衔接
+2 -6
View File
@@ -229,11 +229,7 @@ const menuOptions = computed<MenuOption[]>(() => [
<main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main> <main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main>
</NLayout> </NLayout>
</div> </div>
<AppDialog <AppDialog v-model:open="settingsOpen" title="后端连接" description="查看当前 API 地址与代理配置。">
v-model:open="settingsOpen"
title="后端连接"
description="页面使用真实后端接口,生成操作会实际提交。"
>
<dl class="detail-grid mt-6"> <dl class="detail-grid mt-6">
<dt>API 地址</dt> <dt>API 地址</dt>
<dd> <dd>
@@ -244,7 +240,7 @@ const menuOptions = computed<MenuOption[]>(() => [
<dt>生产环境</dt> <dt>生产环境</dt>
<dd> /api/storage 配置反向代理并为长工作流设置足够的超时</dd> <dd> /api/storage 配置反向代理并为长工作流设置足够的超时</dd>
<dt>状态更新</dt> <dt>状态更新</dt>
<dd>已有刷新按钮的页面使用手动更新剧本创作和拆解仍每 6 秒同步项目状态关闭页面不会取消后端任务</dd> <dd>已有刷新按钮的页面使用手动更新剧本创作和拆解仍每 6 秒同步项目状态</dd>
</dl> </dl>
</AppDialog> </AppDialog>
</NConfigProvider> </NConfigProvider>
+1 -4
View File
@@ -44,7 +44,6 @@ const open = defineModel<boolean>('open', { default: false })
></NButton></div ></NButton></div
></template> ></template>
<slot /> <slot />
<template #footer><span class="text-xs text-muted">关闭面板不会取消已提交的后台任务</span></template>
</NDrawerContent> </NDrawerContent>
</NDrawer> </NDrawer>
</template> </template>
@@ -52,8 +51,7 @@ const open = defineModel<boolean>('open', { default: false })
<style> <style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */ /* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css"; @reference "../../styles/styles.css";
.workspace-tools-drawer .n-drawer-header, .workspace-tools-drawer .n-drawer-header {
.workspace-tools-drawer .n-drawer-footer {
@apply bg-(--app-subtle) p-4; @apply bg-(--app-subtle) p-4;
} }
.workspace-tools-drawer .n-drawer-body { .workspace-tools-drawer .n-drawer-body {
@@ -62,7 +60,6 @@ const open = defineModel<boolean>('open', { default: false })
.workspace-tools-drawer { .workspace-tools-drawer {
--n-header-padding: 16px; --n-header-padding: 16px;
--n-body-padding: 16px; --n-body-padding: 16px;
--n-footer-padding: 16px;
@apply max-w-full; @apply max-w-full;
} }
.workspace-tools-heading { .workspace-tools-heading {
@@ -66,7 +66,7 @@ function exportReceipt() {
<p v-else class="mt-2 text-sm">前置检查通过提交前仍会再次校验</p> <p v-else class="mt-2 text-sm">前置检查通过提交前仍会再次校验</p>
</div> </div>
<p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm"> <p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm">
请求进行中关闭面板不会取消后端任务请勿重复提交 请求进行中请勿重复提交
</p> </p>
<section v-if="receipt" aria-label="串联生产回执" class="surface-inset mt-4 p-4"> <section v-if="receipt" aria-label="串联生产回执" class="surface-inset mt-4 p-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -239,7 +239,7 @@ async function submit() {
> >
</div> </div>
<p v-if="operation.pending" role="status" class="mt-3 text-xs text-muted"> <p v-if="operation.pending" role="status" class="mt-3 text-xs text-muted">
{{ operation.label }}进行中关闭面板不会取消后端任务请勿重复提交 {{ operation.label }}进行中请勿重复提交
</p> </p>
<NAlert v-if="session.error" role="alert" type="error" :show-icon="false" class="mt-4">{{ <NAlert v-if="session.error" role="alert" type="error" :show-icon="false" class="mt-4">{{
session.error session.error
+1 -1
View File
@@ -54,7 +54,7 @@ onScopeDispose(() => {
></NAlert ></NAlert
> >
<NAlert v-if="operation.pending" :show-icon="false" role="status" <NAlert v-if="operation.pending" :show-icon="false" role="status"
>{{ operation.label }}可切换页面查看结果请勿重复提交关闭页面不会取消后端任务</NAlert >{{ operation.label }}可切换页面查看结果请勿重复提交</NAlert
> >
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert> <NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert> <NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
@@ -158,9 +158,10 @@ watch(
</script> </script>
<template> <template>
<WorkspacePage split compact <WorkspacePage split compact class="identity-workspace-page"
><template #header ><template #header>
><div class="workspace-toolbar"> <!-- 以下是类型筛选主体数量与操作入口 -->
<div class="workspace-toolbar">
<div class="toolbar-type-filter"> <div class="toolbar-type-filter">
<NSelect <NSelect
aria-label="身份主体类型" aria-label="身份主体类型"
@@ -414,6 +415,7 @@ watch(
</NAlert></NScrollbar </NAlert></NScrollbar
> >
<div v-if="subjects.length" class="identity-workspace"> <div v-if="subjects.length" class="identity-workspace">
<!-- 以下是主体目录 -->
<aside class="panel directory-panel identity-directory"> <aside class="panel directory-panel identity-directory">
<div class="directory-heading"> <div class="directory-heading">
<h3> <h3>
@@ -654,8 +656,14 @@ watch(
@apply grid-cols-[repeat(2,_minmax(0,_1fr))]; @apply grid-cols-[repeat(2,_minmax(0,_1fr))];
} }
} }
.identity-workspace-page {
container: identity-header / inline-size;
}
.toolbar-type-filter { .toolbar-type-filter {
@apply w-[152px]; @apply w-[152px] min-w-0;
}
.toolbar-type-filter .n-select {
@apply w-full min-w-0;
} }
.identity-tools-intro { .identity-tools-intro {
@apply mb-6; @apply mb-6;
@@ -728,10 +736,47 @@ watch(
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.identity-workspace { .identity-workspace {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[250px_minmax(0,_1fr)] gap-2.5; @apply grid-cols-[minmax(0,_1fr)] grid-rows-[auto_minmax(0,_1fr)] gap-2.5;
}
.identity-directory {
@apply h-auto;
}
.identity-subject-list {
@apply flex-none h-auto max-h-none;
}
.identity-directory .identity-subject-list.n-scrollbar,
.identity-directory .identity-subject-list .n-scrollbar-container,
.identity-directory .identity-subject-list .n-scrollbar-content {
@apply h-auto;
} }
.directory-filters { .directory-filters {
@apply grid-cols-[minmax(0,_1fr)_120px]; @apply grid-cols-[minmax(0,_1fr)];
}
}
/* 窄内容区去掉数量文案,操作入口收成图标与类型筛选同一行。 */
@container identity-header (max-width: 760px) {
.identity-workspace-page .workspace-toolbar {
@apply flex-nowrap gap-2;
}
.toolbar-type-filter {
@apply w-auto min-w-[120px] flex-1;
}
.identity-workspace-page .toolbar-summary {
@apply hidden;
}
.identity-workspace-page .toolbar-actions {
@apply ml-0 shrink-0;
}
.identity-workspace-page .workspace-tools-label,
.identity-workspace-page .workspace-tools-trigger .text-muted {
@apply hidden;
}
.identity-workspace-page .workspace-tools-trigger {
@apply h-[34px] w-[34px] p-0;
min-width: 34px;
}
.identity-workspace-page .workspace-tools-trigger .n-button__icon {
@apply m-0;
} }
} }
</style> </style>
@@ -15,7 +15,7 @@ import {
NSelect, NSelect,
NTag NTag
} from 'naive-ui' } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch, type Directive } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
Search, Search,
@@ -40,7 +40,7 @@ import FormPromptDialog from './components/FormPromptDialog.vue'
import FormPromptBatch from './components/FormPromptBatch.vue' import FormPromptBatch from './components/FormPromptBatch.vue'
import { useKeyframeImpact } from './useKeyframeImpact' import { useKeyframeImpact } from './useKeyframeImpact'
import { queryText, referenceLocation, referenceTargets } from '../production/asset-links' import { queryText, referenceLocation, referenceTargets } from '../production/asset-links'
import { formCoverAspectRatio, type GalleryLayout } from './layout' import { masonryRowSpan, type GalleryLayout } from './layout'
/** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */ /** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */
const route = useRoute() const route = useRoute()
@@ -139,6 +139,75 @@ const layout = ref<GalleryLayout>('grid')
const module = ref('all') const module = ref('all')
const onlyMissing = ref(false) const onlyMissing = ref(false)
const onlyStale = ref(false) const onlyStale = ref(false)
/** 保存每张卡片的尺寸观察器,切换回网格时及时释放。 */
const masonryCardObservers = new WeakMap<HTMLElement, ResizeObserver>()
/**
* 按素材顺序重新计算整个瀑布流的位置。
* @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<number>(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<HTMLElement, boolean> = {
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 generateOpen = ref(false)
const galleryOpen = ref(false) const galleryOpen = ref(false)
const generateId = ref('') const generateId = ref('')
@@ -593,7 +662,7 @@ async function showStaleForms() {
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4" <NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据</span></NAlert >{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据</span></NAlert
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4"> ><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务 数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对任务状态
</NAlert> </NAlert>
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status"> <p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
正在读取形态和已有图片 正在读取形态和已有图片
@@ -605,6 +674,7 @@ async function showStaleForms() {
> >
<article <article
v-for="form in filtered" v-for="form in filtered"
v-masonry-card="layout === 'masonry'"
:key="form.id" :key="form.id"
class="panel form-image-card" class="panel form-image-card"
:class="{ :class="{
@@ -612,7 +682,6 @@ async function showStaleForms() {
'form-image-card-focused': targetFormId === form.id 'form-image-card-focused': targetFormId === form.id
}" }"
:data-form-id="form.id" :data-form-id="form.id"
:style="{ '--form-image-aspect': formCoverAspectRatio(form) }"
> >
<AssetImage <AssetImage
:src="coverImage(form)?.imageUrl" :src="coverImage(form)?.imageUrl"
@@ -759,13 +828,21 @@ async function showStaleForms() {
@apply pt-3; @apply pt-3;
} }
.gallery-workspace-page .form-image-masonry { .gallery-workspace-page .form-image-masonry {
@apply block columns-[260px] gap-x-5; @apply grid grid-cols-[repeat(auto-fill,_minmax(min(260px,_100%),_1fr))] gap-x-5 items-start;
grid-auto-flow: row dense;
grid-auto-rows: 1px;
} }
.form-image-masonry > .form-image-card { .form-image-masonry > .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 { .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 { .gallery-sticky-controls > .form-image-filter-region {
@apply my-0; @apply my-0;
+10 -16
View File
@@ -1,20 +1,14 @@
import { coverImage } from './model'
import type { SubjectFormAsset } from './types'
/** 图库展示方式;仅改变卡片排版,不影响素材顺序、筛选及后端查询。 */ /** 图库展示方式;仅改变卡片排版,不影响素材顺序、筛选及后端查询。 */
export type GalleryLayout = 'grid' | 'masonry' export type GalleryLayout = 'grid' | 'masonry'
/** 按正式封面尺寸预留瀑布流图片高度,避免懒加载时卡片跳动;旧记录缺少尺寸时使用 4:3。 */ /**
export function formCoverAspectRatio(form: SubjectFormAsset): string { * 计算瀑布流卡片需要跨越的网格行数。
const image = coverImage(form) * @param height 卡片实际高度
const width = image?.width * @param rowHeight 隐式网格行高
const height = image?.height * @param rowGap 网格行间距
return typeof width === 'number' && * @returns 卡片需要跨越的网格行数
typeof height === 'number' && */
Number.isFinite(width) && export function masonryRowSpan(height: number, rowHeight: number, rowGap: number): number {
Number.isFinite(height) && if (![height, rowHeight, rowGap].every(Number.isFinite) || height <= 0 || rowHeight <= 0 || rowGap < 0) return 1
width > 0 && return Math.max(1, Math.ceil((height + rowGap) / (rowHeight + rowGap)))
height > 0
? `${width} / ${height}`
: '4 / 3'
} }
+26 -22
View File
@@ -106,26 +106,17 @@ function removeImage(image: VisualStyleImage) {
</script> </script>
<template> <template>
<WorkspacePage <WorkspacePage compact class="visual-style-page"
><template #header ><template #header>
><div class="flex flex-wrap items-end justify-between gap-4"> <!-- 以下是标题锁定状态与生成操作 -->
<div> <div class="workspace-toolbar visual-style-toolbar">
<h2 class="text-lg font-semibold">项目视觉风格</h2> <h2 class="font-semibold">项目视觉风格</h2>
</div>
<RouterLink :to="`/projects/${projectId}/subject-identity`" class="text-button"
>下一步主体身份 </RouterLink
>
</div></template
>
<div class="visual-style-toolbar">
<div class="visual-style-status" role="status"> <div class="visual-style-status" role="status">
<component :is="statusInfo.icon" :size="18" aria-hidden="true" /> <component :is="statusInfo.icon" :size="16" aria-hidden="true" />
<div>
<strong>{{ statusInfo.label }}</strong> <strong>{{ statusInfo.label }}</strong>
<p>{{ statusInfo.hint }}</p> <p>{{ statusInfo.hint }}</p>
</div> </div>
</div> <div class="visual-style-actions toolbar-actions">
<div class="visual-style-actions">
<ConfirmAction <ConfirmAction
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'" :label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
:disabled="disabled || !!style?.isLocked || dirty" :disabled="disabled || !!style?.isLocked || dirty"
@@ -142,8 +133,12 @@ function removeImage(image: VisualStyleImage) {
title="刷新风格" title="刷新风格"
><template #icon><RefreshCw :size="16" /></template ><template #icon><RefreshCw :size="16" /></template
></NButton> ></NButton>
<RouterLink :to="`/projects/${projectId}/subject-identity`" class="text-button"
>下一步主体身份 </RouterLink
>
</div> </div>
</div> </div>
</template>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4" <NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
>{{ query.error.value }} 请确认后端 dev 已更新并重启查询失败不等同于尚未创建 >{{ query.error.value }} 请确认后端 dev 已更新并重启查询失败不等同于尚未创建
</NAlert> </NAlert>
@@ -174,22 +169,31 @@ function removeImage(image: VisualStyleImage) {
<style> <style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */ /* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css"; @reference "../../styles/styles.css";
.visual-style-toolbar { .visual-style-page {
@apply flex items-center justify-between flex-wrap gap-y-4 gap-x-6 my-5 py-3.5 px-4 bg-(--app-subtle); container: visual-style-header / inline-size;
} }
.visual-style-status { .visual-style-status {
@apply flex items-center gap-3 min-w-0; @apply flex items-center gap-2 min-w-0 flex-1 flex-nowrap;
} }
.visual-style-status > svg { .visual-style-status > svg {
@apply shrink-0 text-muted; @apply shrink-0 text-muted;
} }
.visual-style-status strong { .visual-style-status strong {
@apply text-sm font-medium; @apply text-xs font-medium whitespace-nowrap;
} }
.visual-style-status p { .visual-style-status p {
@apply mt-1 mx-0 mb-0 text-muted text-xs leading-[1.6]; @apply mt-0 mx-0 mb-0 min-w-0 truncate text-muted text-xs leading-[1.6];
} }
.visual-style-actions { .visual-style-actions {
@apply flex items-center flex-wrap gap-2.5 ml-auto; @apply shrink-0;
}
/* 窄内容区让说明占满首行剩余空间,操作独占下一行并从左侧排开。 */
@container visual-style-header (max-width: 760px) {
.visual-style-page .workspace-toolbar {
@apply gap-x-3 gap-y-2;
}
.visual-style-actions.toolbar-actions {
@apply ml-0 w-full justify-start;
}
} }
</style> </style>
-3
View File
@@ -38,9 +38,6 @@ function confirm() {
label label
}}</NButton></template }}</NButton></template
> >
<p class="mt-5 text-sm leading-6 text-muted">
此操作会提交到真实后端可能调用模型并产生费用请勿在其他页面或终端同时启动相同工作流
</p>
<NCheckbox <NCheckbox
v-model:checked="acknowledged" v-model:checked="acknowledged"
v-if="acknowledgement" v-if="acknowledgement"
+5 -4
View File
@@ -166,16 +166,17 @@ describe('全站平面主题', () => {
) )
}) })
it('瀑布流自适应列宽,卡片保持完整并按封面尺寸预留高度', () => { it('瀑布流按行填充自适应列宽,卡片与图片均保留实际高度', () => {
const css = readAllStyles() const css = readAllStyles()
expect(css).toMatch( 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( 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( 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;/
) )
}) })
@@ -149,7 +149,7 @@ describe('过期首帧到具体素材定位', () => {
expect(scroll.scrollTop).toBe(240) expect(scroll.scrollTop).toBe(240)
expect( expect(
wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect') wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect')
).toBe('2048 / 2048') ).toBe('')
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜') await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
await wrapper!.get('[aria-label="网格布局"]').trigger('click') await wrapper!.get('[aria-label="网格布局"]').trigger('click')
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false) expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
+7 -27
View File
@@ -1,31 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { formCoverAspectRatio } from '@/features/subject-images/layout' import { masonryRowSpan } from '@/features/subject-images/layout'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
describe('图库瀑布流图片比例', () => { describe('图库瀑布流卡片布局', () => {
it.each([ it('按卡片实际高度计算网格跨度', () => {
[800, 1200], expect(masonryRowSpan(420, 1, 20)).toBe(21)
[1600, 900], expect(masonryRowSpan(421, 1, 20)).toBe(21)
[1024, 1024] expect(masonryRowSpan(422, 1, 20)).toBe(22)
])('保留正式图片尺寸 %s × %s', (width, height) => { expect(masonryRowSpan(0, 1, 20)).toBe(1)
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')
}) })
}) })