feat(studio): B5a planning 编辑器数据层 - hook + mock + vitest[P1]

B5(从零建 planning 编辑器,让 Planning 字段级数据真实化)数据层:

- usePlanning.ts:usePlanning/useMetaProjections/useMetaProjection/useSavePlanningItem/useValidateDynamicFields。SavePlanningItemDto 内联(openapi 生成 content 为 Record<string,never> 无法承载 fieldKey→value,且缺 usedFieldKeys)。

- openapi.ts 导出 PlanningStructure/MetaProjectionDetail 等;content.ts mock 加 planning+meta-projections+validate handler(PROJECTION_DEFS 固定 schema 字段,保存记录 content+usedFieldKeys,详情回填 value)。

验证:vitest usePlanning 4/4(拉投影→保存 content+usedFieldKeys→回填 value 数据流)、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:44:27 -07:00
parent f3f7a0962f
commit e7616fc511
4 changed files with 246 additions and 0 deletions

View File

@ -161,6 +161,33 @@ const INITIAL_CHAPTERS: MockChapter[] = [
let mockWorks = JSON.parse(JSON.stringify(INITIAL_WORKS)) as MockWork[];
let mockChapters = JSON.parse(JSON.stringify(INITIAL_CHAPTERS)) as MockChapter[];
/** B5 planning section mock 类型 */
interface MockPlanningSection {
sectionKey: string;
sectionName: string;
content: Record<string, unknown>;
revision: number;
schemaVersion: number;
projectionVersion: number;
usedFieldKeys: string[];
}
/** Meta Schema 投影字段定义(固定 schema供 meta-projection mock 按字段渲染表单) */
const PROJECTION_DEFS: Record<string, {
projectionKey: string; projectionName: string; schemaVersion: number; projectionVersion: number;
fields: Array<{ fieldKey: string; fieldName: string; fieldType: string; uiVisible: boolean; userEditable: boolean; enumValues?: string[] }>;
}> = {
setting: {
projectionKey: 'setting', projectionName: '故事设定', schemaVersion: 1, projectionVersion: 1,
fields: [
{ fieldKey: 'time_period', fieldName: '时代背景', fieldType: 'enum', uiVisible: true, userEditable: true, enumValues: ['古代', '现代', '未来'] },
{ fieldKey: 'world_overview', fieldName: '世界观概述', fieldType: 'text', uiVisible: true, userEditable: true },
],
},
};
let mockPlanningSections: Record<string, MockPlanningSection[]> = {};
/**
* Mock
* beforeEach
@ -168,12 +195,75 @@ let mockChapters = JSON.parse(JSON.stringify(INITIAL_CHAPTERS)) as MockChapter[]
export function resetMockDb(): void {
mockWorks = JSON.parse(JSON.stringify(INITIAL_WORKS));
mockChapters = JSON.parse(JSON.stringify(INITIAL_CHAPTERS));
mockPlanningSections = {};
}
/**
* API MSW Mock
*/
export const contentHandlers = [
// ===== B5 planning 编辑器planning 结构 + meta-projection 字段 =====
// GET planning 结构(已确认的 sections
http.get('/app-api/muse/works/:workId/planning', ({ params }) => {
const sections = mockPlanningSections[String(params.workId)] ?? [];
return HttpResponse.json({ code: 0, msg: 'success', data: {
workId: String(params.workId),
revision: sections.reduce((m, s) => Math.max(m, s.revision), 0),
sections,
} });
}),
// GET meta-projection 摘要列表
http.get('/app-api/muse/works/:workId/meta-projections', () => {
const list = Object.values(PROJECTION_DEFS).map((p) => ({
projectionKey: p.projectionKey, projectionName: p.projectionName,
schemaVersion: p.schemaVersion, projectionVersion: p.projectionVersion, fieldCount: p.fields.length,
}));
return HttpResponse.json({ code: 0, msg: 'success', data: list });
}),
// GET meta-projection 详情fields[].value 回填已存 planning 值)
http.get('/app-api/muse/works/:workId/meta-projections/:projectionKey', ({ params }) => {
const def = PROJECTION_DEFS[String(params.projectionKey)];
if (!def) return HttpResponse.json({ code: 404, msg: 'projection 不存在', data: null });
const sections = mockPlanningSections[String(params.workId)] ?? [];
const saved = sections.find((s) => s.sectionKey === def.projectionKey);
const fields = def.fields.map((f) => ({ ...f, value: saved?.content[f.fieldKey] }));
return HttpResponse.json({ code: 0, msg: 'success', data: {
projectionKey: def.projectionKey, projectionName: def.projectionName,
schemaVersion: def.schemaVersion, projectionVersion: def.projectionVersion,
dataRevision: saved?.revision ?? 0, fields,
} });
}),
// PUT 保存 planning 项(记录 content + usedFieldKeysrevision 自增)
http.put('/app-api/muse/works/:workId/planning/:sectionKey', async ({ params, request }) => {
const body = (await request.json()) as { content: Record<string, unknown>; usedFieldKeys?: string[] };
const workId = String(params.workId);
const sectionKey = String(params.sectionKey);
const sections = mockPlanningSections[workId] ?? (mockPlanningSections[workId] = []);
const def = PROJECTION_DEFS[sectionKey];
let section = sections.find((s) => s.sectionKey === sectionKey);
if (!section) {
section = {
sectionKey, sectionName: def?.projectionName ?? sectionKey, content: {},
revision: 0, schemaVersion: def?.schemaVersion ?? 1, projectionVersion: def?.projectionVersion ?? 1,
usedFieldKeys: [],
};
sections.push(section);
}
section.content = body.content;
section.usedFieldKeys = body.usedFieldKeys ?? [];
section.revision += 1;
return HttpResponse.json({ code: 0, msg: 'success', data: {
sectionKey, newRevision: section.revision, updatedAt: '2026-06-21T00:00:00Z',
} });
}),
// POST 校验动态字段mock 简化:全 valid
http.post('/app-api/muse/works/:workId/dynamic-fields/validate', async ({ request }) => {
const body = (await request.json()) as { fields: Array<{ fieldKey: string }> };
return HttpResponse.json({ code: 0, msg: 'success', data: {
valid: true, fieldResults: body.fields.map((f) => ({ fieldKey: f.fieldKey, valid: true })),
} });
}),
// GET /app-api/muse/works — 获取作品列表
http.get('/app-api/muse/works', () => {
return HttpResponse.json({

View File

@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { usePlanning, useMetaProjections, useMetaProjection, useSavePlanningItem } from './usePlanning';
import { resetMockDb } from '@/api/mocks/handlers/content';
import React from 'react';
const createTestQueryClient = () =>
new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const wrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = createTestQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
describe('usePlanning / meta-projection hooks 单元测试', () => {
beforeEach(() => {
resetMockDb();
});
it('useMetaProjections 应拉取投影摘要列表', async () => {
const { result } = renderHook(() => useMetaProjections('1'), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const list = result.current.data ?? [];
expect(list.length).toBeGreaterThanOrEqual(1);
expect(list[0]?.projectionKey).toBe('setting');
});
it('useMetaProjection 应拉取 projection 字段定义', async () => {
const { result } = renderHook(() => useMetaProjection('1', 'setting'), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const fields = result.current.data?.fields ?? [];
expect(fields.map((f) => f.fieldKey)).toContain('time_period');
expect(fields.map((f) => f.fieldKey)).toContain('world_overview');
// 未保存时 value 为空。
expect(fields.find((f) => f.fieldKey === 'time_period')?.value).toBeUndefined();
});
it('usePlanning 初始应为空结构', async () => {
const { result } = renderHook(() => usePlanning('1'), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.sections ?? []).toHaveLength(0);
});
it('useSavePlanningItem 保存后应记录 content 并回填到 projection value', async () => {
const { result: saveHook } = renderHook(() => useSavePlanningItem('1'), { wrapper });
saveHook.current.mutate({
sectionKey: 'setting',
reqVO: {
commandId: 'cmd-1',
content: { time_period: '古代', world_overview: '架空世界' },
expectedRevision: 0,
usedFieldKeys: ['time_period', 'world_overview'],
},
});
await waitFor(() => expect(saveHook.current.isSuccess).toBe(true));
expect(saveHook.current.data?.newRevision).toBe(1);
// 重新拉 projection验证 value 回填(保存的字段值经 mock 回填到 fields[].value
const { result: projHook } = renderHook(() => useMetaProjection('1', 'setting'), { wrapper });
await waitFor(() => expect(projHook.current.isSuccess).toBe(true));
const fields = projHook.current.data?.fields ?? [];
expect(fields.find((f) => f.fieldKey === 'time_period')?.value).toBe('古代');
// planning 结构也应有该 section。
const { result: planHook } = renderHook(() => usePlanning('1'), { wrapper });
await waitFor(() => expect(planHook.current.isSuccess).toBe(true));
expect(planHook.current.data?.sections ?? []).toHaveLength(1);
expect(planHook.current.data?.sections?.[0]?.sectionKey).toBe('setting');
});
});

View File

@ -0,0 +1,77 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/api/client';
import type {
PlanningStructure,
PlanningItemSaveResult,
MetaProjectionSummary,
MetaProjectionDetail,
DynamicFieldValidationRequest,
DynamicFieldValidationResult,
} from '@/types/openapi';
/**
* planning DTO
*
* openapi SavePlanningItemRequest content `Record<string, never>`
* fieldKeyvalue usedFieldKeys content fieldKeyvalue
* usedFieldKeys schema Planning
*/
export interface SavePlanningItemDto {
commandId: string;
content: Record<string, unknown>;
expectedRevision: number;
expectedSchemaVersion?: number;
expectedProjectionVersion?: number;
usedFieldKeys?: string[];
auditReason?: string;
}
/** 拉取作品的正式规划结构(已确认的 sections。 */
export function usePlanning(workId: string | null) {
return useQuery({
queryKey: ['planning', workId],
queryFn: () => api.get<PlanningStructure>(`/works/${workId}/planning`),
enabled: !!workId,
});
}
/** 拉取作品的 Meta Schema 投影摘要列表(哪些 projection/section 可按字段编辑)。 */
export function useMetaProjections(workId: string | null) {
return useQuery({
queryKey: ['metaProjections', workId],
queryFn: () => api.get<MetaProjectionSummary[]>(`/works/${workId}/meta-projections`),
enabled: !!workId,
});
}
/** 拉取某 projection 的字段定义 + 已存值回填(按 schema 字段渲染表单的数据源)。 */
export function useMetaProjection(workId: string | null, projectionKey: string | null) {
return useQuery({
queryKey: ['metaProjection', workId, projectionKey],
queryFn: () =>
api.get<MetaProjectionDetail>(`/works/${workId}/meta-projections/${projectionKey}`),
enabled: !!workId && !!projectionKey,
});
}
/** 保存 planning 项PUT传 usedFieldKeys 落字段用量快照)。 */
export function useSavePlanningItem(workId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { sectionKey: string; reqVO: SavePlanningItemDto }) =>
api.put<PlanningItemSaveResult>(`/works/${workId}/planning/${params.sectionKey}`, params.reqVO),
onSuccess: () => {
// planning 与对应 projection 缓存失效projection.fields[].value 会回填新存值)。
queryClient.invalidateQueries({ queryKey: ['planning', workId] });
queryClient.invalidateQueries({ queryKey: ['metaProjection', workId] });
},
});
}
/** 保存前按 schema 校验动态字段(可选;返回 valid + 字段错误 + 路由建议)。 */
export function useValidateDynamicFields(workId: string) {
return useMutation({
mutationFn: (reqVO: DynamicFieldValidationRequest) =>
api.post<DynamicFieldValidationResult>(`/works/${workId}/dynamic-fields/validate`, reqVO),
});
}

View File

@ -46,3 +46,11 @@ export type AccountProfile = AccountComponents['schemas']['AccountProfile'];
export type AppEntitlementDetail = AccountComponents['schemas']['AppEntitlementDetail'];
export type AppUsageSummary = AccountComponents['schemas']['AppUsageSummary'];
export type ProfileUpdateRequest = AccountComponents['schemas']['ProfileUpdateRequest'];
// Planning / Meta-ProjectionB5 planning 编辑器按 schema 字段填值)
export type PlanningStructure = ContentComponents['schemas']['PlanningStructure'];
export type PlanningSection = ContentComponents['schemas']['PlanningSection'];
export type PlanningItemSaveResult = ContentComponents['schemas']['PlanningItemSaveResult'];
export type MetaProjectionSummary = ContentComponents['schemas']['MetaProjectionSummary'];
export type MetaProjectionDetail = ContentComponents['schemas']['MetaProjectionDetail'];
export type DynamicFieldValidationRequest = ContentComponents['schemas']['DynamicFieldValidationRequest'];
export type DynamicFieldValidationResult = ContentComponents['schemas']['DynamicFieldValidationResult'];