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:
parent
985a7ac0d8
commit
53fb2076bc
@ -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 返回 unavailable(code 非 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();
|
||||
});
|
||||
});
|
||||
@ -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 端口在真后端可能 unavailable(Meta 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
// 右侧面板 Tab:AI 创作助手 / 作品规划(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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user