From 985a7ac0d8f110dc9fa77f631c728178c19dc0b5 Mon Sep 17 00:00:00 2001 From: lili Date: Sun, 21 Jun 2026 02:53:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20B5b=20planning=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E7=BB=84=E4=BB=B6=20+=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20vitest=20jsx=20runtime[P1]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlanningEditor.tsx:按 projection.fieldType 动态渲染(enum→select/number→number/boolean→checkbox/text→textarea);edits-diff pattern(字段值=编辑优先否则已存值,避免 effect setState 级联渲染);保存收集有值字段为 content+usedFieldKeys,成功清编辑。 - vite.config.ts:esbuild.jsx='automatic'。修复 vitest 默认 classic jsx runtime(需 React in scope)与生产 plugin-react/tsconfig react-jsx 不一致——此前仅 renderHook 测试未暴露,PlanningEditor 首个 render 组件测试触发 'React is not defined'。 验证:vitest PlanningEditor 2/2、全量 64/64、eslint clean、tsc 0 error。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/components/PlanningEditor.test.tsx | 41 ++++ .../editor/components/PlanningEditor.tsx | 176 ++++++++++++++++++ muse-studio/vite.config.ts | 5 + 3 files changed, 222 insertions(+) create mode 100644 muse-studio/src/features/editor/components/PlanningEditor.test.tsx create mode 100644 muse-studio/src/features/editor/components/PlanningEditor.tsx diff --git a/muse-studio/src/features/editor/components/PlanningEditor.test.tsx b/muse-studio/src/features/editor/components/PlanningEditor.test.tsx new file mode 100644 index 00000000..3911adf4 --- /dev/null +++ b/muse-studio/src/features/editor/components/PlanningEditor.test.tsx @@ -0,0 +1,41 @@ +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 PlanningEditor from './PlanningEditor'; +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('PlanningEditor 组件', () => { + beforeEach(() => resetMockDb()); + + it('应按 schema 字段类型渲染对应控件', async () => { + renderWithClient(); + // enum→select、text→textarea(aria-label 为字段显示名)。 + const select = await screen.findByLabelText('时代背景'); + expect(select.tagName).toBe('SELECT'); + expect(screen.getByLabelText('世界观概述').tagName).toBe('TEXTAREA'); + // enum 选项来自 enumValues。 + expect(screen.getByRole('option', { name: '古代' })).toBeTruthy(); + }); + + it('填字段并保存后只回填有值字段(content/usedFieldKeys 仅含填写字段)', async () => { + const onSaved = vi.fn(); + renderWithClient(); + const select = (await screen.findByLabelText('时代背景')) as HTMLSelectElement; + fireEvent.change(select, { target: { value: '古代' } }); + // 故意不填 world_overview。 + fireEvent.click(screen.getByRole('button', { name: /保存规划/ })); + + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + // 保存成功后 invalidate→重拉 projection→useEffect 回填:time_period='古代'、world_overview 仍空。 + await waitFor(() => + expect((screen.getByLabelText('时代背景') as HTMLSelectElement).value).toBe('古代'), + ); + expect((screen.getByLabelText('世界观概述') as HTMLTextAreaElement).value).toBe(''); + }); +}); diff --git a/muse-studio/src/features/editor/components/PlanningEditor.tsx b/muse-studio/src/features/editor/components/PlanningEditor.tsx new file mode 100644 index 00000000..5d42747e --- /dev/null +++ b/muse-studio/src/features/editor/components/PlanningEditor.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import { useMetaProjection, useSavePlanningItem } from '../hooks/usePlanning'; +import type { MetaProjectionDetail } from '@/types/openapi'; + +/** projection 字段类型(取自 MetaProjectionDetail.fields[]) */ +type ProjectionField = MetaProjectionDetail['fields'][number]; + +interface PlanningEditorProps { + /** 作品 ID */ + workId: string; + /** 规划项 / 投影 key(projectionKey == sectionKey) */ + projectionKey: string; + /** 保存成功回调 */ + onSaved?: () => void; +} + +const FIELD_CLS = + 'w-full px-3 py-2 rounded-lg border border-slate-200 bg-slate-50/50 text-sm text-slate-900 focus:bg-white focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-500/20 transition-all disabled:bg-slate-100 disabled:text-slate-400'; + +/** 按 fieldType 渲染对应输入控件;aria-label 用字段显示名,供测试 / 无障碍定位。 */ +function renderField(field: ProjectionField, value: unknown, onChange: (v: unknown) => void) { + const disabled = field.userEditable === false; + const label = field.fieldName ?? field.fieldKey; + switch (field.fieldType) { + case 'enum': + return ( + + ); + case 'number': + return ( + onChange(e.target.value === '' ? undefined : Number(e.target.value))} + className={FIELD_CLS} + /> + ); + case 'boolean': + return ( + onChange(e.target.checked)} + className="h-4 w-4 rounded border-slate-300 text-violet-600" + /> + ); + default: + // text / structured / reference / date 统一用文本域承载。 + return ( +