lili d0ea9479f1 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>
2026-06-21 08:48:19 -07:00

621 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { http, HttpResponse } from 'msw';
/** 作品类型定义 */
export interface MockWork {
id: string;
title: string;
description: string;
status: string;
genre?: string;
workSchemaId?: number;
wordCount: number;
chapterCount: number;
createdAt: string;
updatedAt: string;
}
/** 章节类型定义 */
export interface MockChapter {
id: string;
title: string;
wordCount: number;
sortOrder: number;
workId: string;
blockId: string;
blockVersion: number;
content: string;
}
interface WorkCreateRequest {
title?: string;
description?: string;
genre?: string;
schemaId?: number;
}
interface ChapterCreateRequest {
title?: string;
}
interface BlockSaveRequest {
content?: string;
expectedRevision?: number;
commandId?: string;
sourceSnapshot?: {
contentRevision?: number;
sourceType?: string;
sourceId?: string;
};
}
/** 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[];
}
/** 从 Tiptap JSON 中递归提取文本节点,用于 Mock 层同步字数统计。 */
function collectTiptapText(node: unknown, textNodes: string[]): void {
if (typeof node !== 'object' || node === null) return;
const current = node as TiptapTextNode;
if (typeof current.text === 'string') {
textNodes.push(current.text);
}
if (Array.isArray(current.content)) {
current.content.forEach((child) => collectTiptapText(child, textNodes));
}
}
/** 为章节构造默认 Tiptap JSON 正文,保持 mock 数据与真实 Block 契约一致。 */
function createChapterContent(title: string, wordCount: number): string {
return JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: `这是章节《${title}》的正文内容。当前已保存字数为 ${wordCount} 字。`,
},
],
},
],
});
}
/** 构造符合 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,
title: string,
wordCount: number,
sortOrder: number,
workId: string
): MockChapter {
return {
id,
title,
wordCount,
sortOrder,
workId,
blockId: `block-${id}-main`,
blockVersion: 1,
content: createChapterContent(title, wordCount),
};
}
// 初始作品数据快照
const INITIAL_WORKS: MockWork[] = [
{
id: '1',
title: '星海迷途',
description: '人类在星际探索中遇到的未知文明与科技碰撞之旅...',
status: 'active',
genre: '科幻',
wordCount: 52300,
chapterCount: 3,
createdAt: '2026-05-01T00:00:00Z',
updatedAt: '2026-05-20T10:30:00Z',
},
{
id: '2',
title: '长安旧事',
description: '讲述大唐风雨飘摇中的平民故事。',
status: 'draft',
genre: '历史',
wordCount: 12800,
chapterCount: 2,
createdAt: '2026-05-10T00:00:00Z',
updatedAt: '2026-05-24T08:00:00Z',
},
{
id: '3',
title: '深渊笔记',
description: '极地深渊探险中的神秘发现。',
status: 'active',
genre: '悬疑',
wordCount: 89500,
chapterCount: 4,
createdAt: '2026-05-15T00:00:00Z',
updatedAt: '2026-05-23T18:00:00Z',
},
];
// 初始章节数据快照
const INITIAL_CHAPTERS: MockChapter[] = [
// 星海迷途章节
createMockChapter('1-1', '第一章 启程', 15000, 1, '1'),
createMockChapter('1-2', '第二章 信号', 18000, 2, '1'),
createMockChapter('1-3', '第三章 降落', 19300, 3, '1'),
// 长安旧事章节
createMockChapter('2-1', '第一章 惊蛰', 6000, 1, '2'),
createMockChapter('2-2', '第二章 春分', 6800, 2, '2'),
// 深渊笔记章节
createMockChapter('3-1', '第一章 迷雾', 20000, 1, '3'),
createMockChapter('3-2', '第二章 深潜', 22000, 2, '3'),
createMockChapter('3-3', '第三章 遗迹', 24000, 3, '3'),
createMockChapter('3-4', '第四章 归途', 23500, 4, '3'),
];
// 运行时的可变内存数据库状态
let mockWorks = JSON.parse(JSON.stringify(INITIAL_WORKS)) as MockWork[];
let mockChapters = JSON.parse(JSON.stringify(INITIAL_CHAPTERS)) as MockChapter[];
/** B5 planning section mock 类型 */
interface MockPlanningSection {
sectionKey: string;
sectionName: string;
content: Record<string, unknown>;
revision: number;
schemaVersion: number;
projectionVersion: number;
usedFieldKeys: string[];
}
/** Meta Schema 投影字段定义(固定 schema,供 meta-projection mock 按字段渲染表单) */
const PROJECTION_DEFS: Record<string, {
projectionKey: string; projectionName: string; schemaVersion: number; projectionVersion: number;
fields: Array<{ fieldKey: string; fieldName: string; fieldType: string; uiVisible: boolean; userEditable: boolean; enumValues?: string[] }>;
}> = {
setting: {
projectionKey: 'setting', projectionName: '故事设定', schemaVersion: 1, projectionVersion: 1,
fields: [
{ fieldKey: 'time_period', fieldName: '时代背景', fieldType: 'enum', uiVisible: true, userEditable: true, enumValues: ['古代', '现代', '未来'] },
{ fieldKey: 'world_overview', fieldName: '世界观概述', fieldType: 'text', uiVisible: true, userEditable: true },
],
},
};
let mockPlanningSections: Record<string, MockPlanningSection[]> = {};
/**
* 重置 Mock 内存数据库
* 在单元测试 beforeEach 中调用,保证测试套件之间的并发与环境完全隔离
*/
export function resetMockDb(): void {
mockWorks = JSON.parse(JSON.stringify(INITIAL_WORKS));
mockChapters = JSON.parse(JSON.stringify(INITIAL_CHAPTERS));
mockPlanningSections = {};
}
/**
* 作品与内容管理 API 的 MSW Mock 处理器
*/
export const contentHandlers = [
// ===== B5 planning 编辑器:planning 结构 + meta-projection 字段 =====
// GET planning 结构(已确认的 sections)
http.get('/app-api/muse/works/:workId/planning', ({ params }) => {
const sections = mockPlanningSections[String(params.workId)] ?? [];
return HttpResponse.json({ code: 0, msg: 'success', data: {
workId: String(params.workId),
revision: sections.reduce((m, s) => Math.max(m, s.revision), 0),
sections,
} });
}),
// GET meta-projection 摘要列表
http.get('/app-api/muse/works/:workId/meta-projections', () => {
const list = Object.values(PROJECTION_DEFS).map((p) => ({
projectionKey: p.projectionKey, projectionName: p.projectionName,
schemaVersion: p.schemaVersion, projectionVersion: p.projectionVersion, fieldCount: p.fields.length,
}));
return HttpResponse.json({ code: 0, msg: 'success', data: list });
}),
// GET meta-projection 详情(fields[].value 回填已存 planning 值)
http.get('/app-api/muse/works/:workId/meta-projections/:projectionKey', ({ params }) => {
const def = PROJECTION_DEFS[String(params.projectionKey)];
if (!def) return HttpResponse.json({ code: 404, msg: 'projection 不存在', data: null });
const sections = mockPlanningSections[String(params.workId)] ?? [];
const saved = sections.find((s) => s.sectionKey === def.projectionKey);
const fields = def.fields.map((f) => ({ ...f, value: saved?.content[f.fieldKey] }));
return HttpResponse.json({ code: 0, msg: 'success', data: {
projectionKey: def.projectionKey, projectionName: def.projectionName,
schemaVersion: def.schemaVersion, projectionVersion: def.projectionVersion,
dataRevision: saved?.revision ?? 0, fields,
} });
}),
// PUT 保存 planning 项(记录 content + usedFieldKeys,revision 自增)
http.put('/app-api/muse/works/:workId/planning/:sectionKey', async ({ params, request }) => {
const body = (await request.json()) as { content: Record<string, unknown>; usedFieldKeys?: string[] };
const workId = String(params.workId);
const sectionKey = String(params.sectionKey);
const sections = mockPlanningSections[workId] ?? (mockPlanningSections[workId] = []);
const def = PROJECTION_DEFS[sectionKey];
let section = sections.find((s) => s.sectionKey === sectionKey);
if (!section) {
section = {
sectionKey, sectionName: def?.projectionName ?? sectionKey, content: {},
revision: 0, schemaVersion: def?.schemaVersion ?? 1, projectionVersion: def?.projectionVersion ?? 1,
usedFieldKeys: [],
};
sections.push(section);
}
section.content = body.content;
section.usedFieldKeys = body.usedFieldKeys ?? [];
section.revision += 1;
return HttpResponse.json({ code: 0, msg: 'success', data: {
sectionKey, newRevision: section.revision, updatedAt: '2026-06-21T00:00:00Z',
} });
}),
// POST 校验动态字段(mock 简化:全 valid)
http.post('/app-api/muse/works/:workId/dynamic-fields/validate', async ({ request }) => {
const body = (await request.json()) as { fields: Array<{ fieldKey: string }> };
return HttpResponse.json({ code: 0, msg: 'success', data: {
valid: true, fieldResults: body.fields.map((f) => ({ fieldKey: f.fieldKey, valid: true })),
} });
}),
// GET /app-api/muse/works — 获取作品列表
http.get('/app-api/muse/works', () => {
return HttpResponse.json({
code: 0,
msg: 'success',
data: {
total: mockWorks.length,
pageNo: 1,
pageSize: 20,
list: mockWorks,
},
});
}),
// POST /app-api/muse/works — 创建作品
http.post('/app-api/muse/works', async ({ request }) => {
const body = (await request.json()) as WorkCreateRequest;
// 严格字段解析与空安全守卫
const work: MockWork = {
id: crypto.randomUUID(),
title: body.title || '未命名作品',
description: body.description || '',
status: 'draft',
wordCount: 0,
chapterCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (body.genre !== undefined) {
work.genre = body.genre;
}
if (body.schemaId !== undefined) {
work.workSchemaId = body.schemaId;
}
mockWorks.push(work);
return HttpResponse.json({ code: 0, msg: 'success', data: work }, { status: 201 });
}),
// GET /app-api/muse/works/schema-options — 作品可绑定的 Meta Schema 选项(置 :workId 之前,避免被参数路由吞掉)
http.get('/app-api/muse/works/schema-options', () => {
return HttpResponse.json({
code: 0,
msg: 'success',
data: [
{ id: 1, schemaKey: 'work_core', displayName: '作品核心结构', targetType: 'work' },
{ id: 2, schemaKey: 'work_outline', displayName: '大纲结构', targetType: 'work' },
],
});
}),
// GET /app-api/muse/works/:workId — 获取作品详情
http.get('/app-api/muse/works/:workId', ({ params }) => {
const { workId } = params;
const work = mockWorks.find((w) => w.id === workId);
if (!work) {
return HttpResponse.json({ code: 40401, msg: '作品不存在', data: null }, { status: 404 });
}
return HttpResponse.json({
code: 0,
msg: 'success',
data: work,
});
}),
// DELETE /app-api/muse/works/:workId — 删除作品 (包含关联章节的级联删除)
http.delete('/app-api/muse/works/:workId', ({ params }) => {
const { workId } = params;
const workIndex = mockWorks.findIndex((w) => w.id === workId);
if (workIndex === -1) {
return HttpResponse.json({ code: 40401, msg: '作品不存在', data: null }, { status: 404 });
}
// 移出作品
mockWorks.splice(workIndex, 1);
// 级联删除所属该作品的所有章节
mockChapters = mockChapters.filter((ch) => ch.workId !== workId);
return HttpResponse.json({
code: 0,
msg: 'success',
data: true,
});
}),
// GET /app-api/muse/works/:workId/chapters — 获取特定作品的章节列表 (按 sortOrder 升序)
http.get('/app-api/muse/works/:workId/chapters', ({ params }) => {
const { workId } = params;
// 过滤出该作品下的所有章节并排序
const list = mockChapters
.filter((ch) => ch.workId === workId)
.sort((a, b) => a.sortOrder - b.sortOrder);
return HttpResponse.json({
code: 0,
msg: 'success',
data: list,
});
}),
// GET /app-api/muse/works/:workId/chapters/:chapterId — 获取章节详情及 Block 正文
http.get('/app-api/muse/works/:workId/chapters/:chapterId', ({ params }) => {
const { workId, chapterId } = params;
const chapter = mockChapters.find((ch) => ch.id === chapterId && ch.workId === workId);
if (!chapter) {
return HttpResponse.json({ code: 40402, msg: '章节不存在', data: null }, { status: 404 });
}
return HttpResponse.json({
code: 0,
msg: 'success',
data: {
id: chapter.id,
workId: chapter.workId,
title: chapter.title,
sortOrder: chapter.sortOrder,
status: 'draft',
wordCount: chapter.wordCount,
blockCount: 1,
createdAt: '2026-05-20T00:00:00Z',
updatedAt: new Date().toISOString(),
blocks: [
{
id: chapter.blockId,
workId: chapter.workId,
chapterId: chapter.id,
blockType: 'scene',
title: chapter.title,
content: chapter.content,
sortOrder: 1,
version: chapter.blockVersion,
wordCount: chapter.wordCount,
createdAt: '2026-05-20T00:00:00Z',
updatedAt: new Date().toISOString(),
},
],
},
});
}),
// POST /app-api/muse/works/:workId/chapters — 创建章节
http.post('/app-api/muse/works/:workId/chapters', async ({ params, request }) => {
const { workId } = params;
const body = (await request.json()) as ChapterCreateRequest;
const list = mockChapters.filter((ch) => ch.workId === workId);
const newChapter: MockChapter = {
id: crypto.randomUUID(),
title: body.title || `第 ${list.length + 1} 章 未命名章节`,
wordCount: 0,
sortOrder: list.length + 1,
workId: workId as string,
blockId: crypto.randomUUID(),
blockVersion: 1,
content: createChapterContent(body.title || `第 ${list.length + 1} 章 未命名章节`, 0),
};
mockChapters.push(newChapter);
// 级联更新对应作品的 chapterCount
const work = mockWorks.find((w) => w.id === workId);
if (work) {
work.chapterCount += 1;
work.updatedAt = new Date().toISOString();
}
return HttpResponse.json({
code: 0,
msg: 'success',
data: newChapter,
}, { status: 201 });
}),
// DELETE /app-api/muse/works/:workId/chapters/:chapterId — 删除小节/章节
http.delete('/app-api/muse/works/:workId/chapters/:chapterId', ({ params }) => {
const { workId, chapterId } = params;
const chapterIndex = mockChapters.findIndex((ch) => ch.id === chapterId && ch.workId === workId);
if (chapterIndex === -1) {
return HttpResponse.json({ code: 40402, msg: '章节不存在', data: null }, { status: 404 });
}
const removedChapter = mockChapters[chapterIndex];
const removedWords = removedChapter ? removedChapter.wordCount : 0;
// 移出章节
mockChapters.splice(chapterIndex, 1);
// 级联更新对应作品的 chapterCount 与 wordCount
const work = mockWorks.find((w) => w.id === workId);
if (work) {
work.chapterCount = Math.max(0, work.chapterCount - 1);
work.wordCount = Math.max(0, work.wordCount - removedWords);
work.updatedAt = new Date().toISOString();
}
return HttpResponse.json({
code: 0,
msg: 'success',
data: true,
});
}),
// PUT /app-api/muse/works/:workId/blocks/:blockId — 编辑器保存 Block 正文接口拦截
http.put('/app-api/muse/works/:workId/blocks/:blockId', async ({ params, request }) => {
const { workId, blockId } = params;
const body = (await request.json()) as BlockSaveRequest;
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 });
}
// 获取提交的正文 JSON 以计算字数进行 Mock 存储更新
let newWordCount = 0;
try {
if (body.content) {
const parsed: unknown = JSON.parse(body.content);
// 简易字数计算:遍历文本节点节点获取字符总长
const textNodes: string[] = [];
collectTiptapText(parsed, textNodes);
newWordCount = textNodes.join('').length;
}
} catch {
newWordCount = 100; // 降级默认字数
}
// 查找到该章节并更新其字数、正文和乐观锁版本
const oldWords = chapter.wordCount;
chapter.wordCount = newWordCount;
chapter.content = body.content || chapter.content;
chapter.blockVersion += 1;
// 联动更新关联作品的总字数统计
const work = mockWorks.find((w) => w.id === chapter.workId);
if (work) {
work.wordCount = Math.max(0, work.wordCount - oldWords + newWordCount);
work.updatedAt = new Date().toISOString();
}
return HttpResponse.json({
code: 0,
msg: 'success',
data: {
newVersion: chapter.blockVersion,
},
});
}),
// 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),
},
});
}),
];