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