feat(studio): B5b planning 编辑器组件 + 修复 vitest jsx runtime[P1]

- 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) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-21 02:53:22 -07:00
parent e7616fc511
commit 985a7ac0d8
3 changed files with 222 additions and 0 deletions

View File

@ -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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe('PlanningEditor 组件', () => {
beforeEach(() => resetMockDb());
it('应按 schema 字段类型渲染对应控件', async () => {
renderWithClient(<PlanningEditor workId="1" projectionKey="setting" />);
// enum→select、text→textareaaria-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(<PlanningEditor workId="1" projectionKey="setting" onSaved={onSaved} />);
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('');
});
});

View File

@ -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;
/** 规划项 / 投影 keyprojectionKey == 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 (
<select
aria-label={label}
disabled={disabled}
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
className={FIELD_CLS}
>
<option value=""></option>
{(field.enumValues ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
case 'number':
return (
<input
type="number"
aria-label={label}
disabled={disabled}
value={(value as number | string) ?? ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
className={FIELD_CLS}
/>
);
case 'boolean':
return (
<input
type="checkbox"
aria-label={label}
disabled={disabled}
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-violet-600"
/>
);
default:
// text / structured / reference / date 统一用文本域承载。
return (
<textarea
aria-label={label}
disabled={disabled}
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
rows={2}
className={`${FIELD_CLS} resize-none`}
/>
);
}
}
/** 某字段是否“有值”(用于收集 usedFieldKeys本次实际填了的 schema 字段)。 */
function hasValue(v: unknown): boolean {
return v !== undefined && v !== null && v !== '';
}
/**
* Meta Schema
*
* <p>useMetaProjection fieldType
* content fieldKeyvalueusedFieldKeys schema
* 使 Meta Schema Planning </p>
*/
export default function PlanningEditor({ workId, projectionKey, onSaved }: PlanningEditorProps) {
const { data: projection, isLoading } = useMetaProjection(workId, projectionKey);
const { mutate: save, isPending } = useSavePlanningItem(workId);
// 只记录用户本次的编辑diff。字段当前值 = 编辑优先,否则取 projection 回填的已存值;
// 如此无需在 effect 里同步 setState 初始化表单(避免级联渲染),并天然区分“已存”与“本次改动”。
const [edits, setEdits] = useState<Record<string, unknown>>({});
if (isLoading || !projection) {
return <div className="p-6 text-sm text-slate-400"></div>;
}
// 字段当前值:用户编辑过取编辑值,否则取已存值。
const valueOf = (fieldKey: string, savedValue: unknown) =>
fieldKey in edits ? edits[fieldKey] : savedValue;
const handleChange = (fieldKey: string, value: unknown) => {
setEdits((prev) => ({ ...prev, [fieldKey]: value }));
};
const handleSave = () => {
// content = 各字段当前值(已存值叠加本次编辑),只收有值字段。
const content: Record<string, unknown> = {};
projection.fields.forEach((f) => {
const v = valueOf(f.fieldKey, f.value);
if (hasValue(v)) content[f.fieldKey] = v;
});
// usedFieldKeys = 本次有值的 schema 字段(字段级用量来源,未填的不计入)。
const usedFieldKeys = projection.fields
.filter((f) => hasValue(content[f.fieldKey]))
.map((f) => f.fieldKey);
save(
{
sectionKey: projectionKey,
reqVO: {
commandId: crypto.randomUUID(),
content,
// 乐观锁:以拉取时的 dataRevision 为期望版本(首次为 0 即新建)。
expectedRevision: projection.dataRevision ?? 0,
expectedSchemaVersion: projection.schemaVersion,
expectedProjectionVersion: projection.projectionVersion,
usedFieldKeys,
auditReason: '用户编辑规划',
},
},
// 保存成功后清空本地编辑:已落库,后续以重拉的 projection.value 为准。
{
onSuccess: () => {
setEdits({});
onSaved?.();
},
},
);
};
return (
<div className="p-6">
<h3 className="text-base font-bold text-slate-900 mb-4">{projection.projectionName ?? projectionKey}</h3>
<form
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
className="space-y-4"
>
{projection.fields
.filter((f) => f.uiVisible)
.map((field) => (
<div key={field.fieldKey}>
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
{field.fieldName ?? field.fieldKey}
{field.required && <span className="text-rose-500"> *</span>}
</label>
{renderField(field, valueOf(field.fieldKey, field.value), (v) => handleChange(field.fieldKey, v))}
</div>
))}
<button
type="submit"
disabled={isPending}
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-violet-600 to-indigo-600 text-white text-sm font-semibold shadow-md hover:from-violet-700 hover:to-indigo-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? '保存中…' : '保存规划'}
</button>
</form>
</div>
);
}

View File

@ -5,6 +5,11 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
// vitest 走 esbuild transform默认 classic jsx runtime需 React in scope
// 显式设 automatic与生产 plugin-react / tsconfig react-jsx 一致,组件文件无需手动 import React。
esbuild: {
jsx: 'automatic',
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),