forked from 77media/video-flow
新增获取项目剧本数据的API接口,并在剧本服务Hook中实现相应的功能;优化剧本编辑界面,支持剧本片段的动态加载和用户提示词的更新;更新相关测试用例以覆盖新功能。
This commit is contained in:
parent
e42f5269ca
commit
4cd9a371ff
@ -593,3 +593,22 @@ export const applyScriptToShot = async (request: {
|
||||
}): Promise<ApiResponse<any>> => {
|
||||
return post<ApiResponse<any>>('/movie/apply_script_to_shot', request);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取项目剧本数据
|
||||
* @param request 请求参数
|
||||
*/
|
||||
export const getProjectScript = async (request: {
|
||||
/** 项目ID */
|
||||
projectId: string;
|
||||
}): Promise<ApiResponse<{
|
||||
/** 用户提示词 */
|
||||
prompt: string;
|
||||
/** 生成的剧本文本 */
|
||||
scriptText: string;
|
||||
}>> => {
|
||||
return post<ApiResponse<{
|
||||
prompt: string;
|
||||
scriptText: string;
|
||||
}>>('/movie/get_project_script', request);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { ScriptSlice } from "../domain/valueObject";
|
||||
import { ScriptEditUseCase } from "../usecase/ScriptEditUseCase";
|
||||
import { getProjectScript } from "../../../api/video_flow";
|
||||
|
||||
/**
|
||||
* 剧本服务Hook接口
|
||||
@ -28,6 +29,8 @@ export interface UseScriptService {
|
||||
// 操作方法
|
||||
/** 获取剧本数据(用户提示词) */
|
||||
fetchScriptData: (prompt: string) => Promise<void>;
|
||||
/** 根据项目ID获取已存在的剧本数据 */
|
||||
fetchProjectScript: (projectId: string) => Promise<void>;
|
||||
/** 设置当前聚焦的剧本片段 */
|
||||
setFocusedSlice: (sliceId: string) => void;
|
||||
/** 清除聚焦状态 */
|
||||
@ -123,6 +126,58 @@ export const useScriptService = (): UseScriptService => {
|
||||
}
|
||||
}, [initialScriptText]);
|
||||
|
||||
/**
|
||||
* 根据项目ID获取已存在的剧本数据
|
||||
* @param projectId 项目ID
|
||||
*/
|
||||
const fetchProjectScript = useCallback(async (projectId: string): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 清空当前状态
|
||||
setScriptText("");
|
||||
setScriptSlices([]);
|
||||
setFocusedSliceId("");
|
||||
setScriptSliceText("");
|
||||
|
||||
// 调用API获取项目剧本数据
|
||||
const response = await getProjectScript({ projectId });
|
||||
|
||||
if (!response.successful) {
|
||||
throw new Error(response.message || '获取项目剧本失败');
|
||||
}
|
||||
|
||||
const { prompt, scriptText } = response.data;
|
||||
|
||||
// 更新用户提示词状态
|
||||
setUserPrompt(prompt);
|
||||
|
||||
// 保存初始提示词(只在第一次获取时保存)
|
||||
if (!initialScriptText) {
|
||||
setInitialScriptText(prompt);
|
||||
}
|
||||
|
||||
// 创建新的剧本编辑用例并初始化数据
|
||||
const newScriptEditUseCase = new ScriptEditUseCase(scriptText);
|
||||
setScriptEditUseCase(newScriptEditUseCase);
|
||||
|
||||
// 设置剧本文本
|
||||
setScriptText(scriptText);
|
||||
|
||||
// 从UseCase获取解析后的剧本片段
|
||||
const scriptSlices = newScriptEditUseCase.getScriptSlices();
|
||||
setScriptSlices(scriptSlices);
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取项目剧本数据失败:', error);
|
||||
setError(error instanceof Error ? error.message : '获取项目剧本数据失败');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialScriptText]);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@ -278,6 +333,7 @@ export const useScriptService = (): UseScriptService => {
|
||||
|
||||
// 操作方法
|
||||
fetchScriptData,
|
||||
fetchProjectScript,
|
||||
setFocusedSlice,
|
||||
clearFocusedSlice,
|
||||
updateScriptSliceText,
|
||||
|
||||
@ -70,7 +70,7 @@ describe('RoleService 业务逻辑测试', () => {
|
||||
id: 'shot1',
|
||||
name: '分镜1',
|
||||
sketchUrl: 'http://example.com/sketch1.jpg',
|
||||
videoUrl: 'http://example.com/video1.mp4',
|
||||
videoUrl: ['http://example.com/video1.mp4'],
|
||||
roleList: [],
|
||||
sceneList: [],
|
||||
status: ShotStatus.sketchLoading,
|
||||
@ -254,140 +254,120 @@ describe('RoleService 业务逻辑测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('重新获取角色数据测试', () => {
|
||||
it('应该成功重新获取角色数据并更新实体', async () => {
|
||||
const mockTextEntity = {
|
||||
id: 'text1',
|
||||
content: '更新后的AI文本',
|
||||
updatedAt: Date.now(),
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
};
|
||||
describe('角色业务流程测试', () => {
|
||||
it('应该完成完整的角色编辑流程:获取列表→选择角色→修改提示词→智能优化→修改标签→重新生成→角色库替换→应用角色', async () => {
|
||||
// 1. 用户操作:获取角色列表
|
||||
const mockRoles = [mockRoleEntity];
|
||||
(getRoleList as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: mockRoles,
|
||||
message: 'success',
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{
|
||||
id: 'tag1',
|
||||
name: '更新标签1',
|
||||
content: '更新内容1',
|
||||
updatedAt: Date.now(),
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
},
|
||||
{
|
||||
id: 'tag2',
|
||||
name: '更新标签2',
|
||||
content: '更新内容2',
|
||||
updatedAt: Date.now(),
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
}
|
||||
];
|
||||
const roleListResult = await getRoleList({ projectId: 'project1' });
|
||||
expect(roleListResult.successful).toBe(true);
|
||||
expect(roleListResult.data).toEqual(mockRoles);
|
||||
|
||||
// 2. 用户操作:选择角色并获取角色数据
|
||||
(getRoleData as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: {
|
||||
text: mockTextEntity,
|
||||
tags: mockTags,
|
||||
tags: [mockTagEntity1],
|
||||
},
|
||||
});
|
||||
|
||||
// 模拟RoleItem的setEntity方法
|
||||
const mockSetEntity = jest.fn();
|
||||
(RoleItem as jest.MockedClass<typeof RoleItem>).mockImplementation((entity) => ({
|
||||
entity,
|
||||
metadata: {},
|
||||
disableEdit: entity.disableEdit,
|
||||
type: 1,
|
||||
setEntity: mockSetEntity,
|
||||
} as any));
|
||||
const roleDataResult = await getRoleData({ roleId: 'role1' });
|
||||
expect(roleDataResult.successful).toBe(true);
|
||||
expect(roleDataResult.data.text).toEqual(mockTextEntity);
|
||||
expect(roleDataResult.data.tags).toEqual([mockTagEntity1]);
|
||||
|
||||
const roleItem = new RoleItem(mockRoleEntity);
|
||||
const useCase = new RoleEditUseCase(roleItem);
|
||||
|
||||
const result = await useCase.refreshRoleData();
|
||||
|
||||
expect(getRoleData).toHaveBeenCalledWith({ roleId: 'role1' });
|
||||
expect(result.text).toEqual(mockTextEntity);
|
||||
expect(result.tags).toEqual(mockTags);
|
||||
expect(mockSetEntity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
generateTextId: 'text1',
|
||||
tagIds: ['tag1', 'tag2'],
|
||||
updatedAt: expect.any(Number),
|
||||
}));
|
||||
});
|
||||
|
||||
it('角色ID不存在时应该抛出错误', async () => {
|
||||
const emptyRoleEntity = { ...mockRoleEntity, id: '' };
|
||||
const roleItem = new RoleItem(emptyRoleEntity);
|
||||
const useCase = new RoleEditUseCase(roleItem);
|
||||
|
||||
await expect(useCase.refreshRoleData()).rejects.toThrow('角色ID不存在,无法获取角色数据');
|
||||
});
|
||||
|
||||
it('API调用失败时应该抛出错误', async () => {
|
||||
(getRoleData as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '获取失败',
|
||||
// 3. 用户操作:修改角色提示词
|
||||
const updatedTextEntity = { ...mockTextEntity, content: '修改后的角色提示词' };
|
||||
(updateText as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: updatedTextEntity,
|
||||
});
|
||||
|
||||
const roleItem = new RoleItem(mockRoleEntity);
|
||||
const useCase = new RoleEditUseCase(roleItem);
|
||||
const updateTextResult = await updateText({
|
||||
textId: 'text1',
|
||||
content: '修改后的角色提示词'
|
||||
});
|
||||
expect(updateTextResult.successful).toBe(true);
|
||||
expect(updateTextResult.data.content).toBe('修改后的角色提示词');
|
||||
|
||||
await expect(useCase.refreshRoleData()).rejects.toThrow('获取角色数据失败: 获取失败');
|
||||
});
|
||||
});
|
||||
// 4. 用户操作:智能优化文本
|
||||
const optimizedContent = '智能优化后的角色文本';
|
||||
mockTextEditUseCase.getOptimizedContent.mockResolvedValue(optimizedContent);
|
||||
mockTextEditUseCase.updateText.mockResolvedValue({
|
||||
entity: { ...mockTextEntity, content: optimizedContent },
|
||||
metadata: {},
|
||||
disableEdit: false,
|
||||
type: 0,
|
||||
} as any);
|
||||
|
||||
describe('角色形象库选取使用测试', () => {
|
||||
it('应该成功获取用户角色库', async () => {
|
||||
const mockLibraryRoles = [mockRoleEntity];
|
||||
const optimizedContentResult = await mockTextEditUseCase.getOptimizedContent();
|
||||
expect(optimizedContentResult).toBe(optimizedContent);
|
||||
|
||||
// 5. 用户操作:修改标签
|
||||
const updatedTagEntity = { ...mockTagEntity1, content: '修改后的标签内容' };
|
||||
(updateTag as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: updatedTagEntity,
|
||||
});
|
||||
|
||||
const updateTagResult = await updateTag({
|
||||
tagId: 'tag1',
|
||||
content: '修改后的标签内容'
|
||||
});
|
||||
expect(updateTagResult.successful).toBe(true);
|
||||
expect(updateTagResult.data.content).toBe('修改后的标签内容');
|
||||
|
||||
// 6. 用户操作:使用新的提示词和标签重新生成角色
|
||||
const newRoleEntity = { ...mockRoleEntity, id: 'role2', name: '重新生成的角色' };
|
||||
(regenerateRole as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: newRoleEntity,
|
||||
});
|
||||
|
||||
mockRoleEditUseCase.AIgenerateRole.mockResolvedValue(newRoleEntity);
|
||||
|
||||
const regenerateResult = await regenerateRole({
|
||||
prompt: '使用新的提示词重新生成角色',
|
||||
tagTypes: ['tag1'],
|
||||
roleId: 'role1'
|
||||
});
|
||||
expect(regenerateResult.successful).toBe(true);
|
||||
expect(regenerateResult.data.name).toBe('重新生成的角色');
|
||||
|
||||
// 7. 用户操作:获取角色库
|
||||
const mockLibraryRoles = [
|
||||
{ ...mockRoleEntity, id: 'libraryRole1', name: '库角色1' },
|
||||
{ ...mockRoleEntity, id: 'libraryRole2', name: '库角色2' }
|
||||
];
|
||||
(getUserRoleLibrary as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: mockLibraryRoles,
|
||||
});
|
||||
|
||||
const result = await getUserRoleLibrary();
|
||||
const libraryResult = await getUserRoleLibrary();
|
||||
expect(libraryResult.successful).toBe(true);
|
||||
expect(libraryResult.data).toEqual(mockLibraryRoles);
|
||||
|
||||
expect(getUserRoleLibrary).toHaveBeenCalled();
|
||||
expect(result.successful).toBe(true);
|
||||
expect(result.data).toEqual(mockLibraryRoles);
|
||||
});
|
||||
|
||||
it('应该成功替换角色', async () => {
|
||||
// 8. 用户操作:从角色库中选择角色替换当前角色
|
||||
const selectedLibraryRole = mockLibraryRoles[0];
|
||||
(replaceRole as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
const result = await replaceRole({
|
||||
const replaceResult = await replaceRole({
|
||||
currentRoleId: 'role1',
|
||||
replaceRoleId: 'newRoleId'
|
||||
replaceRoleId: 'libraryRole1'
|
||||
});
|
||||
expect(replaceResult.successful).toBe(true);
|
||||
|
||||
expect(replaceRole).toHaveBeenCalledWith({
|
||||
currentRoleId: 'role1',
|
||||
replaceRoleId: 'newRoleId'
|
||||
});
|
||||
expect(result.successful).toBe(true);
|
||||
});
|
||||
|
||||
it('替换角色失败时应该返回错误信息', async () => {
|
||||
(replaceRole as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '替换失败',
|
||||
});
|
||||
|
||||
const result = await replaceRole({
|
||||
currentRoleId: 'role1',
|
||||
replaceRoleId: 'newRoleId'
|
||||
});
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
expect(result.message).toBe('替换失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色形象应用到多个分镜测试', () => {
|
||||
it('应该成功获取角色分镜列表', async () => {
|
||||
// 9. 用户操作:获取角色应用到的分镜列表
|
||||
const mockShots = [mockShotEntity];
|
||||
(getRoleShots as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
@ -397,15 +377,11 @@ describe('RoleService 业务逻辑测试', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getRoleShots({ roleId: 'role1' });
|
||||
const shotsResult = await getRoleShots({ roleId: 'role1' });
|
||||
expect(shotsResult.successful).toBe(true);
|
||||
expect(shotsResult.data.shots).toEqual(mockShots);
|
||||
|
||||
expect(getRoleShots).toHaveBeenCalledWith({ roleId: 'role1' });
|
||||
expect(result.successful).toBe(true);
|
||||
expect(result.data.shots).toEqual(mockShots);
|
||||
expect(result.data.appliedShotIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该成功应用角色到选中的分镜', async () => {
|
||||
// 10. 用户操作:选择分镜并应用角色
|
||||
(applyRoleToShots as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
@ -413,31 +389,360 @@ describe('RoleService 业务逻辑测试', () => {
|
||||
|
||||
mockRoleEditUseCase.applyRole.mockResolvedValue({} as any);
|
||||
|
||||
const result = await applyRoleToShots({
|
||||
const applyResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: ['shot1', 'shot2']
|
||||
});
|
||||
|
||||
expect(applyRoleToShots).toHaveBeenCalledWith({
|
||||
roleId: 'role1',
|
||||
shotIds: ['shot1', 'shot2']
|
||||
});
|
||||
expect(result.successful).toBe(true);
|
||||
expect(applyResult.successful).toBe(true);
|
||||
});
|
||||
|
||||
it('应用角色失败时应该返回错误信息', async () => {
|
||||
(applyRoleToShots as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '应用失败',
|
||||
it('应该验证角色库替换时只保留ID,其他数据都被替换', async () => {
|
||||
// 当前角色
|
||||
const currentRole = {
|
||||
id: 'currentRoleId',
|
||||
name: '当前角色',
|
||||
generateTextId: 'currentTextId',
|
||||
tagIds: ['currentTag1'],
|
||||
imageUrl: 'http://example.com/current.jpg',
|
||||
updatedAt: Date.now(),
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
isStored: false,
|
||||
};
|
||||
|
||||
// 角色库中的角色
|
||||
const libraryRole = {
|
||||
id: 'libraryRoleId',
|
||||
name: '库角色',
|
||||
generateTextId: 'libraryTextId',
|
||||
tagIds: ['libraryTag1', 'libraryTag2'],
|
||||
imageUrl: 'http://example.com/library.jpg',
|
||||
updatedAt: Date.now() + 1000,
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
isStored: true,
|
||||
};
|
||||
|
||||
// 模拟替换操作
|
||||
(replaceRole as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
const result = await applyRoleToShots({
|
||||
const replaceResult = await replaceRole({
|
||||
currentRoleId: currentRole.id,
|
||||
replaceRoleId: libraryRole.id
|
||||
});
|
||||
expect(replaceResult.successful).toBe(true);
|
||||
|
||||
// 验证替换后的角色数据:ID保持不变,其他数据来自库角色
|
||||
const replacedRole = {
|
||||
...libraryRole,
|
||||
id: currentRole.id, // 只有ID保持不变
|
||||
};
|
||||
|
||||
expect(replacedRole.id).toBe(currentRole.id); // ID不变
|
||||
expect(replacedRole.name).toBe(libraryRole.name); // 其他数据来自库角色
|
||||
expect(replacedRole.generateTextId).toBe(libraryRole.generateTextId);
|
||||
expect(replacedRole.tagIds).toEqual(libraryRole.tagIds);
|
||||
expect(replacedRole.imageUrl).toBe(libraryRole.imageUrl);
|
||||
expect(replacedRole.isStored).toBe(libraryRole.isStored);
|
||||
});
|
||||
|
||||
it('应该模拟用户选择角色并修改提示词的完整流程', async () => {
|
||||
// 用户操作:获取项目中的角色列表
|
||||
const mockRoles = [
|
||||
{ ...mockRoleEntity, id: 'role1', name: '角色1' },
|
||||
{ ...mockRoleEntity, id: 'role2', name: '角色2' }
|
||||
];
|
||||
(getRoleList as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: mockRoles,
|
||||
});
|
||||
|
||||
const roleList = await getRoleList({ projectId: 'project1' });
|
||||
expect(roleList.data).toHaveLength(2);
|
||||
|
||||
// 用户操作:选择第一个角色
|
||||
const selectedRoleId = 'role1';
|
||||
(getRoleData as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: {
|
||||
text: mockTextEntity,
|
||||
tags: [mockTagEntity1],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRoleData = await getRoleData({ roleId: selectedRoleId });
|
||||
expect(selectedRoleData.data.text.id).toBe('text1');
|
||||
|
||||
// 用户操作:修改角色提示词
|
||||
const newPrompt = '我想要一个更加勇敢的角色';
|
||||
(updateText as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { ...mockTextEntity, content: newPrompt },
|
||||
});
|
||||
|
||||
const updatedText = await updateText({
|
||||
textId: 'text1',
|
||||
content: newPrompt
|
||||
});
|
||||
expect(updatedText.data.content).toBe(newPrompt);
|
||||
|
||||
// 用户操作:使用新提示词重新生成角色
|
||||
const regeneratedRole = { ...mockRoleEntity, name: '勇敢的角色' };
|
||||
(regenerateRole as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: regeneratedRole,
|
||||
});
|
||||
|
||||
const regenerationResult = await regenerateRole({
|
||||
prompt: newPrompt,
|
||||
tagTypes: ['tag1'],
|
||||
roleId: selectedRoleId
|
||||
});
|
||||
expect(regenerationResult.data.name).toBe('勇敢的角色');
|
||||
});
|
||||
|
||||
it('应该处理角色编辑流程中的错误情况', async () => {
|
||||
// 模拟获取角色列表失败
|
||||
(getRoleList as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '获取角色列表失败',
|
||||
});
|
||||
|
||||
const roleListResult = await getRoleList({ projectId: 'project1' });
|
||||
expect(roleListResult.successful).toBe(false);
|
||||
expect(roleListResult.message).toBe('获取角色列表失败');
|
||||
|
||||
// 模拟修改文本失败
|
||||
(updateText as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '修改文本失败',
|
||||
});
|
||||
|
||||
const updateTextResult = await updateText({
|
||||
textId: 'text1',
|
||||
content: '新的文本内容'
|
||||
});
|
||||
expect(updateTextResult.successful).toBe(false);
|
||||
expect(updateTextResult.message).toBe('修改文本失败');
|
||||
|
||||
// 模拟重新生成角色失败
|
||||
(regenerateRole as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '重新生成角色失败',
|
||||
});
|
||||
|
||||
const regenerateResult = await regenerateRole({
|
||||
prompt: '重新生成角色',
|
||||
tagTypes: ['tag1'],
|
||||
roleId: 'role1'
|
||||
});
|
||||
expect(regenerateResult.successful).toBe(false);
|
||||
expect(regenerateResult.message).toBe('重新生成角色失败');
|
||||
|
||||
// 模拟替换角色失败
|
||||
(replaceRole as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '替换角色失败',
|
||||
});
|
||||
|
||||
const replaceResult = await replaceRole({
|
||||
currentRoleId: 'role1',
|
||||
replaceRoleId: 'newRoleId'
|
||||
});
|
||||
expect(replaceResult.successful).toBe(false);
|
||||
expect(replaceResult.message).toBe('替换角色失败');
|
||||
|
||||
// 模拟应用角色失败
|
||||
(applyRoleToShots as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '应用角色失败',
|
||||
});
|
||||
|
||||
const applyResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: ['shot1']
|
||||
});
|
||||
expect(applyResult.successful).toBe(false);
|
||||
expect(applyResult.message).toBe('应用角色失败');
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
expect(result.message).toBe('应用失败');
|
||||
describe('角色库业务流程测试', () => {
|
||||
it('应该完成角色库选择和使用流程:获取角色库→选择角色→替换当前角色→验证数据替换', async () => {
|
||||
// 1. 用户操作:获取用户角色库
|
||||
const mockLibraryRoles = [
|
||||
{ ...mockRoleEntity, id: 'libraryRole1', name: '库角色1', isStored: true },
|
||||
{ ...mockRoleEntity, id: 'libraryRole2', name: '库角色2', isStored: true }
|
||||
];
|
||||
(getUserRoleLibrary as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: mockLibraryRoles,
|
||||
});
|
||||
|
||||
const libraryResult = await getUserRoleLibrary();
|
||||
expect(libraryResult.successful).toBe(true);
|
||||
expect(libraryResult.data).toEqual(mockLibraryRoles);
|
||||
|
||||
// 2. 用户操作:从角色库中选择角色
|
||||
const selectedLibraryRole = mockLibraryRoles[0];
|
||||
expect(selectedLibraryRole.isStored).toBe(true); // 验证是库中的角色
|
||||
|
||||
// 3. 用户操作:替换当前角色
|
||||
(replaceRole as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
const replaceResult = await replaceRole({
|
||||
currentRoleId: 'role1',
|
||||
replaceRoleId: selectedLibraryRole.id
|
||||
});
|
||||
expect(replaceResult.successful).toBe(true);
|
||||
|
||||
// 4. 验证替换后的角色数据:ID保持不变,其他数据来自库角色
|
||||
const currentRoleId = 'role1';
|
||||
const replacedRole = {
|
||||
...selectedLibraryRole,
|
||||
id: currentRoleId, // 只有ID保持不变
|
||||
};
|
||||
|
||||
expect(replacedRole.id).toBe(currentRoleId); // ID不变
|
||||
expect(replacedRole.name).toBe(selectedLibraryRole.name); // 其他数据来自库角色
|
||||
expect(replacedRole.generateTextId).toBe(selectedLibraryRole.generateTextId);
|
||||
expect(replacedRole.tagIds).toEqual(selectedLibraryRole.tagIds);
|
||||
expect(replacedRole.imageUrl).toBe(selectedLibraryRole.imageUrl);
|
||||
expect(replacedRole.isStored).toBe(selectedLibraryRole.isStored);
|
||||
});
|
||||
|
||||
it('应该验证角色库替换的边界情况', async () => {
|
||||
// 测试空角色库
|
||||
(getUserRoleLibrary as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: [],
|
||||
});
|
||||
|
||||
const emptyLibraryResult = await getUserRoleLibrary();
|
||||
expect(emptyLibraryResult.data).toHaveLength(0);
|
||||
|
||||
// 测试替换不存在的角色
|
||||
(replaceRole as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '角色不存在',
|
||||
});
|
||||
|
||||
const invalidReplaceResult = await replaceRole({
|
||||
currentRoleId: 'nonexistentRole',
|
||||
replaceRoleId: 'libraryRole1'
|
||||
});
|
||||
expect(invalidReplaceResult.successful).toBe(false);
|
||||
expect(invalidReplaceResult.message).toBe('角色不存在');
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色应用到分镜业务流程测试', () => {
|
||||
it('应该完成角色应用到分镜的完整流程:获取分镜列表→选择分镜→应用角色→验证应用状态', async () => {
|
||||
// 1. 用户操作:获取角色应用到的分镜列表
|
||||
const mockShots = [
|
||||
{ ...mockShotEntity, id: 'shot1', name: '分镜1' },
|
||||
{ ...mockShotEntity, id: 'shot2', name: '分镜2' },
|
||||
{ ...mockShotEntity, id: 'shot3', name: '分镜3' }
|
||||
];
|
||||
(getRoleShots as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: {
|
||||
shots: mockShots,
|
||||
appliedShotIds: ['shot1'], // 分镜1已经应用了角色
|
||||
},
|
||||
});
|
||||
|
||||
const shotsResult = await getRoleShots({ roleId: 'role1' });
|
||||
expect(shotsResult.successful).toBe(true);
|
||||
expect(shotsResult.data.shots).toEqual(mockShots);
|
||||
expect(shotsResult.data.appliedShotIds).toEqual(['shot1']);
|
||||
|
||||
// 2. 用户操作:选择未应用的分镜
|
||||
const unappliedShots = mockShots.filter(shot => !shotsResult.data.appliedShotIds.includes(shot.id));
|
||||
expect(unappliedShots).toHaveLength(2); // shot2, shot3 未应用
|
||||
|
||||
// 3. 用户操作:应用角色到选中的分镜
|
||||
const selectedShotIds = ['shot2', 'shot3'];
|
||||
(applyRoleToShots as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
mockRoleEditUseCase.applyRole.mockResolvedValue({} as any);
|
||||
|
||||
const applyResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: selectedShotIds
|
||||
});
|
||||
expect(applyResult.successful).toBe(true);
|
||||
|
||||
// 4. 验证应用后的状态
|
||||
const updatedAppliedShotIds = ['shot1', 'shot2', 'shot3']; // 所有分镜都已应用
|
||||
expect(updatedAppliedShotIds).toContain('shot1'); // 原来已应用的
|
||||
expect(updatedAppliedShotIds).toContain('shot2'); // 新应用的
|
||||
expect(updatedAppliedShotIds).toContain('shot3'); // 新应用的
|
||||
});
|
||||
|
||||
it('应该处理分镜应用的各种情况', async () => {
|
||||
// 测试应用空分镜列表
|
||||
(applyRoleToShots as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
const emptyApplyResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: []
|
||||
});
|
||||
expect(emptyApplyResult.successful).toBe(true);
|
||||
|
||||
// 测试应用单个分镜
|
||||
const singleApplyResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: ['shot1']
|
||||
});
|
||||
expect(singleApplyResult.successful).toBe(true);
|
||||
|
||||
// 测试应用多个分镜
|
||||
const multipleApplyResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: ['shot1', 'shot2', 'shot3']
|
||||
});
|
||||
expect(multipleApplyResult.successful).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理分镜应用失败的情况', async () => {
|
||||
// 测试应用失败
|
||||
(applyRoleToShots as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '应用角色失败',
|
||||
});
|
||||
|
||||
const failedApplyResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: ['shot1']
|
||||
});
|
||||
expect(failedApplyResult.successful).toBe(false);
|
||||
expect(failedApplyResult.message).toBe('应用角色失败');
|
||||
|
||||
// 测试部分分镜应用失败
|
||||
(applyRoleToShots as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '部分分镜应用失败',
|
||||
});
|
||||
|
||||
const partialFailedResult = await applyRoleToShots({
|
||||
roleId: 'role1',
|
||||
shotIds: ['shot1', 'shot2']
|
||||
});
|
||||
expect(partialFailedResult.successful).toBe(false);
|
||||
expect(partialFailedResult.message).toBe('部分分镜应用失败');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -75,7 +75,7 @@ describe('SceneService 业务逻辑测试', () => {
|
||||
id: 'shot1',
|
||||
name: '分镜1',
|
||||
sketchUrl: 'http://example.com/sketch1.jpg',
|
||||
videoUrl: 'http://example.com/video1.mp4',
|
||||
videoUrl: ['http://example.com/video1.mp4'],
|
||||
roleList: [],
|
||||
sceneList: [],
|
||||
content: [],
|
||||
@ -300,86 +300,226 @@ describe('SceneService 业务逻辑测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('重新获取场景数据测试', () => {
|
||||
it('应该成功重新获取场景数据并更新实体', async () => {
|
||||
const mockTextEntity = {
|
||||
id: 'text1',
|
||||
content: '更新后的场景AI文本',
|
||||
updatedAt: Date.now(),
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
};
|
||||
describe('场景业务流程测试', () => {
|
||||
it('应该完成完整的场景编辑流程:获取列表→选择场景→修改提示词→智能优化→修改标签→重新生成→应用场景', async () => {
|
||||
// 模拟用户操作:获取场景列表
|
||||
const mockScenes = [mockSceneEntity];
|
||||
(getSceneList as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: mockScenes,
|
||||
message: 'success',
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{
|
||||
id: 'tag1',
|
||||
name: '更新场景标签1',
|
||||
content: '更新场景内容1',
|
||||
updatedAt: Date.now(),
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
},
|
||||
{
|
||||
id: 'tag2',
|
||||
name: '更新场景标签2',
|
||||
content: '更新场景内容2',
|
||||
updatedAt: Date.now(),
|
||||
loadingProgress: 100,
|
||||
disableEdit: false,
|
||||
}
|
||||
];
|
||||
const sceneListResult = await getSceneList({ projectId: 'project1' });
|
||||
expect(sceneListResult.successful).toBe(true);
|
||||
expect(sceneListResult.data).toEqual(mockScenes);
|
||||
|
||||
// 模拟用户操作:选择场景并获取场景数据
|
||||
(getSceneData as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: {
|
||||
text: mockTextEntity,
|
||||
tags: mockTags,
|
||||
tags: [mockTagEntity1, mockTagEntity2],
|
||||
},
|
||||
});
|
||||
|
||||
// 模拟SceneItem的setEntity方法
|
||||
const mockSetEntity = jest.fn();
|
||||
(SceneItem as jest.MockedClass<typeof SceneItem>).mockImplementation((entity) => ({
|
||||
entity,
|
||||
metadata: {},
|
||||
disableEdit: entity.disableEdit,
|
||||
type: 3,
|
||||
setEntity: mockSetEntity,
|
||||
} as any));
|
||||
const sceneDataResult = await getSceneData({ sceneId: 'scene1' });
|
||||
expect(sceneDataResult.successful).toBe(true);
|
||||
expect(sceneDataResult.data.text).toEqual(mockTextEntity);
|
||||
expect(sceneDataResult.data.tags).toEqual([mockTagEntity1, mockTagEntity2]);
|
||||
|
||||
const sceneItem = new SceneItem(mockSceneEntity);
|
||||
const useCase = new SceneEditUseCase(sceneItem);
|
||||
|
||||
const result = await useCase.refreshSceneData();
|
||||
|
||||
expect(getSceneData).toHaveBeenCalledWith({ sceneId: 'scene1' });
|
||||
expect(result.text).toEqual(mockTextEntity);
|
||||
expect(result.tags).toEqual(mockTags);
|
||||
expect(mockSetEntity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
generateTextId: 'text1',
|
||||
tagIds: ['tag1', 'tag2'],
|
||||
updatedAt: expect.any(Number),
|
||||
}));
|
||||
});
|
||||
|
||||
it('场景ID不存在时应该抛出错误', async () => {
|
||||
const emptySceneEntity = { ...mockSceneEntity, id: '' };
|
||||
const sceneItem = new SceneItem(emptySceneEntity);
|
||||
const useCase = new SceneEditUseCase(sceneItem);
|
||||
|
||||
await expect(useCase.refreshSceneData()).rejects.toThrow('场景ID不存在,无法获取场景数据');
|
||||
});
|
||||
|
||||
it('API调用失败时应该抛出错误', async () => {
|
||||
(getSceneData as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '获取失败',
|
||||
// 模拟用户操作:修改场景提示词
|
||||
const updatedTextEntity = { ...mockTextEntity, content: '修改后的场景提示词' };
|
||||
(updateText as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: updatedTextEntity,
|
||||
});
|
||||
|
||||
const sceneItem = new SceneItem(mockSceneEntity);
|
||||
const useCase = new SceneEditUseCase(sceneItem);
|
||||
const updateTextResult = await updateText({
|
||||
textId: 'text1',
|
||||
content: '修改后的场景提示词'
|
||||
});
|
||||
expect(updateTextResult.successful).toBe(true);
|
||||
expect(updateTextResult.data.content).toBe('修改后的场景提示词');
|
||||
|
||||
await expect(useCase.refreshSceneData()).rejects.toThrow('获取场景数据失败: 获取失败');
|
||||
// 模拟用户操作:智能优化文本
|
||||
const optimizedContent = '智能优化后的场景文本';
|
||||
mockTextEditUseCase.getOptimizedContent.mockResolvedValue(optimizedContent);
|
||||
mockTextEditUseCase.updateText.mockResolvedValue({
|
||||
entity: { ...mockTextEntity, content: optimizedContent },
|
||||
metadata: {},
|
||||
disableEdit: false,
|
||||
type: 0,
|
||||
} as any);
|
||||
|
||||
const optimizedContentResult = await mockTextEditUseCase.getOptimizedContent();
|
||||
expect(optimizedContentResult).toBe(optimizedContent);
|
||||
|
||||
// 模拟用户操作:修改标签
|
||||
const updatedTagEntity = { ...mockTagEntity1, content: '修改后的标签内容' };
|
||||
(updateTag as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: updatedTagEntity,
|
||||
});
|
||||
|
||||
const updateTagResult = await updateTag({
|
||||
tagId: 'tag1',
|
||||
content: '修改后的标签内容'
|
||||
});
|
||||
expect(updateTagResult.successful).toBe(true);
|
||||
expect(updateTagResult.data.content).toBe('修改后的标签内容');
|
||||
|
||||
// 模拟用户操作:使用新的提示词和标签重新生成场景
|
||||
const newSceneEntity = { ...mockSceneEntity, id: 'scene2', name: '重新生成的场景' };
|
||||
(regenerateScene as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: newSceneEntity,
|
||||
});
|
||||
|
||||
mockSceneEditUseCase.AIgenerateScene.mockResolvedValue(newSceneEntity);
|
||||
|
||||
const regenerateResult = await regenerateScene({
|
||||
prompt: '使用新的提示词重新生成场景',
|
||||
tagTypes: ['tag1', 'tag2'],
|
||||
sceneId: 'scene1'
|
||||
});
|
||||
expect(regenerateResult.successful).toBe(true);
|
||||
expect(regenerateResult.data.name).toBe('重新生成的场景');
|
||||
|
||||
// 模拟用户操作:获取场景应用到的分镜列表
|
||||
const mockShots = [mockShotEntity];
|
||||
(getSceneShots as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: {
|
||||
shots: mockShots,
|
||||
appliedShotIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const shotsResult = await getSceneShots({ sceneId: 'scene1' });
|
||||
expect(shotsResult.successful).toBe(true);
|
||||
expect(shotsResult.data.shots).toEqual(mockShots);
|
||||
|
||||
// 模拟用户操作:选择分镜并应用新的场景
|
||||
(applySceneToShots as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
mockSceneEditUseCase.applyScene.mockResolvedValue({} as any);
|
||||
|
||||
const applyResult = await applySceneToShots({
|
||||
sceneId: 'scene1',
|
||||
shotIds: ['shot1', 'shot2']
|
||||
});
|
||||
expect(applyResult.successful).toBe(true);
|
||||
});
|
||||
|
||||
it('应该模拟用户选择场景并修改提示词的完整流程', async () => {
|
||||
// 用户操作:获取项目中的场景列表
|
||||
const mockScenes = [
|
||||
{ ...mockSceneEntity, id: 'scene1', name: '场景1' },
|
||||
{ ...mockSceneEntity, id: 'scene2', name: '场景2' }
|
||||
];
|
||||
(getSceneList as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: mockScenes,
|
||||
});
|
||||
|
||||
const sceneList = await getSceneList({ projectId: 'project1' });
|
||||
expect(sceneList.data).toHaveLength(2);
|
||||
|
||||
// 用户操作:选择第一个场景
|
||||
const selectedSceneId = 'scene1';
|
||||
(getSceneData as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: {
|
||||
text: mockTextEntity,
|
||||
tags: [mockTagEntity1],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedSceneData = await getSceneData({ sceneId: selectedSceneId });
|
||||
expect(selectedSceneData.data.text.id).toBe('text1');
|
||||
|
||||
// 用户操作:修改场景提示词
|
||||
const newPrompt = '我想要一个更加戏剧性的场景';
|
||||
(updateText as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { ...mockTextEntity, content: newPrompt },
|
||||
});
|
||||
|
||||
const updatedText = await updateText({
|
||||
textId: 'text1',
|
||||
content: newPrompt
|
||||
});
|
||||
expect(updatedText.data.content).toBe(newPrompt);
|
||||
|
||||
// 用户操作:使用新提示词重新生成场景
|
||||
const regeneratedScene = { ...mockSceneEntity, name: '戏剧性场景' };
|
||||
(regenerateScene as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: regeneratedScene,
|
||||
});
|
||||
|
||||
const regenerationResult = await regenerateScene({
|
||||
prompt: newPrompt,
|
||||
tagTypes: ['tag1'],
|
||||
sceneId: selectedSceneId
|
||||
});
|
||||
expect(regenerationResult.data.name).toBe('戏剧性场景');
|
||||
});
|
||||
|
||||
it('应该处理场景编辑流程中的错误情况', async () => {
|
||||
// 模拟获取场景列表失败
|
||||
(getSceneList as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '获取场景列表失败',
|
||||
});
|
||||
|
||||
const sceneListResult = await getSceneList({ projectId: 'project1' });
|
||||
expect(sceneListResult.successful).toBe(false);
|
||||
expect(sceneListResult.message).toBe('获取场景列表失败');
|
||||
|
||||
// 模拟修改文本失败
|
||||
(updateText as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '修改文本失败',
|
||||
});
|
||||
|
||||
const updateTextResult = await updateText({
|
||||
textId: 'text1',
|
||||
content: '新的文本内容'
|
||||
});
|
||||
expect(updateTextResult.successful).toBe(false);
|
||||
expect(updateTextResult.message).toBe('修改文本失败');
|
||||
|
||||
// 模拟重新生成场景失败
|
||||
(regenerateScene as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '重新生成场景失败',
|
||||
});
|
||||
|
||||
const regenerateResult = await regenerateScene({
|
||||
prompt: '重新生成场景',
|
||||
tagTypes: ['tag1'],
|
||||
sceneId: 'scene1'
|
||||
});
|
||||
expect(regenerateResult.successful).toBe(false);
|
||||
expect(regenerateResult.message).toBe('重新生成场景失败');
|
||||
|
||||
// 模拟应用场景失败
|
||||
(applySceneToShots as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '应用场景失败',
|
||||
});
|
||||
|
||||
const applyResult = await applySceneToShots({
|
||||
sceneId: 'scene1',
|
||||
shotIds: ['shot1']
|
||||
});
|
||||
expect(applyResult.successful).toBe(false);
|
||||
expect(applyResult.message).toBe('应用场景失败');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
484
app/service/test/ScriptService.test.ts
Normal file
484
app/service/test/ScriptService.test.ts
Normal file
@ -0,0 +1,484 @@
|
||||
import { generateScriptStream, applyScriptToShot, getProjectScript } from '../../../api/video_flow';
|
||||
import { ScriptEditUseCase } from '../usecase/ScriptEditUseCase';
|
||||
import { ScriptSlice, ScriptSliceType } from '../domain/valueObject';
|
||||
|
||||
// Mock API模块
|
||||
jest.mock('@/api/video_flow', () => ({
|
||||
generateScriptStream: jest.fn(),
|
||||
applyScriptToShot: jest.fn(),
|
||||
getProjectScript: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock UseCase模块
|
||||
jest.mock('../usecase/ScriptEditUseCase');
|
||||
|
||||
describe('ScriptService 业务逻辑测试', () => {
|
||||
let mockScriptEditUseCase: jest.Mocked<ScriptEditUseCase>;
|
||||
|
||||
// 测试数据
|
||||
const mockScriptSlices: ScriptSlice[] = [
|
||||
{
|
||||
id: 'slice1',
|
||||
type: ScriptSliceType.role,
|
||||
text: '你好,我是主角',
|
||||
metaData: { speaker: '主角', emotion: '友好' }
|
||||
},
|
||||
{
|
||||
id: 'slice2',
|
||||
type: ScriptSliceType.scene,
|
||||
text: '场景:阳光明媚的公园',
|
||||
metaData: { location: '公园', weather: '晴天' }
|
||||
},
|
||||
{
|
||||
id: 'slice3',
|
||||
type: ScriptSliceType.text,
|
||||
text: '这是一个温馨的故事',
|
||||
metaData: { style: '温馨' }
|
||||
}
|
||||
];
|
||||
|
||||
const mockStreamData = [
|
||||
{ id: 'slice1', type: 'role', text: '你好,我是主角', metaData: { speaker: '主角' } },
|
||||
{ id: 'slice2', type: 'scene', text: '场景:阳光明媚的公园', metaData: { location: '公园' } },
|
||||
{ id: 'slice3', type: 'text', text: '这是一个温馨的故事', metaData: { style: '温馨' } }
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// 设置Mock UseCase实例
|
||||
mockScriptEditUseCase = {
|
||||
generateScript: jest.fn(),
|
||||
applyScript: jest.fn(),
|
||||
updateScriptSlice: jest.fn(),
|
||||
getScriptSlices: jest.fn(),
|
||||
toString: jest.fn(),
|
||||
} as any;
|
||||
|
||||
// 设置Mock构造函数
|
||||
(ScriptEditUseCase as jest.MockedClass<typeof ScriptEditUseCase>).mockImplementation(() => mockScriptEditUseCase);
|
||||
|
||||
// 设置默认的Mock返回值
|
||||
mockScriptEditUseCase.getScriptSlices.mockReturnValue(mockScriptSlices);
|
||||
mockScriptEditUseCase.toString.mockReturnValue('你好,我是主角\n场景:阳光明媚的公园\n这是一个温馨的故事');
|
||||
mockScriptEditUseCase.updateScriptSlice.mockReturnValue(true);
|
||||
mockScriptEditUseCase.generateScript.mockResolvedValue();
|
||||
mockScriptEditUseCase.applyScript.mockResolvedValue();
|
||||
|
||||
// Mock流式数据
|
||||
(generateScriptStream as jest.Mock).mockResolvedValue(mockStreamData);
|
||||
(applyScriptToShot as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: { success: true },
|
||||
});
|
||||
(getProjectScript as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: {
|
||||
prompt: '创建一个关于友谊的温馨故事',
|
||||
scriptText: '你好,我是主角\n场景:阳光明媚的公园\n这是一个温馨的故事'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本业务流程测试', () => {
|
||||
it('应该完成完整的剧本编辑流程:根据提示词生成剧本→修改提示词重新生成→重置提示词重新生成→修改剧本→应用剧本', async () => {
|
||||
// 1. 用户操作:根据提示词生成新剧本
|
||||
const initialPrompt = '创建一个关于友谊的温馨故事';
|
||||
|
||||
// 模拟AI生成剧本的流式数据
|
||||
const mockStream = (async function* () {
|
||||
for (const data of mockStreamData) {
|
||||
yield data;
|
||||
}
|
||||
})();
|
||||
|
||||
(generateScriptStream as jest.Mock).mockResolvedValue(mockStream);
|
||||
mockScriptEditUseCase.generateScript.mockImplementation(async (prompt) => {
|
||||
// 模拟流式处理
|
||||
for await (const chunk of mockStream) {
|
||||
// 这里可以模拟逐步添加剧本片段
|
||||
}
|
||||
});
|
||||
|
||||
// 创建剧本编辑用例并生成剧本
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
await scriptEditUseCase.generateScript(initialPrompt);
|
||||
|
||||
expect(ScriptEditUseCase).toHaveBeenCalledWith('');
|
||||
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(initialPrompt);
|
||||
|
||||
// 验证生成的剧本内容
|
||||
const generatedScript = scriptEditUseCase.toString();
|
||||
expect(generatedScript).toContain('你好,我是主角');
|
||||
expect(generatedScript).toContain('场景:阳光明媚的公园');
|
||||
|
||||
// 验证剧本片段
|
||||
const scriptSlices = scriptEditUseCase.getScriptSlices();
|
||||
expect(scriptSlices).toHaveLength(3);
|
||||
expect(scriptSlices[0].type).toBe(ScriptSliceType.role);
|
||||
expect(scriptSlices[1].type).toBe(ScriptSliceType.scene);
|
||||
|
||||
// 2. 用户操作:修改提示词重新生成剧本
|
||||
const modifiedPrompt = '创建一个关于冒险的刺激故事';
|
||||
|
||||
// 模拟修改后的剧本数据
|
||||
const modifiedScriptSlices: ScriptSlice[] = [
|
||||
{
|
||||
id: 'slice1',
|
||||
type: ScriptSliceType.role,
|
||||
text: '我们出发吧!',
|
||||
metaData: { speaker: '冒险者', emotion: '兴奋' }
|
||||
},
|
||||
{
|
||||
id: 'slice2',
|
||||
type: ScriptSliceType.scene,
|
||||
text: '场景:神秘的森林',
|
||||
metaData: { location: '森林', atmosphere: '神秘' }
|
||||
},
|
||||
{
|
||||
id: 'slice3',
|
||||
type: ScriptSliceType.text,
|
||||
text: '这是一个充满挑战的冒险',
|
||||
metaData: { style: '冒险' }
|
||||
}
|
||||
];
|
||||
|
||||
mockScriptEditUseCase.getScriptSlices.mockReturnValue(modifiedScriptSlices);
|
||||
mockScriptEditUseCase.toString.mockReturnValue('我们出发吧!\n场景:神秘的森林\n这是一个充满挑战的冒险');
|
||||
|
||||
// 重新生成剧本
|
||||
await scriptEditUseCase.generateScript(modifiedPrompt);
|
||||
|
||||
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(modifiedPrompt);
|
||||
|
||||
// 验证修改后的剧本内容
|
||||
const modifiedScript = scriptEditUseCase.toString();
|
||||
expect(modifiedScript).toContain('我们出发吧!');
|
||||
expect(modifiedScript).toContain('神秘的森林');
|
||||
expect(modifiedScript).toContain('充满挑战的冒险');
|
||||
|
||||
// 3. 用户操作:重置提示词重新生成剧本
|
||||
const resetPrompt = '创建一个关于友谊的温馨故事'; // 回到初始提示词
|
||||
|
||||
// 模拟重置后的剧本数据(回到初始状态)
|
||||
mockScriptEditUseCase.getScriptSlices.mockReturnValue(mockScriptSlices);
|
||||
mockScriptEditUseCase.toString.mockReturnValue('你好,我是主角\n场景:阳光明媚的公园\n这是一个温馨的故事');
|
||||
|
||||
// 重置并重新生成剧本
|
||||
await scriptEditUseCase.generateScript(resetPrompt);
|
||||
|
||||
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(resetPrompt);
|
||||
|
||||
// 验证重置后的剧本内容
|
||||
const resetScript = scriptEditUseCase.toString();
|
||||
expect(resetScript).toContain('你好,我是主角');
|
||||
expect(resetScript).toContain('阳光明媚的公园');
|
||||
expect(resetScript).toContain('温馨的故事');
|
||||
|
||||
// 4. 用户操作:修改剧本片段
|
||||
const updatedText = '你好,我是勇敢的主角';
|
||||
const updatedMetaData = { speaker: '主角', emotion: '勇敢' };
|
||||
|
||||
// 修改第一个剧本片段
|
||||
const updateSuccess = scriptEditUseCase.updateScriptSlice('slice1', updatedText, updatedMetaData);
|
||||
expect(updateSuccess).toBe(true);
|
||||
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledWith('slice1', updatedText, updatedMetaData);
|
||||
|
||||
// 验证修改后的片段
|
||||
const updatedSlices = scriptEditUseCase.getScriptSlices();
|
||||
// 注意:mock返回的数据不会因为updateScriptSlice调用而改变
|
||||
// 这里只是验证getScriptSlices被正确调用
|
||||
expect(updatedSlices).toEqual(mockScriptSlices);
|
||||
|
||||
// 5. 用户操作:应用剧本到分镜
|
||||
// 应用剧本
|
||||
await scriptEditUseCase.applyScript();
|
||||
|
||||
expect(mockScriptEditUseCase.applyScript).toHaveBeenCalled();
|
||||
|
||||
// 验证API调用
|
||||
expect(applyScriptToShot).toHaveBeenCalledWith({
|
||||
scriptSlices: updatedSlices
|
||||
});
|
||||
});
|
||||
|
||||
it('应该验证剧本生成的不同提示词效果', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
// 测试不同类型的提示词
|
||||
const testPrompts = [
|
||||
'创建一个浪漫的爱情故事',
|
||||
'创建一个悬疑的侦探故事',
|
||||
'创建一个科幻的未来故事'
|
||||
];
|
||||
|
||||
for (const prompt of testPrompts) {
|
||||
await scriptEditUseCase.generateScript(prompt);
|
||||
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(prompt);
|
||||
}
|
||||
|
||||
// 验证每个提示词都被正确处理
|
||||
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledTimes(testPrompts.length);
|
||||
});
|
||||
|
||||
it('应该验证剧本片段的修改和更新', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
await scriptEditUseCase.generateScript('测试剧本');
|
||||
|
||||
// 测试修改不同类型的剧本片段
|
||||
const testUpdates = [
|
||||
{
|
||||
id: 'slice1',
|
||||
text: '修改后的对话',
|
||||
metaData: { speaker: '新角色', emotion: '愤怒' }
|
||||
},
|
||||
{
|
||||
id: 'slice2',
|
||||
text: '修改后的场景描述',
|
||||
metaData: { location: '新地点', weather: '雨天' }
|
||||
},
|
||||
{
|
||||
id: 'slice3',
|
||||
text: '修改后的文本内容',
|
||||
metaData: { style: '新风格', tone: '严肃' }
|
||||
}
|
||||
];
|
||||
|
||||
for (const update of testUpdates) {
|
||||
const success = scriptEditUseCase.updateScriptSlice(update.id, update.text, update.metaData);
|
||||
expect(success).toBe(true);
|
||||
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledWith(
|
||||
update.id,
|
||||
update.text,
|
||||
update.metaData
|
||||
);
|
||||
}
|
||||
|
||||
// 验证所有修改都被正确处理
|
||||
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledTimes(testUpdates.length);
|
||||
});
|
||||
|
||||
it('应该处理剧本编辑流程中的错误情况', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
// 模拟生成剧本失败
|
||||
mockScriptEditUseCase.generateScript.mockRejectedValue(new Error('AI生成失败'));
|
||||
|
||||
await expect(scriptEditUseCase.generateScript('测试提示词')).rejects.toThrow('AI生成失败');
|
||||
|
||||
// 模拟更新剧本片段失败
|
||||
mockScriptEditUseCase.updateScriptSlice.mockReturnValue(false);
|
||||
|
||||
const updateResult = scriptEditUseCase.updateScriptSlice('nonexistent', '新文本');
|
||||
expect(updateResult).toBe(false);
|
||||
|
||||
// 模拟应用剧本失败
|
||||
mockScriptEditUseCase.applyScript.mockRejectedValue(new Error('应用剧本失败'));
|
||||
|
||||
await expect(scriptEditUseCase.applyScript()).rejects.toThrow('应用剧本失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本流式生成测试', () => {
|
||||
it('应该正确处理流式剧本生成', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
// 模拟流式数据生成
|
||||
const streamChunks = [
|
||||
{ id: 'slice1', type: 'role', text: '第一段对话', metaData: { speaker: '角色1' } },
|
||||
{ id: 'slice2', type: 'scene', text: '场景描述', metaData: { location: '地点1' } },
|
||||
{ id: 'slice3', type: 'text', text: '叙述文本', metaData: { style: '风格1' } }
|
||||
];
|
||||
|
||||
let chunkIndex = 0;
|
||||
const mockStream = (async function* () {
|
||||
for (const chunk of streamChunks) {
|
||||
yield chunk;
|
||||
chunkIndex++;
|
||||
}
|
||||
})();
|
||||
|
||||
(generateScriptStream as jest.Mock).mockResolvedValue(mockStream);
|
||||
|
||||
// 模拟流式处理
|
||||
mockScriptEditUseCase.generateScript.mockImplementation(async (prompt) => {
|
||||
for await (const chunk of mockStream) {
|
||||
// 模拟逐步处理每个片段
|
||||
// 注意:这里不需要验证chunk,因为mockStream已经包含了正确的数据
|
||||
}
|
||||
});
|
||||
|
||||
await scriptEditUseCase.generateScript('流式生成测试');
|
||||
|
||||
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith('流式生成测试');
|
||||
expect(chunkIndex).toBe(streamChunks.length);
|
||||
});
|
||||
|
||||
it('应该处理流式生成中的错误', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
// 模拟流式数据生成失败
|
||||
const mockErrorStream = (async function* () {
|
||||
throw new Error('流式生成错误');
|
||||
})();
|
||||
|
||||
(generateScriptStream as jest.Mock).mockResolvedValue(mockErrorStream);
|
||||
|
||||
mockScriptEditUseCase.generateScript.mockImplementation(async () => {
|
||||
for await (const chunk of mockErrorStream) {
|
||||
// 这里不会执行,因为流会抛出错误
|
||||
}
|
||||
});
|
||||
|
||||
await expect(scriptEditUseCase.generateScript('错误测试')).rejects.toThrow('流式生成错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本应用测试', () => {
|
||||
it('应该成功应用剧本到分镜', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
// 先生成剧本
|
||||
await scriptEditUseCase.generateScript('应用测试');
|
||||
|
||||
// 应用剧本
|
||||
await scriptEditUseCase.applyScript();
|
||||
|
||||
expect(mockScriptEditUseCase.applyScript).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理应用剧本失败的情况', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
// 模拟应用失败
|
||||
(applyScriptToShot as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '应用剧本失败',
|
||||
});
|
||||
|
||||
mockScriptEditUseCase.applyScript.mockRejectedValue(new Error('应用失败'));
|
||||
|
||||
await expect(scriptEditUseCase.applyScript()).rejects.toThrow('应用失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本状态管理测试', () => {
|
||||
it('应该正确管理剧本的生成状态', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
// 设置初始状态为空数组
|
||||
mockScriptEditUseCase.getScriptSlices.mockReturnValue([]);
|
||||
|
||||
// 初始状态应该是空的
|
||||
const initialSlices = scriptEditUseCase.getScriptSlices();
|
||||
expect(initialSlices).toEqual([]);
|
||||
|
||||
// 生成剧本后应该有内容
|
||||
await scriptEditUseCase.generateScript('状态测试');
|
||||
|
||||
// 重新设置mock返回值为有内容的数组
|
||||
mockScriptEditUseCase.getScriptSlices.mockReturnValue(mockScriptSlices);
|
||||
|
||||
const generatedSlices = scriptEditUseCase.getScriptSlices();
|
||||
expect(generatedSlices).toEqual(mockScriptSlices);
|
||||
|
||||
// 修改后状态应该更新
|
||||
scriptEditUseCase.updateScriptSlice('slice1', '新文本', undefined);
|
||||
|
||||
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledWith('slice1', '新文本', undefined);
|
||||
});
|
||||
|
||||
it('应该验证剧本文本的完整性', async () => {
|
||||
const scriptEditUseCase = new ScriptEditUseCase('');
|
||||
|
||||
await scriptEditUseCase.generateScript('完整性测试');
|
||||
|
||||
const scriptText = scriptEditUseCase.toString();
|
||||
const scriptSlices = scriptEditUseCase.getScriptSlices();
|
||||
|
||||
// 验证文本包含所有片段的内容
|
||||
for (const slice of scriptSlices) {
|
||||
expect(scriptText).toContain(slice.text);
|
||||
}
|
||||
|
||||
// 验证片段数量与文本行数匹配
|
||||
const textLines = scriptText.split('\n').filter(line => line.trim());
|
||||
expect(textLines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('应该根据项目ID获取已存在的剧本数据', async () => {
|
||||
const projectId = 'test-project-123';
|
||||
|
||||
// 模拟API返回项目剧本数据(只包含提示词和剧本文本)
|
||||
const mockProjectScriptData = {
|
||||
prompt: '创建一个关于冒险的故事',
|
||||
scriptText: '我们出发吧!\n场景:神秘的森林\n这是一个充满挑战的冒险'
|
||||
};
|
||||
|
||||
(getProjectScript as jest.Mock).mockResolvedValue({
|
||||
successful: true,
|
||||
data: mockProjectScriptData
|
||||
});
|
||||
|
||||
// 创建剧本编辑用例并模拟解析逻辑
|
||||
const scriptEditUseCase = new ScriptEditUseCase(mockProjectScriptData.scriptText);
|
||||
|
||||
// 模拟解析后的剧本片段(这是UseCase内部逻辑的结果)
|
||||
const mockParsedSlices = [
|
||||
{
|
||||
id: 'slice1',
|
||||
type: ScriptSliceType.role,
|
||||
text: '我们出发吧!',
|
||||
metaData: { speaker: '冒险者', emotion: '兴奋' }
|
||||
},
|
||||
{
|
||||
id: 'slice2',
|
||||
type: ScriptSliceType.scene,
|
||||
text: '场景:神秘的森林',
|
||||
metaData: { location: '森林', atmosphere: '神秘' }
|
||||
},
|
||||
{
|
||||
id: 'slice3',
|
||||
type: ScriptSliceType.text,
|
||||
text: '这是一个充满挑战的冒险',
|
||||
metaData: { style: '冒险' }
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟从项目获取剧本数据
|
||||
const response = await getProjectScript({ projectId });
|
||||
|
||||
expect(response.successful).toBe(true);
|
||||
expect(response.data.prompt).toBe('创建一个关于冒险的故事');
|
||||
expect(response.data.scriptText).toContain('我们出发吧!');
|
||||
|
||||
// 验证API只返回提示词和剧本文本,不包含解析后的片段
|
||||
expect(response.data).not.toHaveProperty('scriptSlices');
|
||||
|
||||
// 验证UseCase能够正确解析剧本文本
|
||||
const parsedSlices = scriptEditUseCase.getScriptSlices();
|
||||
// 注意:由于ScriptValueObject.parseFromString目前是TODO,这里只是验证调用不会出错
|
||||
expect(Array.isArray(parsedSlices)).toBe(true);
|
||||
|
||||
// 验证API调用参数
|
||||
expect(getProjectScript).toHaveBeenCalledWith({ projectId });
|
||||
});
|
||||
|
||||
it('应该处理获取项目剧本失败的情况', async () => {
|
||||
const projectId = 'invalid-project';
|
||||
|
||||
// 模拟API失败
|
||||
(getProjectScript as jest.Mock).mockResolvedValue({
|
||||
successful: false,
|
||||
message: '项目不存在'
|
||||
});
|
||||
|
||||
await expect(getProjectScript({ projectId })).resolves.toEqual({
|
||||
successful: false,
|
||||
message: '项目不存在'
|
||||
});
|
||||
|
||||
expect(getProjectScript).toHaveBeenCalledWith({ projectId });
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -35,9 +35,9 @@ const tabs = [
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function EditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
export function EditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
activeEditTab,
|
||||
taskStatus,
|
||||
taskSketch,
|
||||
@ -203,7 +203,7 @@ export function EditModal({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 弹窗操作按钮 */}
|
||||
<div className="pl-4 border-l border-white/10">
|
||||
{/* 关闭按钮 */}
|
||||
@ -235,7 +235,7 @@ export function EditModal({
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="p-4 border-t border-white/10 bg-black/20">
|
||||
{/* <div className="p-4 border-t border-white/10 bg-black/20">
|
||||
<div className="flex justify-end gap-3">
|
||||
<motion.button
|
||||
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
@ -253,11 +253,11 @@ export function EditModal({
|
||||
Save
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,145 +1,281 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { StoryboardCard as StoryboardCardType, mockStoryboards, mockSceneOptions } from '@/app/model/enums';
|
||||
import FilterBar from './filter-bar';
|
||||
import StoryboardCard from './storyboard-card';
|
||||
import { X, Check, ChevronDown } from 'lucide-react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { mockSceneOptions, mockCharacterOptions } from '@/app/model/enums';
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { useScriptService } from '@/app/service/Interaction/ScriptService';
|
||||
|
||||
const ScriptTabContent: React.FC = () => {
|
||||
const [cards, setCards] = useState<StoryboardCardType[]>(mockStoryboards);
|
||||
const [selectedScenes, setSelectedScenes] = useState<string[]>([]);
|
||||
const [selectedCharacters, setSelectedCharacters] = useState<string[]>([]);
|
||||
// 获取当前项目ID(这里需要根据实际项目路由或上下文获取)
|
||||
const projectId = 'current-project-id'; // TODO: 从路由或上下文获取实际项目ID
|
||||
|
||||
// 筛选卡片
|
||||
const filteredCards = cards.filter(card => {
|
||||
const matchesScene = selectedScenes.length === 0 ||
|
||||
selectedScenes.includes(card.scene?.sceneId || '');
|
||||
const matchesCharacter = selectedCharacters.length === 0 ||
|
||||
card.characters.some(char => selectedCharacters.includes(char.characterId));
|
||||
return matchesScene && matchesCharacter;
|
||||
});
|
||||
const {
|
||||
scriptSlices,
|
||||
userPrompt,
|
||||
loading,
|
||||
error,
|
||||
updateUserPrompt,
|
||||
fetchScriptData,
|
||||
setFocusedSlice,
|
||||
updateScriptSliceText,
|
||||
resetScript,
|
||||
applyScript,
|
||||
fetchProjectScript
|
||||
} = useScriptService();
|
||||
|
||||
// 处理卡片拖拽
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = cards.findIndex(c => c.id === active.id);
|
||||
const newIndex = cards.findIndex(c => c.id === over?.id);
|
||||
const newCards = [...cards];
|
||||
const [removed] = newCards.splice(oldIndex, 1);
|
||||
newCards.splice(newIndex, 0, removed);
|
||||
setCards(newCards);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理卡片更新
|
||||
const handleCardUpdate = useCallback((cardId: string, updates: Partial<StoryboardCardType>) => {
|
||||
setCards(cards => cards.map(card =>
|
||||
card.id === cardId ? { ...card, ...updates } : card
|
||||
));
|
||||
}, []);
|
||||
|
||||
// 处理卡片删除
|
||||
const handleCardDelete = useCallback((cardId: string) => {
|
||||
setCards(cards => cards.filter(card => card.id !== cardId));
|
||||
}, []);
|
||||
|
||||
// 处理卡片复制
|
||||
const handleCardDuplicate = useCallback((cardId: string) => {
|
||||
const card = cards.find(c => c.id === cardId);
|
||||
if (card) {
|
||||
const newCard: StoryboardCardType = {
|
||||
...card,
|
||||
id: `card-${Date.now()}`,
|
||||
shotId: `SC-${cards.length + 1}`,
|
||||
dialogues: card.dialogues.map(d => ({ ...d, id: `d${Date.now()}-${d.id}` })),
|
||||
};
|
||||
setCards(cards => [...cards, newCard]);
|
||||
}
|
||||
}, [cards]);
|
||||
|
||||
// 添加新卡片
|
||||
const handleAddCard = () => {
|
||||
const newCard: StoryboardCardType = {
|
||||
id: `card-${Date.now()}`,
|
||||
shotId: `SC-${cards.length + 1}`,
|
||||
scene: undefined,
|
||||
characters: [],
|
||||
dialogues: [],
|
||||
description: '',
|
||||
shotType: '',
|
||||
cameraMove: '',
|
||||
// 组件挂载时获取项目剧本数据
|
||||
useEffect(() => {
|
||||
const initializeScript = async () => {
|
||||
try {
|
||||
await fetchProjectScript(projectId);
|
||||
} catch (error) {
|
||||
console.error('初始化剧本数据失败:', error);
|
||||
}
|
||||
};
|
||||
setCards(cards => [...cards, newCard]);
|
||||
};
|
||||
|
||||
initializeScript();
|
||||
}, [projectId, fetchProjectScript]);
|
||||
|
||||
// 处理AI生成按钮点击
|
||||
const handleAiGenerate = useCallback(async () => {
|
||||
if (!userPrompt.trim()) return;
|
||||
|
||||
try {
|
||||
await fetchScriptData(userPrompt);
|
||||
} catch (error) {
|
||||
console.error('生成剧本失败:', error);
|
||||
}
|
||||
}, [userPrompt, fetchScriptData]);
|
||||
|
||||
// 处理重置按钮点击
|
||||
const handleReset = useCallback(() => {
|
||||
resetScript();
|
||||
}, [resetScript]);
|
||||
|
||||
// 处理确认按钮点击
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
await applyScript();
|
||||
} catch (error) {
|
||||
console.error('应用剧本失败:', error);
|
||||
}
|
||||
}, [applyScript]);
|
||||
|
||||
// 处理提示词输入变化
|
||||
const handlePromptChange = useCallback((value: string) => {
|
||||
updateUserPrompt(value);
|
||||
}, [updateUserPrompt]);
|
||||
|
||||
// 处理剧本片段文本变化
|
||||
const handleScriptSliceChange = useCallback((sliceId: string, text: string) => {
|
||||
setFocusedSlice(sliceId);
|
||||
updateScriptSliceText(text);
|
||||
}, [setFocusedSlice, updateScriptSliceText]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 筛选栏 - 固定在顶部 */}
|
||||
<div className="flex-shrink-0 bg-black/20 backdrop-blur-sm z-10">
|
||||
<FilterBar
|
||||
selectedScenes={selectedScenes}
|
||||
selectedCharacters={selectedCharacters}
|
||||
onScenesChange={setSelectedScenes}
|
||||
onCharactersChange={setSelectedCharacters}
|
||||
onReset={() => {
|
||||
setSelectedScenes([]);
|
||||
setSelectedCharacters([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 卡片网格 - 可滚动区域 */}
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={[...filteredCards.map(card => card.id), 'add-card']}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div
|
||||
className="grid auto-rows-min gap-6 w-full"
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
justifyItems: 'center'
|
||||
<motion.div
|
||||
className="relative w-full h-[90vh] backdrop-blur-xl rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
initial={{ scale: 0.95, y: 10, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
duration: 0.3,
|
||||
bounce: 0.15,
|
||||
stiffness: 300,
|
||||
damping: 25
|
||||
}
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.95,
|
||||
y: 10,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
type: "tween",
|
||||
duration: 0.1,
|
||||
ease: "easeOut"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{filteredCards.map((card) => (
|
||||
<div key={card.id} className="w-full max-w-[480px]">
|
||||
<StoryboardCard
|
||||
card={card}
|
||||
onUpdate={(updates) => handleCardUpdate(card.id, updates)}
|
||||
onDelete={() => handleCardDelete(card.id)}
|
||||
onDuplicate={() => handleCardDuplicate(card.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 添加卡片占位符 */}
|
||||
{/* 标题 */}
|
||||
<motion.div
|
||||
className="flex-none px-6 py-4"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Edit Script
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<motion.div
|
||||
className="flex-1 overflow-auto p-6 pt-0 pb-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
>
|
||||
{/* 剧本片段渲染区域 */}
|
||||
<motion.div
|
||||
key="add-card"
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={handleAddCard}
|
||||
className="w-full max-w-[480px] h-[480px] bg-black/20 backdrop-blur-sm rounded-xl
|
||||
border border-dashed border-white/10 cursor-pointer
|
||||
flex items-center justify-center
|
||||
hover:bg-black/30 hover:border-white/20 transition-all duration-200
|
||||
group"
|
||||
style={{
|
||||
height: 'calc(100% - 88px)'
|
||||
}}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Plus className="w-8 h-8 text-white/40 group-hover:text-white/60 transition-colors" />
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 剧本片段列表 */}
|
||||
<div className="space-y-3">
|
||||
{scriptSlices.map((slice, index) => (
|
||||
<motion.div
|
||||
key={slice.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="relative"
|
||||
>
|
||||
<Input
|
||||
value={slice.text}
|
||||
onChange={(e) => handleScriptSliceChange(slice.id, e.target.value)}
|
||||
placeholder={`输入${slice.type}内容...`}
|
||||
className="w-full bg-white/50 dark:bg-[#5b75ac20] border border-gray-200 dark:border-gray-600 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded">
|
||||
{slice.type}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center justify-center py-8"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
正在生成剧本...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* 修改建议输入区域 */}
|
||||
<motion.div
|
||||
className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent dark:from-[#5b75ac4d] dark:via-[#5b75ac4d] dark:to-transparent pt-8 pb-4 rounded-sm"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center space-x-2 px-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={userPrompt}
|
||||
onChange={(e) => handlePromptChange(e.target.value)}
|
||||
placeholder="输入提示词,然后点击AI生成按钮..."
|
||||
className="outline-none box-shadow-none bg-white/50 dark:bg-[#5b75ac20] border-0 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: userPrompt.trim() && !loading ? 1 : 0.8,
|
||||
opacity: userPrompt.trim() && !loading ? 1 : 0.5,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 30
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!userPrompt.trim() || loading}
|
||||
onClick={handleAiGenerate}
|
||||
className="aiGenerate relative w-9 h-9 rounded-full bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: loading ? 0 : 1,
|
||||
scale: loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 transform rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</motion.span>
|
||||
{loading && (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
>
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
</motion.div>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<motion.div
|
||||
className="flex-none px-6 py-4 flex justify-end space-x-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-w-[80px] bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
onClick={handleReset}
|
||||
disabled={loading}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-[80px] bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || scriptSlices.length === 0}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user