feat(short-drama-agent-front): 优化项目查询与工作区界面
This commit is contained in:
@@ -383,7 +383,7 @@ describe('管理后台组件边界', () => {
|
||||
expect(document.querySelector('.n-drawer-container')).toBeNull()
|
||||
})
|
||||
|
||||
it('完成项目解锁全部左侧链接,标题不再显示状态标签和刷新按钮', async () => {
|
||||
it('完成项目解锁全部左侧链接,移除项目标题栏并将项目名写入浏览器标题', async () => {
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
|
||||
id: 'navigation-test',
|
||||
title: '导航测试项目',
|
||||
@@ -423,7 +423,8 @@ describe('管理后台组件边界', () => {
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-label="项目工作流"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.project-title').text()).toBe('导航测试项目')
|
||||
expect(wrapper.find('.project-header').exists()).toBe(false)
|
||||
expect(document.title).toBe('导航测试项目 · 工作空间 · 短剧工作台')
|
||||
for (const path of paths) {
|
||||
await wrapper.get(`.n-menu a[href="/projects/navigation-test/${path}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
@@ -431,8 +432,6 @@ describe('管理后台组件边界', () => {
|
||||
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
|
||||
}
|
||||
expect(detail).toHaveBeenCalledOnce()
|
||||
expect(wrapper.find('.project-header .n-button').exists()).toBe(false)
|
||||
expect(wrapper.find('.project-header .n-tag').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('异步轮询生命周期', () => {
|
||||
it('关闭定时刷新后仍支持首次读取、手动刷新及查询目标切换', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, false))!
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('first')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
await query.refresh()
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('second')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('响应式暂停清除旧定时器,恢复只创建一个轮询链', async () => {
|
||||
vi.useFakeTimers()
|
||||
const interval = ref<number | false>(1000)
|
||||
const loader = vi.fn<() => Promise<string>>(async () => '数据')
|
||||
const scope = effectScope()
|
||||
scope.run(() => usePolling(ref('p'), loader, interval))
|
||||
await Promise.resolve()
|
||||
interval.value = false
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
interval.value = 1000
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const finish = new Map<string, (value: string) => void>()
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(
|
||||
(id: string) =>
|
||||
new Promise<string>(resolve => {
|
||||
finish.set(id, resolve)
|
||||
})
|
||||
)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, 1000))!
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
finish.get('second')!('新项目')
|
||||
await Promise.resolve()
|
||||
finish.get('first')!('旧项目')
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('新项目')
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('失败时保留已有数据,并向页面显示错误', async () => {
|
||||
const scope = effectScope()
|
||||
const loader = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce('上次成功数据')
|
||||
.mockRejectedValueOnce(new Error('连接已断开'))
|
||||
const query = scope.run(() => usePolling(ref('p'), loader))!
|
||||
await Promise.resolve()
|
||||
await query.refresh()
|
||||
expect(query.data.value).toBe('上次成功数据')
|
||||
expect(query.error.value).toBe('连接已断开')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useQuery } from '@/composables/useQuery'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
/** 测试中尚未完成的查询请求。 */
|
||||
interface PendingRequest {
|
||||
/** 完成当前查询。 */
|
||||
resolve: (value: string) => void
|
||||
/** 当前查询的取消信号。 */
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
describe('异步查询生命周期', () => {
|
||||
it('页面停留期间不重复请求,仍支持首次读取、手动刷新及查询目标切换', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => useQuery(key, loader))!
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('first')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
await query.refresh()
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('second')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('切换目标取消旧请求并忽略迟到响应', async () => {
|
||||
const key = ref('first')
|
||||
const requests = new Map<string, PendingRequest>()
|
||||
const loader = vi.fn<(id: string, signal: AbortSignal) => Promise<string>>(
|
||||
(id, signal) =>
|
||||
new Promise<string>(resolve => {
|
||||
requests.set(id, { resolve, signal })
|
||||
})
|
||||
)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => useQuery(key, loader))!
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
expect(requests.get('first')!.signal.aborted).toBe(true)
|
||||
requests.get('second')!.resolve('新项目')
|
||||
await Promise.resolve()
|
||||
requests.get('first')!.resolve('旧项目')
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('新项目')
|
||||
scope.stop()
|
||||
expect(requests.get('second')!.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('失败时保留已有数据,并向页面显示错误', async () => {
|
||||
const scope = effectScope()
|
||||
const loader = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce('上次成功数据')
|
||||
.mockRejectedValueOnce(new Error('连接已断开'))
|
||||
const query = scope.run(() => useQuery(ref('p'), loader))!
|
||||
await Promise.resolve()
|
||||
await query.refresh()
|
||||
expect(query.data.value).toBe('上次成功数据')
|
||||
expect(query.error.value).toBe('连接已断开')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -99,7 +99,7 @@ function checkpoint(): Checkpoint {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提供响应式 checkpoint,模拟轮询更新但不连接后端或启动模型任务。 */
|
||||
/** 提供响应式 checkpoint,模拟手动刷新但不连接后端或启动模型任务。 */
|
||||
function mountPage(records = [checkpoint()], episodes = 1): ReturnType<typeof useProjectData> {
|
||||
const project: ProjectDetail = {
|
||||
id: 'breakdown-scroll-test',
|
||||
@@ -341,7 +341,7 @@ describe('拆解页内容滚动', () => {
|
||||
expect(toolbar.text()).toContain('第二集')
|
||||
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('2 个节拍 · 2 个镜头')
|
||||
expect(wrapper!.findAll('.beat-section')).toHaveLength(2)
|
||||
// 轮询删除当前选项时,输入框与正文一起回到仍存在的第一集。
|
||||
// 刷新删除当前选项时,输入框与正文一起回到仍存在的第一集。
|
||||
provided.data.value!.checkpoints = [checkpoint()]
|
||||
await flushPromises()
|
||||
expect(select.props('value')).toBe(1)
|
||||
|
||||
@@ -66,21 +66,14 @@ async function openProject(initialPath: string) {
|
||||
}
|
||||
|
||||
describe('剧本完成前的下游访问限制', () => {
|
||||
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => {
|
||||
it('项目工作区停留期间不重复读取项目详情和工作流记录', async () => {
|
||||
vi.useFakeTimers()
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-gallery', 'completed'))
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-project', 'completed'))
|
||||
const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router } = await openProject('/projects/manual-gallery/production')
|
||||
await router.push('/projects/manual-gallery/subject-images')
|
||||
await flushPromises()
|
||||
await openProject('/projects/manual-project/production')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(1)
|
||||
await router.push('/projects/manual-gallery/production')
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => {
|
||||
@@ -91,7 +84,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
await openProject('/projects/manual-gate/subject-images')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper!.get('.project-access-gate').text()).not.toContain('状态会自动更新')
|
||||
expect(wrapper!.get('.project-access-gate').text()).toContain('请手动刷新项目状态')
|
||||
status = 'completed'
|
||||
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
|
||||
await refresh.trigger('click')
|
||||
@@ -123,24 +116,21 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => {
|
||||
it('未完成项目停留期间不自动解锁,仅在手动刷新后进入下游页面', async () => {
|
||||
vi.useFakeTimers()
|
||||
let status: ProjectStatus = 'generating'
|
||||
vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { mounted } = await openProject('/projects/polling/production')
|
||||
const { mounted } = await openProject('/projects/manual-refresh/production')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
status = 'completed'
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
await wrapper!.get('.project-access-gate button:nth-of-type(2)').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
status = 'need_review'
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
})
|
||||
|
||||
@@ -168,7 +158,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => {
|
||||
it('读取失败保持锁定;错误区重试成功后解锁且不恢复项目标题栏', async () => {
|
||||
const detail = vi
|
||||
.spyOn(projectsApi, 'detail')
|
||||
.mockRejectedValueOnce(new Error('项目读取失败'))
|
||||
@@ -177,8 +167,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
const { mounted } = await openProject('/projects/retry/storyboard')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(wrapper!.find('.project-header .n-button').exists()).toBe(false)
|
||||
expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false)
|
||||
expect(wrapper!.find('.project-header').exists()).toBe(false)
|
||||
await wrapper!.get('.project-notices button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('身份图与母版契约', () => {
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
|
||||
it('刷新新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
|
||||
const anchor = identityImageFixture({ id: 'anchor' })
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
|
||||
@@ -133,6 +133,15 @@ async function gallery(query = '') {
|
||||
}
|
||||
|
||||
describe('过期首帧到具体素材定位', () => {
|
||||
it('没有图片的形态卡片标记为方形占位', async () => {
|
||||
const { first } = await gallery()
|
||||
first.images = []
|
||||
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
|
||||
await flushPromises()
|
||||
const card = wrapper!.findAll('.form-image-card').find(item => item.attributes('data-form-id') === first.id)!
|
||||
expect(card.get('.asset-image').classes()).toContain('form-image-empty')
|
||||
})
|
||||
|
||||
it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => {
|
||||
const { fetcher } = await gallery()
|
||||
const requestCount = fetcher.mock.calls.length
|
||||
|
||||
Reference in New Issue
Block a user