feat: 形态图库支持铺满展示与原图预览
This commit is contained in:
@@ -90,6 +90,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||||
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
|
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
|
||||||
|
- 形态图库卡片采用 `cover` 铺满,点击图片调用 `NImage` 原生预览查看完整原图;“查看图片与记录”仍单独打开历史管理。历史缩略图和其他资源默认保持 `contain` 且不触发原图预览,避免与候选选择混淆。
|
||||||
- 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。
|
- 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。
|
||||||
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
||||||
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
|
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { NImage } from 'naive-ui'
|
||||||
|
import AssetImage from './AssetImage.vue'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | undefined
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = undefined
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('资源图片展示与原图预览', () => {
|
||||||
|
it('默认保持 contain 并禁用预览,不干扰历史缩略图选择', async () => {
|
||||||
|
wrapper = mount(AssetImage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { src: '/storage/history.png', alt: '历史图片' }
|
||||||
|
})
|
||||||
|
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'contain', previewDisabled: true })
|
||||||
|
await wrapper.get('img').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
||||||
|
wrapper = mount(AssetImage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { src: '/storage/original.png', alt: '林默 · 基础形态', objectFit: 'cover', preview: true }
|
||||||
|
})
|
||||||
|
const source = wrapper.get('img').attributes('src')
|
||||||
|
expect(wrapper.getComponent(NImage).props()).toMatchObject({
|
||||||
|
objectFit: 'cover',
|
||||||
|
previewDisabled: false,
|
||||||
|
previewSrc: source
|
||||||
|
})
|
||||||
|
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('cover')
|
||||||
|
await wrapper.get('img').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const image = document.querySelector<HTMLImageElement>('.n-image-preview')!
|
||||||
|
expect(image).not.toBeNull()
|
||||||
|
expect(image.getAttribute('src')).toBe(source)
|
||||||
|
expect(image.alt).toBe('林默 · 基础形态')
|
||||||
|
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
|
||||||
|
expect(image.style.objectFit).not.toBe('cover')
|
||||||
|
expect(document.querySelector('.n-image-preview-toolbar')).not.toBeNull()
|
||||||
|
expect(wrapper.element.contains(image)).toBe(false)
|
||||||
|
document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.querySelector<HTMLElement>('.n-image-preview-wrapper')?.style.display ?? 'none').toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('资源加载失败只显示重试占位,不打开空预览', async () => {
|
||||||
|
wrapper = mount(AssetImage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { src: '/storage/missing.png', alt: '失败图片', objectFit: 'cover', preview: true }
|
||||||
|
})
|
||||||
|
await wrapper.get('img').trigger('error')
|
||||||
|
expect(wrapper.text()).toContain('图片无法加载')
|
||||||
|
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||||
|
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.getComponent(NImage).props('previewDisabled')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([null, 'javascript:alert(1)', 'data:text/html,test'])('空地址或危险地址 %s 不进入原图预览', src => {
|
||||||
|
wrapper = mount(AssetImage, { props: { src, alt: '图片', objectFit: 'cover', preview: true } })
|
||||||
|
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||||
|
expect(wrapper.find('img').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,10 +4,17 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { ImageOff } from '@lucide/vue'
|
import { ImageOff } from '@lucide/vue'
|
||||||
import { referenceImageUrl } from '../../lib/assets'
|
import { referenceImageUrl } from '../../lib/assets'
|
||||||
|
|
||||||
/** 安全图片展示,资源失效或地址不合法时提供明确占位,不自动改用临时 Provider URL。 */
|
/** 安全图片展示;铺满与原图预览按场景开启,历史选择按钮默认不触发预览。 */
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{ src?: string | null; alt: string; emptyText?: string; retryable?: boolean }>(),
|
defineProps<{
|
||||||
{ retryable: true }
|
src?: string | null
|
||||||
|
alt: string
|
||||||
|
emptyText?: string
|
||||||
|
retryable?: boolean
|
||||||
|
objectFit?: 'contain' | 'cover'
|
||||||
|
preview?: boolean
|
||||||
|
}>(),
|
||||||
|
{ retryable: true, objectFit: 'contain', preview: false }
|
||||||
)
|
)
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
const url = computed(() => (props.src ? referenceImageUrl(props.src) : null))
|
const url = computed(() => (props.src ? referenceImageUrl(props.src) : null))
|
||||||
@@ -24,11 +31,13 @@ watch(
|
|||||||
<NImage
|
<NImage
|
||||||
v-if="url && !failed"
|
v-if="url && !failed"
|
||||||
:src="url"
|
:src="url"
|
||||||
|
:preview-src="url"
|
||||||
:alt="alt"
|
:alt="alt"
|
||||||
lazy
|
lazy
|
||||||
preview-disabled
|
:preview-disabled="!preview"
|
||||||
object-fit="contain"
|
:object-fit="objectFit"
|
||||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||||
|
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
||||||
@error="failed = true"
|
@error="failed = true"
|
||||||
/>
|
/>
|
||||||
<span v-else class="asset-image-empty"
|
<span v-else class="asset-image-empty"
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ function resetFilters() {
|
|||||||
<AssetImage
|
<AssetImage
|
||||||
:src="coverImage(form)?.imageUrl"
|
:src="coverImage(form)?.imageUrl"
|
||||||
:alt="`${form.subject.name} · ${form.name}`"
|
:alt="`${form.subject.name} · ${form.name}`"
|
||||||
|
object-fit="cover"
|
||||||
|
preview
|
||||||
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
|
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
|
||||||
/>
|
/>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { NScrollbar } from 'naive-ui'
|
import { NImage, NScrollbar } from 'naive-ui'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -765,6 +765,34 @@ describe('工作台页面交互', () => {
|
|||||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('形态卡片铺满并打开原生图片预览,历史管理仍从独立按钮进入', async () => {
|
||||||
|
const form = formFixture()
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockImplementation(async url =>
|
||||||
|
jsonResponse(String(url).endsWith('/subject-forms') ? [form] : form.images)
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
await mountSubjectImages()
|
||||||
|
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
|
||||||
|
const card = wrapper!.get('.form-image-card')
|
||||||
|
expect(card.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
|
||||||
|
await card.get('img').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.querySelector('.n-image-preview')?.getAttribute('src')).toContain(
|
||||||
|
'/storage/subjects/project/form/image.png'
|
||||||
|
)
|
||||||
|
expect(document.querySelector('.image-history')).toBeNull()
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce()
|
||||||
|
document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
button('查看图片与记录').click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.querySelector('.image-history')).not.toBeNull()
|
||||||
|
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subject-forms/form-db-1/images'))).toBe(true)
|
||||||
|
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('形态单图确认后发送正式 ID 并刷新候选图,不会调用 production/start', async () => {
|
it('形态单图确认后发送正式 ID 并刷新候选图,不会调用 production/start', async () => {
|
||||||
const form = formFixture()
|
const form = formFixture()
|
||||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user