feat(studio): D-a block 分割/合并数据层 - hook + mock + vitest[P1]
studio 缺 UI 端面补齐(用户选 Block 分割合并)。openapi export SplitBlock/MergeBlockRequest(Result 手写:OpenAPI 误声明 response content:never,后端注解缺失);useBlockStructure(useSplitBlock+useMergeBlock,onSuccess 失效 chapterDetail+chapterList);content mock split/merge handler(单 block 模型简化,校验乐观锁返回契约 block);useBlockStructure.test 3/3。 验证:vitest 3/3 + tsc 0 + eslint 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
acf4f0a8d6
commit
d0ea9479f1
@ -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),
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
77
muse-studio/src/features/editor/hooks/useBlockStructure.ts
Normal file
77
muse-studio/src/features/editor/hooks/useBlockStructure.ts
Normal file
@ -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<SplitBlockResult>(`/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<MergeBlockResult>(`/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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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'];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user