diff --git a/muse-studio/src/features/editor/components/PlanningEditorPanel.test.tsx b/muse-studio/src/features/editor/components/PlanningEditorPanel.test.tsx new file mode 100644 index 00000000..5b658a9e --- /dev/null +++ b/muse-studio/src/features/editor/components/PlanningEditorPanel.test.tsx @@ -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({ui}); +}; + +describe('PlanningEditorPanel 组件', () => { + beforeEach(() => resetMockDb()); + + it('应列出 projection 并默认渲染第一个的字段', async () => { + renderWithClient(); + // 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(); + expect(await screen.findByText('暂无可编辑的规划投影')).toBeInTheDocument(); + }); +}); diff --git a/muse-studio/src/features/editor/components/PlanningEditorPanel.tsx b/muse-studio/src/features/editor/components/PlanningEditorPanel.tsx new file mode 100644 index 00000000..f27116cc --- /dev/null +++ b/muse-studio/src/features/editor/components/PlanningEditorPanel.tsx @@ -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,选中后按字段编辑。 + * + *

projection 由 Meta owner 投影(GET /works/{workId}/meta-projections)。真后端若 Meta owner + * 未接入投影实现,端口返回 unavailable → 此处优雅降级为空态提示,不崩溃(anti-false-green: + * 不假装有字段可填)。投影就绪后即可按 schema 字段填值并落字段用量快照。

+ */ +export default function PlanningEditorPanel({ workId }: PlanningEditorPanelProps) { + const { data: projections, isLoading, isError } = useMetaProjections(workId); + const [selectedKey, setSelectedKey] = useState(null); + + if (isLoading) { + return
加载规划投影…
; + } + + // meta-projection 端口在真后端可能 unavailable(Meta owner 未接入)或作品未绑 schema → 优雅降级。 + if (isError || !projections || projections.length === 0) { + return ( +
+

暂无可编辑的规划投影

+

该作品未绑定已激活的 Meta Schema,或投影服务尚未就绪。

+
+ ); + } + + // 当前选中 projection:用户未选时默认第一个(渲染期计算,无 effect setState)。 + const activeKey = selectedKey ?? projections[0]?.projectionKey ?? null; + + return ( +
+ {/* projection 选择条 */} +
+ {projections.map((p) => ( + + ))} +
+ {/* 选中 projection 的字段编辑器(key=activeKey:切换时重挂载清本地编辑) */} +
+ {activeKey && } +
+
+ ); +} diff --git a/muse-studio/src/pages/WorkspacePage.tsx b/muse-studio/src/pages/WorkspacePage.tsx index da43c0f7..ad8e163a 100644 --- a/muse-studio/src/pages/WorkspacePage.tsx +++ b/muse-studio/src/pages/WorkspacePage.tsx @@ -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(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(null); @@ -235,22 +238,50 @@ export default function WorkspacePage() { )} - {/* 右侧 AI 助手 / 候选对比面板:仅在已激活 Block 时挂载(AI 生成与采纳都需要目标 Block) */} - {activeBlock && ( -
- {showCompare ? ( - + {/* 右侧面板:Tab 切换「AI 创作助手」(需激活 Block) 与「作品规划」(work 级,始终可用)。 */} +
+ {/* Tab 导航栏 */} +
+ + +
+ {/* Tab 内容区:planning 始终可用;AI 须有激活 Block(生成与采纳都需要目标 Block)。 */} +
+ {rightTab === 'planning' ? ( + + ) : activeBlock ? ( + showCompare ? ( + + ) : ( + + ) ) : ( - +
请在左侧大纲选择章节,以使用 AI 创作助手。
)}
- )} +
);