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:
parent
d0ea9479f1
commit
af781163a9
@ -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());
|
||||
});
|
||||
});
|
||||
130
muse-studio/src/features/editor/components/BlockStructureBar.tsx
Normal file
130
muse-studio/src/features/editor/components/BlockStructureBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user