fix: 合并图库显示与操作入口

移除顶部主图统计,将刷新图库和批量生图改为带悬浮说明的图标按钮,并移动到布局切换按钮之后。
This commit is contained in:
GouJ
2026-09-08 13:40:10 +08:00
committed by GitHub
parent 7230f2238c
commit e912eada04
5 changed files with 243 additions and 241 deletions
+29 -26
View File
@@ -1,48 +1,51 @@
# Design QA
- Source visual truth: `/workspace/scratch/6e52996114b8/upload/18c36dd0-af66-494f-9633-c0f6466a7cc9.png`
- Source pixel dimensions: `2048 × 106`.
- Intended implementation viewport: PC dark theme, approximately `2048px` CSS width.
- Source visual truth:
- `/workspace/scratch/6e52996114b8/upload/ff48911d-ddad-4f26-8607-08b588b13a47.png`
- `/workspace/scratch/6e52996114b8/upload/bd89ef3b-1804-44e7-b00c-d376e735fd70.png`
- Source pixel dimensions: `378 × 58` and `282 × 124`.
- Intended implementation viewport: responsive dark theme, with the focused mobile filter region at approximately `393px` CSS width.
- Implementation screenshot: unavailable.
- CSS size and density normalization: unavailable because the implementation could not be opened in the cloud browser.
- State: 形态图片页顶部工具栏,主图统计在左、图库操作在右
- State: 形态图片页默认筛选与网格布局
## Full-view comparison evidence
The source crop was opened and inspected. The toolbar content sits inside a tall horizontal band, while the summary and actions are separated by nearly the full desktop width. This makes the content appear vertically loose and the center read as a large unused region.
The first source crop shows a separate top row containing the main-image count, refresh action, and batch-image action. The second crop identifies the display-control row after the result count and grid/masonry choices as the requested destination.
The component now uses symmetric `5px` vertical padding and a shared `34px` minimum alignment height for the toolbar, summary, and actions. On PC widths the actions no longer use automatic left margin, so the controls follow the summary as one compact toolbar group. Mobile keeps the existing split alignment.
The separate top row is now removed. The refresh and batch-image actions are icon-only buttons placed immediately after the grid/masonry group. Both keep accessible labels and use `NPopover` for visible hover explanations. The batch configuration drawer remains mounted without rendering its original trigger.
A local Sites preview started successfully, but the cloud browser rejected `terminal.local` with `net::ERR_BLOCKED_BY_CLIENT`. Therefore no browser-rendered implementation screenshot is available.
## Focused region comparison evidence
- Vertical alignment: summary and action groups share a `34px` alignment box with centered items.
- Header density: top and bottom padding are both `5px`.
- PC horizontal rhythm: automatic spacer is removed only above `760px`, preventing the wide empty center.
- Mobile behavior: the existing `margin-left: auto` remains active at `760px` and below.
- Removed content: the standalone `主图 x / y` summary is no longer rendered.
- Action order: result count → grid → masonry → refresh → batch image.
- Icon controls: refresh and batch image use the shared square `icon-button` sizing.
- Explanations: both new icon controls have hover-triggered `NPopover` copy.
- Drawer behavior: the external batch icon owns `aria-expanded` and opens the existing configuration drawer.
These are code-level checks only and do not replace a rendered focused-region comparison.
## Required fidelity surfaces
- Fonts and typography: unchanged; browser comparison is blocked.
- Spacing and layout rhythm: header padding is symmetric and the PC toolbar is grouped continuously.
- Colors and visual tokens: unchanged.
- Fonts and typography: the removed labels no longer consume toolbar width; popover typography remains Naive UI-managed.
- Spacing and layout rhythm: actions are consolidated into the existing display-control flex row.
- Colors and visual tokens: existing button states and theme tokens are unchanged.
- Image quality and asset fidelity: no image behavior changed.
- Copy and content: unchanged.
- Copy and content: `刷新图库` and `批量生图` move from button text into popover and accessible labels; receipt state remains visible in the batch popover.
## Findings
- [P1] Browser-rendered comparison unavailable
- Location: 形态图片页顶部工具栏
- Location: 形态图片筛选区的显示与操作按钮
- Evidence: the required cloud browser returns `net::ERR_BLOCKED_BY_CLIENT` for the healthy local preview.
- Impact: exact vertical centering, final toolbar width, responsive transition, and console state cannot be visually certified.
- Fix: capture the page in an available browser at the same desktop width and compare the toolbar crop.
- Impact: exact wrapping, popover position, hover behavior, drawer transition, and console state cannot be visually certified.
- Fix: capture the page in an available browser at the requested responsive widths and compare the consolidated control row.
## Primary interactions tested
Automated style-contract tests protect the symmetric padding, shared alignment height, and PC-only removal of the automatic spacer. All 275 tests pass. Browser interactions were unavailable.
Automated tests verify the new accessible refresh trigger, removal of the old heading row, batch drawer opening through the relocated trigger, and all existing refresh/batch workflows. All 275 tests pass. Browser hover interaction was unavailable.
## Console errors checked
@@ -50,16 +53,16 @@ Not checked because the page could not be opened in the cloud browser.
## Comparison history
- Iteration 1: identified asymmetric perceived vertical spacing and an oversized empty center caused by right-pushing the action group.
- Iteration 2: normalized the toolbar to a compact `44px` total height and grouped desktop actions beside the summary. Full tests and production build pass; browser capture remains blocked.
- Iteration 1: identified duplicated vertical chrome and text-heavy actions outside the filter controls.
- Iteration 2: removed the standalone summary row, consolidated two icon-only actions after the layout controls, added popover explanations, and passed the full test/build suite. Browser capture remained blocked.
## Implementation checklist
- [x] Center summary and actions within the same toolbar height.
- [x] Use equal top and bottom header padding.
- [x] Remove the wide PC-only automatic spacer.
- [x] Preserve the existing mobile split layout.
- [x] Add style-contract regression assertions.
- [ ] Capture and compare the rendered desktop state in the cloud browser.
- [x] Remove the standalone main-image summary.
- [x] Convert refresh and batch image actions to icon-only buttons.
- [x] Move both actions after the layout switch.
- [x] Add `NPopover` explanations and accessible labels.
- [x] Preserve batch drawer and refresh behavior.
- [ ] Capture and compare the rendered responsive state in the cloud browser.
final result: blocked
+6 -2
View File
@@ -2,13 +2,17 @@
import { NButton, NDrawer, NDrawerContent } from 'naive-ui'
import { Settings2, X } from '@lucide/vue'
/** 所有业务抽屉挂到 body,以完整视口为边界;开关面板不影响草稿、页面查询和后台任务。 */
withDefaults(defineProps<{ title: string; label?: string; hasReceipt?: boolean }>(), { label: '操作与回执' })
/** 所有业务抽屉挂到 body,以完整视口为边界;triggerless 允许业务页把入口放进自己的工具栏。 */
withDefaults(defineProps<{ title: string; label?: string; hasReceipt?: boolean; triggerless?: boolean }>(), {
label: '操作与回执',
triggerless: false
})
const open = defineModel<boolean>('open', { default: false })
</script>
<template>
<NButton
v-if="!triggerless"
class="workspace-tools-trigger"
data-workspace-tools
:aria-label="label"
@@ -11,6 +11,7 @@ import {
NCollapseItem,
NInput,
NInputNumber,
NPopover,
NScrollbar,
NSelect,
NTag
@@ -26,6 +27,7 @@ import {
ArrowRight,
LayoutGrid,
Columns3,
Settings2,
AlertTriangle
} from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
@@ -308,21 +310,10 @@ async function showStaleForms() {
<template>
<WorkspacePage compact class="gallery-workspace-page"
><template #header>
<!-- 图库统计和固定操作使用与其他工作台一致的紧凑头部 -->
<div class="workspace-toolbar">
<span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span>
<NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true"
>{{ staleForms.length }} 个形态主图过期</NButton
>
<div class="toolbar-actions">
<NButton
:disabled="query.loading.value || impact.query.loading.value"
@click="refreshAssets"
text
size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库 </NButton
><WorkspaceTools
><template #default>
<!-- 抽屉只保留内容入口跟随图库显示控制移动到筛选区 -->
<WorkspaceTools
triggerless
v-model:open="toolsOpen"
title="形态生图配置与回执"
label="批量生图"
@@ -370,8 +361,8 @@ async function showStaleForms() {
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
><div class="flex flex-wrap items-center justify-between gap-3">
<span class="flex items-center gap-2 text-xs">
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个形态 仍在使用旧
Identity Anchor
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个形态 仍在使用旧 Identity
Anchor
</span>
<ConfirmAction
label="重新生成过期形态"
@@ -437,14 +428,12 @@ async function showStaleForms() {
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
<NButton @click="exportReceipt" text size="small"
><Download :size="13" />导出诊断</NButton
>
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
</div>
<p class="mt-3 text-xs text-muted">
{{ session.receipt.result.total }} 个形态 · 本次目标
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
跳过 {{ session.receipt.result.skipped }} · 失败
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} · 跳过
{{ session.receipt.result.skipped }} · 失败
{{ session.receipt.result.failed }}
<span v-if="session.receipt.result.remaining !== undefined">
· 待后续处理 {{ session.receipt.result.remaining }}</span
@@ -478,9 +467,6 @@ async function showStaleForms() {
</p>
</section>
</WorkspaceTools>
</div>
</div></template
><template #default>
<!-- 关联说明筛选和图片共用正文滚动区避免镜头切换时两块滚动区域高度不同步 -->
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新">
{{ staleForms.length }}
@@ -608,6 +594,42 @@ async function showStaleForms() {
<template #icon><Columns3 :size="16" /></template>
</NButton>
</div>
<!-- 高频图库操作与显示方式连续排列纯图标按钮通过 Popover 解释用途 -->
<NPopover trigger="hover" placement="top">
<template #trigger>
<NButton
class="icon-button"
size="small"
:disabled="query.loading.value || impact.query.loading.value"
aria-label="刷新图库"
@click="refreshAssets"
>
<template #icon>
<RefreshCw
:size="16"
:class="{ 'animate-spin': query.loading.value }"
/>
</template>
</NButton>
</template>
刷新图库
</NPopover>
<NPopover trigger="hover" placement="top">
<template #trigger>
<NButton
class="icon-button"
size="small"
data-workspace-tools
aria-label="批量生图"
aria-haspopup="dialog"
:aria-expanded="toolsOpen"
@click="toolsOpen = true"
>
<template #icon><Settings2 :size="16" /></template>
</NButton>
</template>
{{ session.receipt || session.promptReceipt ? '批量生图(有回执)' : '批量生图' }}
</NPopover>
</div>
</div>
</div>
@@ -830,20 +852,6 @@ async function showStaleForms() {
.gallery-workspace-page .workspace-scroll-content {
@apply pt-2;
}
/* PC 端工具栏保持紧凑并从左向右连续排列;统一最小高度让统计和按钮上下居中。 */
.gallery-workspace-page .workspace-heading {
@apply py-[5px];
}
.gallery-workspace-page .workspace-toolbar,
.gallery-workspace-page .toolbar-summary,
.gallery-workspace-page .toolbar-actions {
@apply min-h-[34px] items-center;
}
@media (min-width: 761px) {
.gallery-workspace-page .toolbar-actions {
@apply ml-0;
}
}
.gallery-workspace-page .gallery-sticky-controls {
@apply sticky top-0 z-10 bg-(--app-body) mt-2.5 mb-0;
}
+7 -8
View File
@@ -162,7 +162,7 @@ describe('全站平面主题', () => {
)
expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/)
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
/<WorkspacePage\b[^>]*>\s*<template #header>/
/<WorkspacePage\b[^>]*>\s*<template #default>/
)
})
@@ -183,13 +183,12 @@ describe('全站平面主题', () => {
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(/\.gallery-workspace-page \.workspace-heading\s*\{[^}]*padding-block:\s*5px;/)
expect(css).toMatch(
/\.gallery-workspace-page \.workspace-toolbar,\s*\.gallery-workspace-page \.toolbar-summary,\s*\.gallery-workspace-page \.toolbar-actions\s*\{(?=[^}]*align-items:\s*center;)(?=[^}]*min-height:\s*34px;)/
)
expect(css).toMatch(
/@media \(min-width: 761px\)\s*\{[^}]*\.gallery-workspace-page \.toolbar-actions\s*\{[^}]*margin-left:\s*0;/
)
const source = readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')
expect(source).not.toContain('class="toolbar-summary"')
expect(source).toMatch(/<WorkspaceTools\s+triggerless/)
expect(source.match(/<NPopover trigger="hover" placement="top">/g)).toHaveLength(2)
expect(source).toMatch(/aria-label="刷新图库"/)
expect(source).toMatch(/data-workspace-tools\s+aria-label="批量生图"/)
expect(css).toMatch(
/\.form-image-filter-region\s*\{[^}]*padding:\s*12px;[^}]*--app-field:\s*var\(--app-surface\);[^}]*--app-field-hover:\s*var\(--app-surface\);[^}]*background:\s*color-mix\(in srgb, var\(--app-subtle\) 96%, white\);/
)
@@ -174,7 +174,7 @@ describe('过期首帧到具体素材定位', () => {
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(count)
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
const refresh = wrapper!.findAll('button').find(button => button.text().includes('刷新图库'))!
const refresh = wrapper!.get('[aria-label="刷新图库"]')
await refresh.trigger('click')
await flushPromises()
expect(fetcher.mock.calls.length).toBeGreaterThan(count)
@@ -185,7 +185,7 @@ describe('过期首帧到具体素材定位', () => {
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(true)
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
const content = wrapper!.get('.workspace-scroll-content').element
const sticky = wrapper!.get('.gallery-sticky-controls').element
@@ -207,10 +207,7 @@ describe('过期首帧到具体素材定位', () => {
})
: original(url, init)
)
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
wrapper!
.findAllComponents(NSelect)
@@ -414,7 +411,7 @@ describe('过期首帧到具体素材定位', () => {
it('身份主图真的过期时显示独立顶部警告,并可从定位模式筛选所有过期素材', async () => {
const { first, router } = await gallery('?subjectFormId=form-other')
first.subject.identity = { id: 'identity-1', isLocked: true, images: [{ id: 'anchor-new' }] }
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新图库')!
const refresh = wrapper!.get('[aria-label="刷新图库"]')
await refresh.trigger('click')
await flushPromises()
expect(wrapper!.text()).toContain('形态主图需要更新')
@@ -432,10 +429,7 @@ describe('过期首帧到具体素材定位', () => {
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
keyframes.items[0]!.primaryKeyframeStale = false
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('检查结果不一致')
expect(wrapper!.get('.form-image-card').text()).toContain('不要据此重复生图')
@@ -444,10 +438,7 @@ describe('过期首帧到具体素材定位', () => {
const { keyframes, videos } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
keyframes.items[0]!.primaryKeyframeStale = false
videos.items[0]!.issues = []
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
expect(wrapper!.find('[data-keyframe-impact]').exists()).toBe(false)
expect(wrapper!.text()).toContain('未被标记为过期')
@@ -467,10 +458,7 @@ describe('过期首帧到具体素材定位', () => {
? Promise.resolve(new Response('{}', { status: 500 }))
: original(url, init)
)
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
expect(wrapper!.text()).toContain('暂时无法确认有无过期首帧')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)