feat: 完善图像工作区与创作工具体验
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 14:08:02 +08:00
parent bfcb88cfef
commit 26b52d76e3
92 changed files with 16678 additions and 2975 deletions

View File

@@ -12,12 +12,20 @@ import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { useUserProfileStore } from '@/stores/user-profile';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { createProjectConfig } from '../../shared/project-config';
import type { DesignConversationSummary, DesignWorkspace } from '../../shared/image-workspace';
import type {
DesignConversationSummary,
DesignDeleteWorkspaceResult,
DesignGenerationTask,
DesignTaskStatus,
DesignWorkspace,
DesignWorkspaceSummary,
} from '../../shared/image-workspace';
const hostApiFetchMock = vi.fn();
const invokeIpcMock = vi.fn();
const navigateMock = vi.fn();
const prepareUserAvatarMock = vi.hoisted(() => vi.fn());
const originalDeleteImageProject = useImageWorkspaceStore.getState().deleteProject;
vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), ensureHostApiToken: vi.fn().mockResolvedValue('test-token') }));
vi.mock('@/lib/api-client', () => ({ invokeIpc: (...args: unknown[]) => invokeIpcMock(...args) }));
vi.mock('@/lib/user-avatar', async (importOriginal) => ({
@@ -26,6 +34,69 @@ vi.mock('@/lib/user-avatar', async (importOriginal) => ({
}));
vi.mock('react-router-dom', async (importOriginal) => ({ ...(await importOriginal<typeof import('react-router-dom')>()), useNavigate: () => navigateMock }));
const DELETE_PROJECT_SUMMARY: DesignWorkspaceSummary = {
workspaceId: 'delete-project',
title: '待删除的概念设计',
viewRevision: 3,
conversationCount: 1,
phase: 'shaping',
updatedAt: '2026-08-15T10:00:00Z',
};
function seedDeleteProjectState(
deleteProject: (workspaceId: string) => Promise<DesignDeleteWorkspaceResult>,
activeTaskStatus?: Extract<DesignTaskStatus, 'queued' | 'running'>,
) {
const workspace: DesignWorkspace = {
...DELETE_PROJECT_SUMMARY,
conversations: [],
};
const tasks: DesignGenerationTask[] = activeTaskStatus ? [{
taskId: 'active-delete-task',
workspaceId: DELETE_PROJECT_SUMMARY.workspaceId,
conversationId: null,
medium: 'image',
status: activeTaskStatus,
briefVersion: 1,
briefSummary: '仍在生成的概念图',
quoteId: null,
quotedDesignPoints: null,
failureCode: null,
resultAssets: [],
createdAt: '2026-08-15T10:00:00Z',
updatedAt: '2026-08-15T10:00:00Z',
}] : [];
useImageWorkspaceStore.setState({
status: 'ready',
bootstrap: {
capabilities: { conversation: true, generation: true, image: true, video: true },
workspaces: [DELETE_PROJECT_SUMMARY],
},
activeWorkspaceId: DELETE_PROJECT_SUMMARY.workspaceId,
activeConversationId: null,
workspace,
conversation: null,
deletingWorkspaceId: null,
tasks,
error: null,
deleteProject,
});
}
function renderDeleteProjectSidebar() {
render(
<MemoryRouter initialEntries={['/image-canvas']}>
<ImageWorkspaceSidebar sidebarCollapsed={false} />
</MemoryRouter>,
);
}
function openDeleteProjectDialog() {
fireEvent.click(screen.getByRole('button', { name: `删除 ${DELETE_PROJECT_SUMMARY.title}` }));
return screen.getByRole('dialog', { name: '删除项目' });
}
describe('Sidebar project initialization flow', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -35,6 +106,7 @@ describe('Sidebar project initialization flow', () => {
useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: null, refreshToken: null, tokenType: null, expiresAt: null, user: null });
useUserProfileStore.setState({ profilesByUserId: {} });
useImageWorkspaceStore.getState().reset();
useImageWorkspaceStore.setState({ deleteProject: originalDeleteImageProject });
useUpdateStore.setState({
status: 'idle',
currentVersion: '0.9.1',
@@ -930,4 +1002,159 @@ describe('Sidebar project initialization flow', () => {
.toHaveTextContent('历史消息 7');
});
it('opens a delete confirmation without deleting immediately', () => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
expect(deleteProject).not.toHaveBeenCalled();
expect(within(dialog).getByText(/删除后,项目/))
.toBeInTheDocument();
expect(within(dialog).getByText(/未提交的任务会被取消并释放预留积分/)).toBeInTheDocument();
expect(within(dialog).getByText(/已提交或运行中的任务会继续后台结算/)).toBeInTheDocument();
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeDisabled();
});
it('requires the trimmed project name to match exactly before deletion', () => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
const input = within(dialog).getByLabelText(`输入项目名称“${DELETE_PROJECT_SUMMARY.title}”以确认删除`);
fireEvent.change(input, { target: { value: '待删除的概念' } });
expect(within(dialog).getByText('项目名称必须完全匹配。')).toBeInTheDocument();
const confirm = within(dialog).getByTestId('confirm-delete-image-project');
expect(confirm).toBeDisabled();
fireEvent.click(confirm);
expect(deleteProject).not.toHaveBeenCalled();
});
it('deletes only after an exact trimmed-name confirmation', async () => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
fireEvent.change(
within(dialog).getByTestId('delete-image-project-name-input'),
{ target: { value: ` ${DELETE_PROJECT_SUMMARY.title} ` } },
);
const confirm = within(dialog).getByTestId('confirm-delete-image-project');
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
await waitFor(() => expect(deleteProject).toHaveBeenCalledTimes(1));
expect(deleteProject).toHaveBeenCalledWith(DELETE_PROJECT_SUMMARY.workspaceId);
await waitFor(() => expect(screen.queryByTestId('delete-image-project-dialog')).not.toBeInTheDocument());
});
it.each(['queued', 'running'] as const)(
'allows deletion while the active project has a %s task',
async (taskStatus) => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject, taskStatus);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
expect(within(dialog).getByText(/未提交的任务会被取消并释放预留积分/)).toBeInTheDocument();
const input = within(dialog).getByTestId('delete-image-project-name-input');
expect(input).toBeEnabled();
fireEvent.change(input, { target: { value: DELETE_PROJECT_SUMMARY.title } });
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeEnabled();
fireEvent.click(within(dialog).getByTestId('confirm-delete-image-project'));
await waitFor(() => expect(deleteProject).toHaveBeenCalledWith(
DELETE_PROJECT_SUMMARY.workspaceId,
));
},
);
it('keeps the confirmation open and surfaces a deletion error', async () => {
const deleteProject = vi.fn(async () => {
throw new Error('云端暂时无法删除该项目');
});
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
fireEvent.change(
within(dialog).getByTestId('delete-image-project-name-input'),
{ target: { value: DELETE_PROJECT_SUMMARY.title } },
);
fireEvent.click(within(dialog).getByTestId('confirm-delete-image-project'));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('云端暂时无法删除该项目');
expect(screen.getByTestId('delete-image-project-dialog')).toBeInTheDocument();
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeEnabled();
});
it('locks every dismissal and editing control while deletion is pending', async () => {
let resolveDelete!: (result: DesignDeleteWorkspaceResult) => void;
const pendingDelete = new Promise<DesignDeleteWorkspaceResult>((resolve) => {
resolveDelete = resolve;
});
const deleteProject = vi.fn((workspaceId: string) => {
useImageWorkspaceStore.setState({ deletingWorkspaceId: workspaceId });
return pendingDelete.finally(() => {
useImageWorkspaceStore.setState({ deletingWorkspaceId: null });
});
});
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
const input = within(dialog).getByTestId('delete-image-project-name-input');
fireEvent.change(input, { target: { value: DELETE_PROJECT_SUMMARY.title } });
fireEvent.click(within(dialog).getByTestId('confirm-delete-image-project'));
await waitFor(() => expect(within(dialog).getByTestId('confirm-delete-image-project'))
.toHaveTextContent('正在删除'));
expect(input).toBeDisabled();
expect(within(dialog).getByRole('button', { name: '取消' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: '关闭删除项目对话框' })).toBeDisabled();
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeDisabled();
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(screen.getByTestId('delete-image-project-dialog')).toBeInTheDocument();
await act(async () => {
resolveDelete({ workspaceId: DELETE_PROJECT_SUMMARY.workspaceId, deleted: true });
await pendingDelete;
});
await waitFor(() => expect(screen.queryByTestId('delete-image-project-dialog')).not.toBeInTheDocument());
});
it('keeps Canvas entry actions left-aligned and gives the active inspiration page feedback', () => {
useImageWorkspaceStore.setState({ status: 'ready' });
render(
<MemoryRouter initialEntries={['/image-prompts']}>
<ImageWorkspaceSidebar sidebarCollapsed={false} />
</MemoryRouter>,
);
const inspiration = screen.getByTestId('sidebar-open-image-prompt-museum');
const createProject = screen.getByTestId('sidebar-create-image-project');
expect(inspiration).toHaveClass('justify-start', 'focus-visible:ring-brand/35');
expect(createProject).toHaveClass('justify-start', 'focus-visible:ring-brand/35');
expect(inspiration).toHaveAttribute('aria-current', 'page');
expect(inspiration).toHaveAttribute('title', '获取灵感');
expect(createProject).toHaveAttribute('title', '新建设计项目');
});
});