feat(studio): D-b block 分割/合并 UI - 章节内多 Block 切换 + 结构条[P1]

- BlockStructureBar:章节内多 Block 列表(选中编辑/分割/与下合并),分割取正文中点 splitPosition,乐观锁用 block revision(blockRevision helper 兼容真后端 revision 与 openapi version 字段漂移)。

- WorkspacePage 集成:activeBlock 从 blocks[0]→用户选(activeBlockId);编辑器区上方挂 BlockStructureBar;MuseEditor 本体不动(低回归)。

验证:全套 vitest 73/73(无回归,+7) + 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:55:15 -07:00
parent d0ea9479f1
commit af781163a9
3 changed files with 232 additions and 13 deletions

View File

@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import BlockStructureBar from './BlockStructureBar';
import { resetMockDb } from '@/api/mocks/handlers/content';
import type { BlockVO } from '@/types/openapi';
import React from 'react';
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const docJson = (text: string) =>
JSON.stringify({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] });
// 模拟真后端 Block(含 revision 字段,openapi 标 version;此处验 blockRevision 兼容)。
const mkBlock = (id: string, text: string, sortOrder: number): BlockVO =>
({
id,
workId: '1',
chapterId: '1-1',
blockType: 'scene',
content: docJson(text),
sortOrder,
revision: 1,
wordCount: text.length,
createdAt: '',
updatedAt: '',
}) as unknown as BlockVO;
describe('BlockStructureBar 组件', () => {
beforeEach(() => resetMockDb());
it('应列出章节所有 Block 并高亮激活项', () => {
const blocks = [mkBlock('block-1-1-main', '第一节正文内容', 1), mkBlock('b2', '第二节正文', 2)];
renderWithClient(
<BlockStructureBar workId="1" chapterId="1-1" blocks={blocks} activeBlockId="block-1-1-main" onSelectBlock={() => {}} />
);
expect(screen.getByText(/2 个小节/)).toBeInTheDocument();
expect(screen.getByLabelText('编辑小节 1')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('编辑小节 2')).toHaveAttribute('aria-pressed', 'false');
});
it('点击 Block 应触发 onSelectBlock', () => {
const onSelect = vi.fn();
const blocks = [mkBlock('block-1-1-main', '第一节', 1), mkBlock('b2', '第二节', 2)];
renderWithClient(
<BlockStructureBar workId="1" chapterId="1-1" blocks={blocks} activeBlockId="block-1-1-main" onSelectBlock={onSelect} />
);
fireEvent.click(screen.getByLabelText('编辑小节 2'));
expect(onSelect).toHaveBeenCalledWith('b2');
});
it('末节合并按钮禁用、分割可用', () => {
const blocks = [mkBlock('block-1-1-main', '一些足够长的正文', 1)];
renderWithClient(
<BlockStructureBar workId="1" chapterId="1-1" blocks={blocks} activeBlockId="block-1-1-main" onSelectBlock={() => {}} />
);
expect(screen.getByLabelText('合并小节 1 与下一节')).toBeDisabled(); // 单节即末节,无下一节。
expect(screen.getByLabelText('分割小节 1')).not.toBeDisabled();
});
it('点击分割应成功调用后端(不出现错误提示)', async () => {
const blocks = [mkBlock('block-1-1-main', '一些足够长的正文用于分割', 1)];
renderWithClient(
<BlockStructureBar workId="1" chapterId="1-1" blocks={blocks} activeBlockId="block-1-1-main" onSelectBlock={() => {}} />
);
fireEvent.click(screen.getByLabelText('分割小节 1'));
// mock split 校验乐观锁(revision=1==blockVersion=1)成功 → 不出现错误提示。
await waitFor(() => expect(screen.queryByText(/结构编辑失败/)).not.toBeInTheDocument());
});
});

View File

@ -0,0 +1,130 @@
import { useSplitBlock, useMergeBlock } from '../hooks/useBlockStructure';
import type { BlockVO } from '@/types/openapi';
/** 从 Tiptap JSON 字符串提取纯文本(用于摘要 + 计算分割中点)。 */
function extractText(contentJson: string): string {
try {
const out: string[] = [];
const walk = (node: unknown): void => {
if (typeof node !== 'object' || node === null) return;
const n = node as { text?: string; content?: unknown[] };
if (typeof n.text === 'string') out.push(n.text);
if (Array.isArray(n.content)) n.content.forEach(walk);
};
walk(JSON.parse(contentJson));
return out.join('');
} catch {
return '';
}
}
/**
* 取 Block 乐观锁版本。
* OpenAPI Block schema 标 `version`,但真后端 BlockRespVO 实际返回 `revision`(字段名漂移);
* 故 revision 优先,兼容 mock 的 version,兜底 1。
*/
function blockRevision(block: BlockVO): number {
const b = block as { revision?: number; version?: number };
return b.revision ?? b.version ?? 1;
}
interface BlockStructureBarProps {
workId: string;
chapterId: string;
blocks: BlockVO[];
activeBlockId?: string;
onSelectBlock: (blockId: string) => void;
}
/**
* 章节内多 Block 结构管理条:列出章节所有 Block,支持选中编辑、分割(在正文中点)、与下一个 Block 合并。
*
* 编辑器(MuseEditor)一次只编辑一个 Block,本条提供 Block 间切换 + 结构编辑入口(低回归:不改编辑器本体)。
* 分割位置取正文纯文本中点(splitPosition 精确性由后端保证,UI 给合理默认);乐观锁用 Block 当前 revision。
*/
export default function BlockStructureBar({
workId,
chapterId,
blocks,
activeBlockId,
onSelectBlock,
}: BlockStructureBarProps) {
const splitBlock = useSplitBlock(workId, chapterId);
const mergeBlock = useMergeBlock(workId, chapterId);
const busy = splitBlock.isPending || mergeBlock.isPending;
if (blocks.length === 0) return null;
return (
<div className="border-b border-slate-200 bg-slate-50 px-4 py-2" aria-label="章节结构">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-slate-500">章节结构 · {blocks.length} 个小节</span>
{busy && <span className="text-xs text-violet-500">处理中…</span>}
</div>
<div className="flex flex-col gap-1">
{blocks.map((block, idx) => {
const text = extractText(block.content);
const isActive = block.id === activeBlockId;
const isLast = idx === blocks.length - 1;
return (
<div
key={block.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded-md text-xs ${
isActive ? 'bg-violet-100 ring-1 ring-violet-300' : 'bg-white hover:bg-slate-100'
}`}
>
<button
type="button"
onClick={() => onSelectBlock(block.id)}
className="flex-1 text-left truncate"
aria-label={`编辑小节 ${idx + 1}`}
aria-pressed={isActive}
>
<span className="font-mono text-slate-400 mr-1.5">#{idx + 1}</span>
<span className="text-slate-700">{text.slice(0, 30) || '(空)'}</span>
</button>
{/* 分割:在正文中点切成两段;正文过短(<2 字)无意义,禁用。 */}
<button
type="button"
disabled={busy || text.length < 2}
onClick={() =>
splitBlock.mutate({
blockId: block.id,
splitPosition: Math.floor(text.length / 2),
expectedRevision: blockRevision(block),
})
}
className="text-violet-600 hover:text-violet-800 disabled:text-slate-300 px-1.5 py-0.5 font-medium"
aria-label={`分割小节 ${idx + 1}`}
>
分割
</button>
{/* 合并:与下一个小节合并;末节无下一节,禁用。 */}
<button
type="button"
disabled={busy || isLast}
onClick={() => {
const next = blocks[idx + 1];
if (next) {
mergeBlock.mutate({
blockId: block.id,
targetBlockId: next.id,
expectedRevision: blockRevision(block),
});
}
}}
className="text-slate-500 hover:text-slate-800 disabled:text-slate-300 px-1.5 py-0.5 font-medium"
aria-label={`合并小节 ${idx + 1} 与下一节`}
>
合并↓
</button>
</div>
);
})}
</div>
{(splitBlock.isError || mergeBlock.isError) && (
<div className="mt-1.5 text-xs text-red-500">结构编辑失败,请刷新后重试(可能版本冲突)。</div>
)}
</div>
);
}

View File

@ -12,6 +12,7 @@ import type { MuseEditorInstance } from '../features/editor/components/MuseEdito
import AIPanel from '../features/editor/components/AIPanel';
import CandidatePanel from '../features/editor/components/CandidatePanel';
import PlanningEditorPanel from '../features/editor/components/PlanningEditorPanel';
import BlockStructureBar from '../features/editor/components/BlockStructureBar';
import type { WorkVO } from '@/types/openapi';
/**
@ -39,7 +40,10 @@ export default function WorkspacePage() {
workId || '',
activeChapterId
);
const activeBlock = chapterDetail?.blocks?.[0];
// 章节内多 Block:用户可在 BlockStructureBar 选择激活哪个 Block 编辑(默认首个);split/merge 在其上操作。
const blocks = chapterDetail?.blocks ?? [];
const [activeBlockId, setActiveBlockId] = useState<string | undefined>(undefined);
const activeBlock = blocks.find((b) => b.id === activeBlockId) ?? blocks[0];
let initialBlockContent: Record<string, unknown> | null = null;
if (activeBlock?.content) {
@ -208,18 +212,28 @@ export default function WorkspacePage() {
<div className="h-6 bg-slate-50/80 rounded-xl w-4/5" />
</div>
) : chapterDetail && activeBlock ? (
// 强制绑定 key={activeBlock.id}!在切换 Block 时强制 React 销毁重建,断绝 ProseMirror 状态污染残留
<MuseEditor
key={activeBlock.id}
blockId={activeBlock.id}
workId={workId}
initialContent={initialBlockContent}
revision={effectiveRevision}
onEditorReady={(editor) => {
// 持有真实编辑器实例,仅用于生成候选时读取当前正文作 Diff 原文。
editorRef.current = editor;
}}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* 章节内多 Block 结构条:切换激活 Block + 分割/合并(work 真实多 Block 时显现价值)。 */}
<BlockStructureBar
workId={workId}
chapterId={activeChapterId}
blocks={blocks}
activeBlockId={activeBlock.id}
onSelectBlock={setActiveBlockId}
/>
{/* 强制绑定 key={activeBlock.id}!在切换 Block 时强制 React 销毁重建,断绝 ProseMirror 状态污染残留 */}
<MuseEditor
key={activeBlock.id}
blockId={activeBlock.id}
workId={workId}
initialContent={initialBlockContent}
revision={effectiveRevision}
onEditorReady={(editor) => {
// 持有真实编辑器实例,仅用于生成候选时读取当前正文作 Diff 原文。
editorRef.current = editor;
}}
/>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 bg-white">
<span className="text-2xl mb-2">❌</span>