feat: 实现剧本创作与拆解前端工作台

This commit is contained in:
GouJ
2026-08-27 19:33:51 +08:00
parent edc003443f
commit 2ee6f049f6
56 changed files with 7597 additions and 2 deletions
+142
View File
@@ -0,0 +1,142 @@
import { computed, ref } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CreateProjectDialog from '../projects/components/CreateProjectDialog.vue'
import BreakdownPage from '../breakdown/BreakdownPage.vue'
import CreateDramaPage from '../create-drama/CreateDramaPage.vue'
import ProjectsPage from '../projects/ProjectsPage.vue'
import { projectContextKey, type useProjectData } from '../projects/context'
import type { ProjectDetail } from '../projects/types'
import type { Checkpoint } from './types'
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
const fixture: ProjectDetail = {
id: 'page-test-project',
title: '雨夜来信',
topic: '一封信改变了两个人的命运',
style: '都市悬疑',
status: 'completed',
createdAt: '2026-08-27T00:00:00Z',
updatedAt: '2026-08-27T00:00:00Z',
episodes: [{ episode: 1, title: '来信', content: '<script>不要执行模型内容</script>\n第一场:旧书店。' }],
characters: [{ id: 'character', name: '林知夏' }],
world: { era: '当代' },
reviews: [],
tasks: []
}
let wrapper: VueWrapper | undefined
/** 使用真实上下文形状,不绕过页面内的异步操作与按钮守卫。 */
function context(): ReturnType<typeof useProjectData> {
const data = ref({ project: fixture, checkpoints: [] as Checkpoint[] })
return {
data,
project: computed(() => data.value.project),
checkpoints: computed(() => data.value.checkpoints),
loading: ref(false),
error: ref(''),
updatedAt: ref(''),
refresh: vi.fn<() => Promise<void>>().mockResolvedValue()
}
}
/** 找到挂载到 body 的 Reka 弹窗按钮。 */
function button(label: string): HTMLButtonElement {
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
if (!element) throw new Error('找不到按钮:' + label)
return element
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('工作台页面交互', () => {
it('项目筛选无结果时可清除条件并返回真实列表', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockImplementation(async () => new Response(JSON.stringify({ data: [fixture] })))
)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: ProjectsPage }]
})
await router.push('/')
wrapper = mount(ProjectsPage, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.text()).toContain('雨夜来信')
await wrapper.get('input[aria-label="搜索项目"]').setValue('不存在的关键词')
expect(wrapper.text()).toContain('没有匹配的剧本')
button('清除筛选').click()
await flushPromises()
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
})
it('新建弹窗提交真实参数并返回 202 的项目 ID', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 }))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(CreateProjectDialog, { attachTo: document.body })
button('新建剧本').click()
await flushPromises()
const topic = document.querySelector<HTMLTextAreaElement>('#topic')!
topic.value = ' 雨夜来信 '
topic.dispatchEvent(new Event('input', { bubbles: true }))
const count = document.querySelector<HTMLInputElement>('#episode-count')!
count.value = '6'
count.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
expect(wrapper.emitted('created')).toEqual([['created']])
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toEqual({
topic: '雨夜来信',
style: '爽文反转',
episodeCount: 6
})
})
it('修改每组集数会废弃预览,重新预览并确认后才能启动', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
if (String(url).includes('breakdown-preview'))
return new Response(
'{"data":{"episodeCount":1,"groupCount":1,"estimatedTaskCount":3,"groups":[],"tasks":[],"modules":["character","scene","prop"],"groupSize":2}}'
)
return new Response('{"data":{"workflowExecution":{"status":"completed"}}}')
})
vi.stubGlobal('fetch', fetcher)
const provided = context()
wrapper = mount(BreakdownPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: provided } }
})
button('预览分组').click()
await flushPromises()
expect(button('开始拆解').disabled).toBe(false)
await wrapper.get('#group-size').setValue('2')
expect(document.body.textContent).not.toContain('开始拆解')
button('预览分组').click()
await flushPromises()
button('开始拆解').click()
await flushPromises()
button('确认开始拆解').click()
await flushPromises()
const post = fetcher.mock.calls.find(call => call[1]?.method === 'POST')
expect(post?.[0]).toBe('/api/projects/page-test-project/breakdown/start')
expect(JSON.parse(post![1]!.body as string)).toEqual({ groupSize: 2, modules: ['character', 'scene', 'prop'] })
expect(provided.refresh).toHaveBeenCalledOnce()
})
it('剧本文本按纯文本显示,不执行模型输出的 HTML', () => {
wrapper = mount(CreateDramaPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
expect(wrapper.find('.script-body').text()).toContain('<script>不要执行模型内容</script>')
expect(wrapper.find('.script-body script').exists()).toBe(false)
})
})