diff --git a/muse-studio/src/api/mocks/handlers/content.ts b/muse-studio/src/api/mocks/handlers/content.ts index 8a45aa51..9a510485 100644 --- a/muse-studio/src/api/mocks/handlers/content.ts +++ b/muse-studio/src/api/mocks/handlers/content.ts @@ -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; + revision: number; + schemaVersion: number; + projectionVersion: number; + usedFieldKeys: string[]; +} + +/** Meta Schema 投影字段定义(固定 schema,供 meta-projection mock 按字段渲染表单) */ +const PROJECTION_DEFS: Record; +}> = { + 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 = {}; + /** * 重置 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; 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({ diff --git a/muse-studio/src/features/editor/hooks/usePlanning.test.tsx b/muse-studio/src/features/editor/hooks/usePlanning.test.tsx new file mode 100644 index 00000000..4de39685 --- /dev/null +++ b/muse-studio/src/features/editor/hooks/usePlanning.test.tsx @@ -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 {children}; +}; + +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'); + }); +}); diff --git a/muse-studio/src/features/editor/hooks/usePlanning.ts b/muse-studio/src/features/editor/hooks/usePlanning.ts new file mode 100644 index 00000000..11987ec2 --- /dev/null +++ b/muse-studio/src/features/editor/hooks/usePlanning.ts @@ -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` 无法承载 + * fieldKey→value 动态字段值,且尚缺 usedFieldKeys 字段。此处 content 放 fieldKey→value, + * usedFieldKeys 传本次用到的 schema 字段(写入后端字段用量快照,供 Planning 维度字段级真实计数)。 + */ +export interface SavePlanningItemDto { + commandId: string; + content: Record; + 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(`/works/${workId}/planning`), + enabled: !!workId, + }); +} + +/** 拉取作品的 Meta Schema 投影摘要列表(哪些 projection/section 可按字段编辑)。 */ +export function useMetaProjections(workId: string | null) { + return useQuery({ + queryKey: ['metaProjections', workId], + queryFn: () => api.get(`/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(`/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(`/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(`/works/${workId}/dynamic-fields/validate`, reqVO), + }); +} diff --git a/muse-studio/src/types/openapi.ts b/muse-studio/src/types/openapi.ts index 2bc355ae..8f0f8ee3 100644 --- a/muse-studio/src/types/openapi.ts +++ b/muse-studio/src/types/openapi.ts @@ -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'];