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:
parent
f3f7a0962f
commit
e7616fc511
@ -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 + usedFieldKeys,revision 自增)
|
||||
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({
|
||||
|
||||
71
muse-studio/src/features/editor/hooks/usePlanning.test.tsx
Normal file
71
muse-studio/src/features/editor/hooks/usePlanning.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
77
muse-studio/src/features/editor/hooks/usePlanning.ts
Normal file
77
muse-studio/src/features/editor/hooks/usePlanning.ts
Normal 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>` 无法承载
|
||||
* fieldKey→value 动态字段值,且尚缺 usedFieldKeys 字段。此处 content 放 fieldKey→value,
|
||||
* 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),
|
||||
});
|
||||
}
|
||||
@ -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-Projection(B5 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'];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user