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:
parent
e7616fc511
commit
985a7ac0d8
@ -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→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(<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('');
|
||||
});
|
||||
});
|
||||
176
muse-studio/src/features/editor/components/PlanningEditor.tsx
Normal file
176
muse-studio/src/features/editor/components/PlanningEditor.tsx
Normal 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;
|
||||
/** 规划项 / 投影 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 (
|
||||
<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 放 fieldKey→value、usedFieldKeys 收集本次有值的 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>
|
||||
);
|
||||
}
|
||||
@ -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'),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user