diff --git a/muse-studio/src/api/mocks/handlers/content.ts b/muse-studio/src/api/mocks/handlers/content.ts index 9a510485..3d1564f5 100644 --- a/muse-studio/src/api/mocks/handlers/content.ts +++ b/muse-studio/src/api/mocks/handlers/content.ts @@ -48,6 +48,19 @@ interface BlockSaveRequest { }; } +/** split/merge 结构编辑命令(mock 入参)。 */ +interface BlockStructureRequest { + commandId?: string; + splitPosition?: number; + targetBlockId?: string; + expectedRevision?: number; + sourceSnapshot?: { + contentRevision?: number; + sourceType?: string; + sourceId?: string; + }; +} + interface TiptapTextNode { text?: string; content?: TiptapTextNode[]; @@ -84,6 +97,29 @@ function createChapterContent(title: string, wordCount: number): string { }); } +/** 构造符合 Block 契约的 mock 对象(对齐 chapterDetail handler 的 block 结构),供 split/merge 返回。 */ +function mockBlock(id: string, chapter: MockChapter, content: string, sortOrder: number, version: number) { + const textNodes: string[] = []; + try { + collectTiptapText(JSON.parse(content), textNodes); + } catch { + /* 正文非合法 JSON 时字数计 0,不阻断 mock。 */ + } + return { + id, + workId: chapter.workId, + chapterId: chapter.id, + blockType: 'scene', + title: chapter.title, + content, + sortOrder, + version, + wordCount: textNodes.join('').length, + createdAt: '2026-05-20T00:00:00Z', + updatedAt: new Date().toISOString(), + }; +} + /** 构造章节和首个正文 Block 的一体化 mock 数据。 */ function createMockChapter( id: string, @@ -526,4 +562,59 @@ export const contentHandlers = [ }, }); }), + + // POST /app-api/muse/works/:workId/blocks/:blockId/split — 分割 Block(mock:校验乐观锁后返回原+新两个 Block) + http.post('/app-api/muse/works/:workId/blocks/:blockId/split', async ({ params, request }) => { + const { workId, blockId } = params; + const body = (await request.json()) as BlockStructureRequest; + const chapter = mockChapters.find((ch) => ch.blockId === blockId && ch.workId === workId); + + if (!chapter) { + return HttpResponse.json({ code: 40403, msg: 'Block 不存在', data: null }, { status: 404 }); + } + if (!body.commandId || !body.sourceSnapshot) { + return HttpResponse.json({ code: 40001, msg: '缺少分割命令字段', data: null }, { status: 400 }); + } + if (body.expectedRevision !== chapter.blockVersion) { + return HttpResponse.json({ code: 40901, msg: 'Block 版本已变化,请刷新后重试', data: null }, { status: 409 }); + } + + // mock 不持久化多 Block(单 Block 模型),仅校验后返回符合契约的两个 Block;真实多 Block 由真后端 e2e 覆盖。 + chapter.blockVersion += 1; + const newBlockId = crypto.randomUUID(); + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + originalBlock: mockBlock(chapter.blockId, chapter, chapter.content, 1, chapter.blockVersion), + newBlock: mockBlock(newBlockId, chapter, createChapterContent(chapter.title, 0), 2, 1), + }, + }); + }), + + // POST /app-api/muse/works/:workId/blocks/:blockId/merge — 合并 Block(mock:校验乐观锁后返回合并后 Block) + http.post('/app-api/muse/works/:workId/blocks/:blockId/merge', async ({ params, request }) => { + const { workId, blockId } = params; + const body = (await request.json()) as BlockStructureRequest; + const chapter = mockChapters.find((ch) => ch.blockId === blockId && ch.workId === workId); + + if (!chapter) { + return HttpResponse.json({ code: 40403, msg: 'Block 不存在', data: null }, { status: 404 }); + } + if (!body.commandId || !body.sourceSnapshot) { + return HttpResponse.json({ code: 40001, msg: '缺少合并命令字段', data: null }, { status: 400 }); + } + if (body.expectedRevision !== chapter.blockVersion) { + return HttpResponse.json({ code: 40901, msg: 'Block 版本已变化,请刷新后重试', data: null }, { status: 409 }); + } + + chapter.blockVersion += 1; + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + mergedBlock: mockBlock(chapter.blockId, chapter, chapter.content, 1, chapter.blockVersion), + }, + }); + }), ]; diff --git a/muse-studio/src/features/editor/hooks/useBlockStructure.test.tsx b/muse-studio/src/features/editor/hooks/useBlockStructure.test.tsx new file mode 100644 index 00000000..76a3c58a --- /dev/null +++ b/muse-studio/src/features/editor/hooks/useBlockStructure.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useSplitBlock, useMergeBlock } from './useBlockStructure'; +import { resetMockDb } from '@/api/mocks/handlers/content'; +import React from 'react'; + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } }, + }); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = createTestQueryClient(); + return {children}; +}; + +describe('useBlockStructure (split/merge) hooks 单元测试', () => { + beforeEach(() => { + resetMockDb(); + }); + + it('useSplitBlock 分割后应返回原 Block 与新 Block', async () => { + const { result } = renderHook(() => useSplitBlock('1', '1-1'), { wrapper }); + result.current.mutate({ blockId: 'block-1-1-main', splitPosition: 5, expectedRevision: 1 }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.originalBlock.id).toBe('block-1-1-main'); + expect(result.current.data?.newBlock.id).toBeTruthy(); + expect(result.current.data?.newBlock.id).not.toBe('block-1-1-main'); // 新 Block 独立 id。 + }); + + it('useMergeBlock 合并后应返回合并 Block', async () => { + const { result } = renderHook(() => useMergeBlock('1', '1-1'), { wrapper }); + result.current.mutate({ blockId: 'block-1-1-main', expectedRevision: 1 }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.mergedBlock.id).toBe('block-1-1-main'); + }); + + it('useSplitBlock 乐观锁版本不匹配应失败(409)', async () => { + const { result } = renderHook(() => useSplitBlock('1', '1-1'), { wrapper }); + result.current.mutate({ blockId: 'block-1-1-main', splitPosition: 5, expectedRevision: 999 }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/muse-studio/src/features/editor/hooks/useBlockStructure.ts b/muse-studio/src/features/editor/hooks/useBlockStructure.ts new file mode 100644 index 00000000..d930ddfb --- /dev/null +++ b/muse-studio/src/features/editor/hooks/useBlockStructure.ts @@ -0,0 +1,77 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/api/client'; +import type { BlockVO, SplitBlockRequest, MergeBlockRequest } from '@/types/openapi'; + +/** + * Block 结构编辑(分割/合并)的 mutation hooks。 + * + * OpenAPI 将 split/merge 的 200 response 误声明为 content:never(后端 response schema 注解缺失), + * 实际后端返回 SplitBlockRespVO{originalBlock,newBlock} / MergeBlockRespVO{mergedBlock},故此处手写 Result。 + * 成功后失效章节详情(['chapterDetail',chapterId])与大纲(['chapterList',workId]),触发多 Block 列表重拉。 + */ + +/** 分割结果:原 Block(前半)+新 Block(后半)。 */ +export interface SplitBlockResult { + originalBlock: BlockVO; + newBlock: BlockVO; +} + +/** 合并结果:合并后的 Block。 */ +export interface MergeBlockResult { + mergedBlock: BlockVO; +} + +/** 分割 Block:在 splitPosition(字符偏移)处把一个 Block 切成两个。 */ +export function useSplitBlock(workId: string, chapterId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + blockId, + splitPosition, + expectedRevision, + }: { + blockId: string; + splitPosition: number; + expectedRevision: number; + }) => + api.post(`/works/${workId}/blocks/${blockId}/split`, { + commandId: crypto.randomUUID(), + splitPosition, + expectedRevision, + sourceSnapshot: { sourceType: 'user_original', sourceId: blockId, contentRevision: expectedRevision }, + } satisfies SplitBlockRequest), + onSuccess: () => { + // 分割后章节内 Block 数量变化,失效详情与大纲缓存触发重拉。 + queryClient.invalidateQueries({ queryKey: ['chapterDetail', chapterId] }); + queryClient.invalidateQueries({ queryKey: ['chapterList', workId] }); + }, + }); +} + +/** 合并 Block:把当前 Block 与目标相邻 Block(targetBlockId 不传时后端默认与下一个合并)合为一个。 */ +export function useMergeBlock(workId: string, chapterId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + blockId, + targetBlockId, + expectedRevision, + }: { + blockId: string; + targetBlockId?: string; + expectedRevision: number; + }) => + api.post(`/works/${workId}/blocks/${blockId}/merge`, { + commandId: crypto.randomUUID(), + expectedRevision, + // targetBlockId 为可选(exactOptionalPropertyTypes 禁显式 undefined):仅指定时带上,不传则后端默认与下一个 Block 合并。 + ...(targetBlockId ? { targetBlockId } : {}), + sourceSnapshot: { sourceType: 'user_original', sourceId: blockId, contentRevision: expectedRevision }, + } satisfies MergeBlockRequest), + onSuccess: () => { + // 合并后章节内 Block 数量变化,失效详情与大纲缓存触发重拉。 + queryClient.invalidateQueries({ queryKey: ['chapterDetail', chapterId] }); + queryClient.invalidateQueries({ queryKey: ['chapterList', workId] }); + }, + }); +} diff --git a/muse-studio/src/types/openapi.ts b/muse-studio/src/types/openapi.ts index 8f0f8ee3..cb24bfb1 100644 --- a/muse-studio/src/types/openapi.ts +++ b/muse-studio/src/types/openapi.ts @@ -54,3 +54,7 @@ export type MetaProjectionSummary = ContentComponents['schemas']['MetaProjection export type MetaProjectionDetail = ContentComponents['schemas']['MetaProjectionDetail']; export type DynamicFieldValidationRequest = ContentComponents['schemas']['DynamicFieldValidationRequest']; export type DynamicFieldValidationResult = ContentComponents['schemas']['DynamicFieldValidationResult']; +// Block 结构编辑(split/merge)——Request 取自 openapi;OpenAPI 误将 200 response 声明为 content:never +// (后端 response schema 注解缺失),故 Result 在 useBlockStructure 内手写(对齐后端 SplitBlockRespVO/MergeBlockRespVO)。 +export type SplitBlockRequest = ContentComponents['schemas']['SplitBlockRequest']; +export type MergeBlockRequest = ContentComponents['schemas']['MergeBlockRequest'];