feat(studio): B5c-1 planning 编辑器集成 WorkspacePage[P1]

- PlanningEditorPanel.tsx:列 work 的 meta-projection→选→内嵌 PlanningEditor;真后端 Meta owner 未接入投影时优雅降级(空态提示不崩,anti-false-green 不假装有字段)。

- WorkspacePage.tsx:右侧面板 Tab 化(AI 创作助手/作品规划);planning 为 work 级不依赖 activeBlock 故面板始终挂载;AI tab 保持原 activeBlock 挂载语义+空态提示。

验证:vitest PlanningEditorPanel 2/2(列 projection 默认渲染字段、unavailable server.use override 降级)、全量 66/66、tsc 0、eslint clean。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-21 04:14:08 -07:00
parent 985a7ac0d8
commit 53fb2076bc
3 changed files with 143 additions and 13 deletions

View File

@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/setup';
import PlanningEditorPanel from './PlanningEditorPanel';
import { resetMockDb } from '@/api/mocks/handlers/content';
import React from 'react';
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe('PlanningEditorPanel 组件', () => {
beforeEach(() => resetMockDb());
it('应列出 projection 并默认渲染第一个的字段', async () => {
renderWithClient(<PlanningEditorPanel workId="1" />);
// projection 选择条出现「故事设定」。
expect(await screen.findByRole('button', { name: '故事设定' })).toBeInTheDocument();
// 默认选中第一个 → 内嵌 PlanningEditor 渲染其字段。
expect(await screen.findByLabelText('时代背景')).toBeInTheDocument();
});
it('meta-projection unavailable 时优雅降级(不崩)', async () => {
// 模拟真后端 Meta owner 未接入meta-projections 返回 unavailablecode 非 0 → api client 抛错)。
server.use(
http.get('/app-api/muse/works/:workId/meta-projections', () =>
HttpResponse.json({ code: 500, msg: 'external owner unavailable', data: null }),
),
);
renderWithClient(<PlanningEditorPanel workId="1" />);
expect(await screen.findByText('暂无可编辑的规划投影')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,63 @@
import { useState } from 'react';
import { useMetaProjections } from '../hooks/usePlanning';
import PlanningEditor from './PlanningEditor';
interface PlanningEditorPanelProps {
/** 当前作品 ID */
workId: string;
}
/**
* work schema meta-projection
*
* <p>projection Meta owner GET /works/{workId}/meta-projections Meta owner
* unavailable anti-false-green
* schema </p>
*/
export default function PlanningEditorPanel({ workId }: PlanningEditorPanelProps) {
const { data: projections, isLoading, isError } = useMetaProjections(workId);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
if (isLoading) {
return <div className="p-6 text-sm text-slate-400"></div>;
}
// meta-projection 端口在真后端可能 unavailableMeta owner 未接入)或作品未绑 schema → 优雅降级。
if (isError || !projections || projections.length === 0) {
return (
<div className="p-6 text-sm text-slate-400">
<p className="font-semibold text-slate-600 mb-1"></p>
<p className="text-xs leading-relaxed"> Meta Schema</p>
</div>
);
}
// 当前选中 projection用户未选时默认第一个渲染期计算无 effect setState
const activeKey = selectedKey ?? projections[0]?.projectionKey ?? null;
return (
<div className="h-full flex flex-col overflow-hidden">
{/* projection 选择条 */}
<div className="flex gap-2 px-4 py-2 border-b border-slate-100 overflow-x-auto shrink-0">
{projections.map((p) => (
<button
key={p.projectionKey}
type="button"
onClick={() => setSelectedKey(p.projectionKey)}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg whitespace-nowrap transition ${
activeKey === p.projectionKey
? 'bg-violet-100 text-violet-700'
: 'text-slate-500 hover:bg-slate-100'
}`}
>
{p.projectionName ?? p.projectionKey}
</button>
))}
</div>
{/* 选中 projection 的字段编辑器key=activeKey切换时重挂载清本地编辑 */}
<div className="flex-1 overflow-y-auto">
{activeKey && <PlanningEditor key={activeKey} workId={workId} projectionKey={activeKey} />}
</div>
</div>
);
}

View File

@ -11,6 +11,7 @@ import MuseEditor from '../features/editor/components/MuseEditor';
import type { MuseEditorInstance } from '../features/editor/components/MuseEditor';
import AIPanel from '../features/editor/components/AIPanel';
import CandidatePanel from '../features/editor/components/CandidatePanel';
import PlanningEditorPanel from '../features/editor/components/PlanningEditorPanel';
import type { WorkVO } from '@/types/openapi';
/**
@ -55,6 +56,8 @@ export default function WorkspacePage() {
const [candidateSuggestionId, setCandidateSuggestionId] = useState<string | null>(null);
const [originalText, setOriginalText] = useState('');
const [showCompare, setShowCompare] = useState(false);
// 右侧面板 TabAI 创作助手 / 作品规划planning 为 work 级,不依赖 activeBlock故面板始终挂载
const [rightTab, setRightTab] = useState<'ai' | 'planning'>('ai');
// 本地 revision 覆盖:采纳成功后后端返回 newRevision先用它即时校准本地乐观锁避免连续采纳因 revision 滞后误触 409。
const [revisionOverride, setRevisionOverride] = useState<number | null>(null);
@ -235,22 +238,50 @@ export default function WorkspacePage() {
)}
</div>
{/* 右侧 AI 助手 / 候选对比面板:仅在已激活 Block 时挂载AI 生成与采纳都需要目标 Block */}
{activeBlock && (
<div className="h-full flex overflow-hidden">
{showCompare ? (
<CandidatePanel
originalText={originalText}
candidateText={candidateText}
onAccept={handleAcceptCandidate}
onReject={handleRejectCandidate}
isSubmitting={acceptSuggestion.isPending}
/>
{/* 右侧面板Tab 切换「AI 创作助手」(需激活 Block) 与「作品规划」(work 级,始终可用)。 */}
<div className="w-80 h-full flex flex-col overflow-hidden border-l border-slate-200 bg-white">
{/* Tab 导航栏 */}
<div className="flex gap-2 px-3 py-2 border-b border-slate-200 bg-slate-50 shrink-0">
<button
type="button"
onClick={() => setRightTab('ai')}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition ${
rightTab === 'ai' ? 'bg-violet-600 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
AI
</button>
<button
type="button"
onClick={() => setRightTab('planning')}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition ${
rightTab === 'planning' ? 'bg-violet-600 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
</button>
</div>
{/* Tab 内容区planning 始终可用AI 须有激活 Block生成与采纳都需要目标 Block。 */}
<div className="flex-1 overflow-hidden">
{rightTab === 'planning' ? (
<PlanningEditorPanel workId={workId} />
) : activeBlock ? (
showCompare ? (
<CandidatePanel
originalText={originalText}
candidateText={candidateText}
onAccept={handleAcceptCandidate}
onReject={handleRejectCandidate}
isSubmitting={acceptSuggestion.isPending}
/>
) : (
<AIPanel onCandidateGenerated={handleCandidateGenerated} />
)
) : (
<AIPanel onCandidateGenerated={handleCandidateGenerated} />
<div className="p-6 text-sm text-slate-400">使 AI </div>
)}
</div>
)}
</div>
</div>
</div>
);