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:
lili 2026-06-21 08:48:19 -07:00
parent acf4f0a8d6
commit d0ea9479f1
4 changed files with 216 additions and 0 deletions

View File

@ -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),
},
});
}),
];

View File

@ -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));
});
});

View 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] });
},
});
}

View File

@ -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'];