oh-my-muse/docs/superpowers/plans/2026-05-24-P2-muse-studio.md
zizi 317dfd4d13 feat(muse-studio): 搭建 P2 用户端工作台
完成 Muse Studio React SPA 基座和六个用户端功能域,接入 MSW Mock、Tiptap 写作台、知识库资料管理、智能体槽位预检绑定、市场授权安装和个人中心用量权益视图。
2026-05-25 10:15:28 +08:00

34 KiB
Raw Blame History

P2: muse-studio 用户端搭建 — 执行计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

目标: 从零搭建 muse-studio React SPA实现写作台、作品管理、知识库、智能体、市场、个人中心 6 个功能域。

架构: Vite + React + TypeScript SPATanStack Query 数据层Zustand 状态管理Tiptap/ProseMirror 编辑器MSW Mock APISSE 双通道实时通信IndexedDB 安全网。

技术栈: React 18, TypeScript 5, Vite 6, TanStack Query 5, Zustand 4, React Hook Form 7 + Zod 3, Tiptap 2, Tailwind CSS 3, MSW 2, Vitest + Testing Library + Playwright


执行状态2026-05-25

当前提交范围: muse-studio/ 用户端 SPA 已完成 6 个功能域的可交互实现:作品工作区、写作台、知识库工作台、智能体工作台、创作市场、个人中心。

已完成基础设施:

  • Vite + React 18 + TypeScript 工程骨架。
  • React Router 应用路由与侧边栏布局。
  • TanStack Query 数据层、Zustand 编辑器状态、统一 API Client。
  • MSW Mock API覆盖 content、ai、knowledge、market、account。
  • Tiptap 编辑器、IndexedDB 草稿安全网、SSE AI 流式生成、候选 diff 与采纳流程。

已完成业务域:

  • Feature 1 写作台编辑器、AI 生成面板、候选对比与采纳。
  • Feature 2 我的作品 + 工作台:作品列表、创建、删除、章节大纲、新建章节、删除章节、切章重载。
  • Feature 3 知识库工作台:按当前产品规格演进为知识库列表、资料管理、上传、手写条目、外链导入、解析状态轮询、删除/卸载。
  • Feature 4 智能体工作台:智能体列表、创建、试用、作品开放槽位预检与绑定。
  • Feature 5 市场 + 个人中心:市场搜索筛选、授权获取、安装;账户资料、权益配额、用量摘要。

验证记录:

  • pnpm exec tsc -b --pretty false 通过。
  • pnpm exec vitest run 通过7 个测试文件、23 个测试。
  • pnpm build 通过;仍有 Vite chunk > 500KB 提示,后续可做路由级 code splitting。
  • pnpm lint 退出码 0public/mockServiceWorker.js 生成文件有 unused eslint-disable warning。
  • 已用 Playwright + 本机 Chrome 冒烟 /agents/market/account,页面非空且无明显加载失败或重叠。

未完成或后续增强:

  • 尚未补 Playwright E2E 自动化用例。
  • 尚未按完成标准统计组件/Hook 覆盖率百分比。
  • public/mockServiceWorker.js lint warning 建议通过生成文件 ignore 处理,不手改生成文件。

Step 1: 工程脚手架

Task 1.1: 初始化 Vite + React + TS 项目

仓库路径: muse-studio/(新建)

  • Step 1: 创建项目
pnpm create vite@latest muse-studio --template react-ts
cd muse-studio
  • Step 2: 安装核心依赖
pnpm add react@18 react-dom@18 react-router-dom@6
pnpm add @tanstack/react-query@5
pnpm add zustand@4
pnpm add react-hook-form@7 zod@3 @hookform/resolvers@3
pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-placeholder
pnpm add -D tailwindcss@3 postcss autoprefixer
pnpm add -D @types/react @types/react-dom
pnpm add -D vitest@2 @testing-library/react @testing-library/jest-dom jsdom
pnpm add -D eslint@9 prettier @typescript-eslint/parser
pnpm add -D msw@2
pnpm add -D playwright@1 @playwright/test
pnpm add -D start-server-and-test
  • Step 3: 配置 Vite
// muse-studio/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
  server: {
    port: 5173,
    proxy: {
      '/app-api': {
        target: 'http://localhost:48080',
        changeOrigin: true,
      },
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    css: true,
  },
})
  • Step 4: 创建目录结构
mkdir -p muse-studio/src/{app/routes,components/{ui,layout,feedback},features/{editor,agent,knowledge,market,account},hooks,stores,lib,types,api/{hooks,mocks/handlers},test}
  • Step 5: 配置 Tailwind
npx tailwindcss init -p
// muse-studio/tailwind.config.js
export default {
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: { extend: {} },
  plugins: [],
}
  • Step 6: 提交
git init && git add -A && git commit -m "feat(init): 初始化 Vite + React + TS 项目脚手架"

Task 1.2: 路由框架 + 布局

文件:

  • 创建: src/app/routes/index.tsx

  • 创建: src/components/layout/AppLayout.tsx

  • 创建: src/pages/{Workspace,Editor,Knowledge,Agent,Market,Account}Page.tsx

  • Step 1: 编写路由配置

// src/app/routes/index.tsx
import { createBrowserRouter } from 'react-router-dom';
import AppLayout from '@/components/layout/AppLayout';
import WorkspacePage from '@/pages/WorkspacePage';
import EditorPage from '@/pages/EditorPage';
import KnowledgePage from '@/pages/KnowledgePage';
import AgentPage from '@/pages/AgentPage';
import MarketPage from '@/pages/MarketPage';
import AccountPage from '@/pages/AccountPage';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <AppLayout />,
    children: [
      { index: true, element: <WorkspacePage /> },
      { path: 'works/:workId', element: <WorkspacePage /> },
      { path: 'works/:workId/editor/:chapterId', element: <EditorPage /> },
      { path: 'knowledge', element: <KnowledgePage /> },
      { path: 'knowledge/:workId', element: <KnowledgePage /> },
      { path: 'agents', element: <AgentPage /> },
      { path: 'market', element: <MarketPage /> },
      { path: 'account', element: <AccountPage /> },
    ],
  },
]);
  • Step 2: 编写布局组件
// src/components/layout/AppLayout.tsx
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';

export default function AppLayout() {
  return (
    <div className="flex h-screen bg-gray-50">
      <Sidebar />
      <main className="flex-1 overflow-auto">
        <Outlet />
      </main>
    </div>
  );
}
  • Step 3: 实现侧边栏导航
// src/components/layout/Sidebar.tsx
import { NavLink } from 'react-router-dom';

const navItems = [
  { to: '/', label: '我的作品', icon: '📝' },
  { to: '/knowledge', label: '知识库', icon: '📚' },
  { to: '/agents', label: '智能体', icon: '🤖' },
  { to: '/market', label: '市场', icon: '🏪' },
  { to: '/account', label: '个人中心', icon: '👤' },
];

export default function Sidebar() {
  return (
    <nav className="w-16 bg-white border-r flex flex-col items-center py-4 gap-2">
      {navItems.map(item => (
        <NavLink key={item.to} to={item.to}
          className={({ isActive }) =>
            `p-2 rounded text-xs ${isActive ? 'bg-blue-100' : 'hover:bg-gray-100'}`
          }>
          <div className="text-xl">{item.icon}</div>
          <div>{item.label}</div>
        </NavLink>
      ))}
    </nav>
  );
}
  • Step 4: 创建占位页面
// src/pages/WorkspacePage.tsx
export default function WorkspacePage() {
  return <div className="p-6"><h1 className="text-2xl font-bold">我的作品</h1></div>;
}
  • Step 5: Main.tsx 集成路由
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { router } from './app/routes';
import './index.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 1,
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </React.StrictMode>
);
  • Step 6: 验证启动
cd muse-studio && pnpm dev

浏览 http://localhost:5173,验证路由导航正常。

  • Step 7: 提交
git add -A && git commit -m "feat(router): 添加路由框架 + 布局组件 + 6 个功能域占位"

Step 2: 核心基础设施

Task 2.1: API 客户端 + 类型集成

文件:

  • 创建: src/api/client.ts

  • 创建: src/types/openapi.ts(从 OpenAPI 生成物导入)

  • Step 1: 创建 API 客户端

// src/api/client.ts
import { Base } from '@/types/openapi';

const BASE_URL = '/app-api/muse';

class ApiError extends Error {
  constructor(public code: string, message: string, public status: number) {
    super(message);
  }
}

export async function request<T>(
  path: string,
  options: RequestInit = {}
): Promise<T> {
  const response = await fetch(`${BASE_URL}${path}`, {
    headers: {
      'Content-Type': 'application/json',
      'X-API-Version': '1',
      ...options.headers,
    },
    ...options,
  });

  const body: Base.CommonResult<T> = await response.json();

  if (body.code !== 0) {
    throw new ApiError(String(body.code), body.msg, response.status);
  }

  return body.data as T;
}

export const api = {
  get: <T>(path: string) => request<T>(path),
  post: <T>(path: string, data?: unknown) =>
    request<T>(path, {
      method: 'POST',
      body: data ? JSON.stringify(data) : undefined,
    }),
  put: <T>(path: string, data?: unknown) =>
    request<T>(path, {
      method: 'PUT',
      body: data ? JSON.stringify(data) : undefined,
    }),
  delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
  • Step 2: 复制类型包
cp docs/api-contracts/generated/typescript/*.ts muse-studio/src/types/
  • Step 3: 编写类型入口
// src/types/openapi.ts
export type * as Base from './base';
export type * as Content from './content';
export type * as AI from './ai';
export type * as Knowledge from './knowledge';
export type * as Market from './market';
export type * as Account from './account';
export type * as Meta from './meta';
  • Step 4: 提交
git add -A && git commit -m "feat(api): 集成 API 客户端 + OpenAPI 类型包"

Task 2.2: MSW Mock API 配置

文件:

  • 创建: src/api/mocks/handlers/ 下各模块 handler

  • Step 1: 安装 MSW

pnpm add -D msw@2
npx msw init public/ --save
  • Step 2: 编写 Content Mock Handlers
// src/test/mocks/handlers/content.ts
import { http, HttpResponse } from 'msw';

export const contentHandlers = [
  // GET /app-api/muse/works — 作品列表
  http.get('/app-api/muse/works', ({ request }) => {
    const url = new URL(request.url);
    const pageNo = parseInt(url.searchParams.get('pageNo') || '1');
    return HttpResponse.json({
      code: 0,
      msg: 'success',
      data: {
        total: 3,
        pageNo,
        pageSize: 20,
        list: [
          {
            id: '1',
            title: '星海迷途',
            status: 'active',
            genre: '科幻',
            wordCount: 52300,
            chapterCount: 12,
            updatedAt: '2026-05-20T10:30:00Z',
          },
          {
            id: '2',
            title: '长安旧事',
            status: 'draft',
            genre: '历史',
            wordCount: 12800,
            chapterCount: 3,
            updatedAt: '2026-05-24T08:00:00Z',
          },
          {
            id: '3',
            title: '深渊笔记',
            status: 'active',
            genre: '悬疑',
            wordCount: 89500,
            chapterCount: 24,
            updatedAt: '2026-05-23T18:00:00Z',
          },
        ],
      },
    });
  }),

  // POST /app-api/muse/works — 创建作品
  http.post('/app-api/muse/works', async ({ request }) => {
    const body = await request.json();
    const work = {
      id: crypto.randomUUID(),
      title: (body as any).title || '未命名作品',
      description: (body as any).description || '',
      status: 'draft',
      genre: (body as any).genre || '',
      wordCount: 0,
      chapterCount: 0,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    return HttpResponse.json({ code: 0, msg: 'success', data: work }, { status: 201 });
  }),

  // GET /app-api/muse/works/:workId — 作品详情
  http.get('/app-api/muse/works/:workId', ({ params }) => {
    return HttpResponse.json({
      code: 0, msg: 'success',
      data: {
        id: params.workId,
        title: '星海迷途',
        description: '人类在星际探索中...',
        status: 'active',
        genre: '科幻',
        wordCount: 52300,
        chapterCount: 12,
        createdAt: '2026-05-01T00:00:00Z',
        updatedAt: '2026-05-20T10:30:00Z',
      },
    });
  }),
];

// src/api/mocks/handlers/ai.ts
import { http, HttpResponse } from 'msw';

export const aiHandlers = [
  // Mock AI SSE 流式生成接口
  http.get('/app-api/muse/ai/stream', () => {
    const encoder = new TextEncoder();
    const stream = new ReadableStream({
      async start(controller) {
        const chunks = ['宇宙', '深处', '闪烁着', '未知的', '微光,', '那是', '文明的', '遗迹。'];
        for (const chunk of chunks) {
          await new Promise((resolve) => setTimeout(resolve, 100)); // 模拟 Token 渲染间隔
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ type: 'chunk', content: chunk, sequenceNo: chunks.indexOf(chunk) })}\n\n`)
          );
        }
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ type: 'done', generationId: 'g-mock-123', candidateId: 'c-mock-456' })}\n\n`)
        );
        controller.close();
      },
    });

    return new HttpResponse(stream, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
      },
    });
  }),
];
  • Step 3: 配置 MSW 入口
// src/api/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { contentHandlers } from './handlers/content';
import { aiHandlers } from './handlers/ai';
import { knowledgeHandlers } from './handlers/knowledge';
import { marketHandlers } from './handlers/market';
import { accountHandlers } from './handlers/account';

export const worker = setupWorker(
  ...contentHandlers,
  ...aiHandlers,
  ...knowledgeHandlers,
  ...marketHandlers,
  ...accountHandlers
);
  • Step 4: 在开发环境启用 MSW
// src/main.tsx 开头追加
if (import.meta.env.DEV) {
  const { worker } = await import('./api/mocks/browser');
  await worker.start({ onUnhandledRequest: 'bypass' });
}
  • Step 5: 提交
git add -A && git commit -m "feat(mock): 配置 MSW Mock APIContent handlers"

Task 2.3: SSE 双通道客户端

文件:

  • 创建: src/lib/sse.ts
// src/lib/sse.ts

/**
 * Muse SSE 双通道设计:
 * 1. AI stream: 独立连接,用于 AI 生成实时流
 * 2. Event stream: 长连接,用于事件通知,支持 lastEventId 续传
 */

export type SSEEventHandler = {
  onChunk?: (data: { content: string; sequenceNo: number }) => void;
  onQualityCheck?: (data: { dimension: string; score: number; passed: boolean }) => void;
  onDone?: (data: { generationId: string; candidateId: string }) => void;
  onError?: (data: { code: string; message: string }) => void;
};

export function connectAIStream(
  url: string,
  handlers: SSEEventHandler
): AbortController {
  const controller = new AbortController();

  fetch(url, {
    headers: {
      'Accept': 'text/event-stream',
      'X-API-Version': '1',
    },
    signal: controller.signal,
  }).then(async (response) => {
    const reader = response.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = JSON.parse(line.slice(6));
          switch (data.type) {
            case 'chunk': handlers.onChunk?.(data); break;
            case 'quality_check': handlers.onQualityCheck?.(data); break;
            case 'done': handlers.onDone?.(data); break;
            case 'error': handlers.onError?.(data); break;
          }
        }
      }
    }
  }).catch((err) => {
    if (err.name !== 'AbortError') {
      handlers.onError?.({ code: 'SSE_ERROR', message: err.message });
    }
  });

  return controller;
}

export function connectEventStream(
  lastEventId?: string
): { close: () => void; on: (event: string, handler: (data: unknown) => void) => void } {
  const eventSource = new EventSource(
    `/app-api/muse/events${lastEventId ? `?lastEventId=${lastEventId}` : ''}`
  );

  return {
    close: () => eventSource.close(),
    on: (event, handler) => {
      eventSource.addEventListener(event, (e) => {
        handler(JSON.parse(e.data));
      });
    },
  };
}
  • Step: 提交
git add -A && git commit -m "feat(sse): 实现 SSE 双通道客户端AI stream + Event stream"

Task 2.4: IndexedDB 持久化层

文件:

  • 创建: src/lib/indexed-db.ts
// src/lib/indexed-db.ts
const DB_NAME = 'muse-studio';
const BLOCK_STORE = 'block-drafts';
const DB_VERSION = 1;

function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(BLOCK_STORE)) {
        db.createObjectStore(BLOCK_STORE, { keyPath: 'blockId' });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

export async function saveBlockDraft(blockId: string, content: Record<string, unknown>): Promise<void> {
  const db = await openDB();
  const tx = db.transaction(BLOCK_STORE, 'readwrite');
  tx.objectStore(BLOCK_STORE).put({ blockId, content, savedAt: Date.now() });
}

export async function getBlockDraft(blockId: string): Promise<Record<string, unknown> | null> {
  const db = await openDB();
  const tx = db.transaction(BLOCK_STORE, 'readonly');
  return new Promise((resolve, reject) => {
    const req = tx.objectStore(BLOCK_STORE).get(blockId);
    req.onsuccess = () => resolve(req.result?.content ?? null);
    req.onerror = () => reject(req.error);
  });
}

export async function removeBlockDraft(blockId: string): Promise<void> {
  const db = await openDB();
  const tx = db.transaction(BLOCK_STORE, 'readwrite');
  tx.objectStore(BLOCK_STORE).delete(blockId);
}
  • Step: 提交
git add -A && git commit -m "feat(storage): 实现 IndexedDB 持久化层Block draft 安全网)"

Task 2.5: Zustand 状态管理

文件:

  • 创建: src/stores/editorStore.ts
  • 创建: src/stores/uiStore.ts
// src/stores/editorStore.ts
import { create } from 'zustand';

interface EditorState {
  activeBlockId: string | null;
  isDirty: boolean;
  lastSavedAt: Date | null;
  setActiveBlock: (blockId: string | null) => void;
  markDirty: () => void;
  markSaved: () => void;
}

export const useEditorStore = create<EditorState>((set) => ({
  activeBlockId: null,
  isDirty: false,
  lastSavedAt: null,
  setActiveBlock: (blockId) => set({ activeBlockId: blockId, isDirty: false }),
  markDirty: () => set({ isDirty: true }),
  markSaved: () => set({ isDirty: false, lastSavedAt: new Date() }),
}));
// src/stores/uiStore.ts
import { create } from 'zustand';

interface UIState {
  sidebarCollapsed: boolean;
  candidatePanelOpen: boolean;
  toggleSidebar: () => void;
  toggleCandidatePanel: () => void;
}

export const useUIStore = create<UIState>((set) => ({
  sidebarCollapsed: false,
  candidatePanelOpen: false,
  toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
  toggleCandidatePanel: () => set((s) => ({ candidatePanelOpen: !s.candidatePanelOpen })),
}));
  • Step: 提交
git add -A && git commit -m "feat(state): 配置 Zustand store编辑器状态 + UI 状态)"

Step 3: 功能域开发(按优先级)

Feature 1: 写作台(核心路径)

优先级 1 — 编辑器集成 + AI 生成 + 候选面板

  • Task 3.1: Tiptap 编辑器集成

文件: src/features/editor/components/MuseEditor.tsx

import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect, useCallback } from 'react';
import { useEditorStore } from '@/stores/editorStore';
import { saveBlockDraft, getBlockDraft, removeBlockDraft } from '@/lib/indexed-db';
import { debounce } from '@/lib/debounce';
import { api } from '@/api/client';

interface MuseEditorProps {
  blockId: string;
  initialContent?: unknown;
  revision: number;
}

export default function MuseEditor({ blockId, initialContent, revision }: MuseEditorProps) {
  const { markDirty, markSaved } = useEditorStore();

  const editor = useEditor({
    extensions: [
      StarterKit,
      Placeholder.configure({ placeholder: '开始创作...' }),
    ],
    content: initialContent as any,
    onUpdate: ({ editor }) => {
      markDirty();
      // 自动保存到 IndexedDBdebounce 2s
      debouncedSave(blockId, editor.getJSON());
    },
    autofocus: 'end',
  });

  // 自动保存到后端 + IndexedDB 安全网debounce 2s
  const debouncedSave = useCallback(
    debounce(async (blockId: string, content: unknown) => {
      // 1. 先保存到 IndexedDB即时安全网
      await saveBlockDraft(blockId, content as Record<string, unknown>);
      // 2. 再调用 API乐观锁
      try {
        const result = await api.put<{ newRevision: number }>(
          `/app-api/muse/blocks/${blockId}`,
          { content: JSON.stringify(content), expectedRevision: revision }
        );
        await removeBlockDraft(blockId);
        markSaved();
      } catch (err) {
        // API 失败IndexedDB 已保存,下次恢复
      }
    }, 2000),
    [revision]
  );

  // 恢复 IndexedDB 草稿
  useEffect(() => {
    getBlockDraft(blockId).then((draft) => {
      if (draft !== null && draft !== undefined && editor) {
        editor.commands.setContent(draft as any);
      }
    });
  }, [blockId, editor]);

  return (
    <div className="prose max-w-3xl mx-auto min-h-screen py-8">
      <EditorContent editor={editor} />
    </div>
  );
}

关键共享模块: src/lib/debounce.ts

// src/lib/debounce.ts — 通用去抖工具函数
export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
  let timer: ReturnType<typeof setTimeout>;
  return ((...args: any[]) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  }) as T;
}
  • Task 3.2: AI 生成面板 — 调用 AI SSE stream展示实时流式输出

  • Task 3.3: 候选面板 — 展示候选列表,支持接受/拒绝,显示 diff

  • Task 3.4: 编程式提交

git add -A && git commit -m "feat(editor): Tiptap 编辑器集成 + IndexedDB 安全网 + 自动保存"

Feature 2: 我的作品 + 工作台

涉及的 API 端点: GET /worksPOST /worksGET /works/:workIdDELETE /works/:workIdGET /works/:workId/chaptersPOST /works/:workId/chaptersPATCH /works/:workId/chapters/:chapterIdDELETE /works/:workId/chapters/:chapterId

文件:

  • 创建: src/features/editor/components/WorkListPage.tsx
  • 创建: src/features/editor/components/CreateWorkModal.tsx
  • 创建: src/features/editor/components/ChapterPanel.tsx
  • 创建: src/features/editor/hooks/useWorks.ts

关键组件与 Props:

组件 Props 说明
WorkListPage 作品列表页,使用 TanStack Query 拉取列表,展示卡片网格
CreateWorkModal open: boolean; onClose: () => void; onCreated: (work: WorkVO) => void 创建作品弹窗React Hook Form + Zod 校验
ChapterPanel workId: string; chapters: ChapterVO[]; activeChapterId?: string; onSelect: (chapterId: string) => void; onDelete: (chapterId: string) => void 章节管理侧面板,支持拖拽排序

代码示例 — useWorks Hook:

// src/features/editor/hooks/useWorks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/api/client';
import type { WorkVO, WorkCreateDTO } from '@/types/openapi';

export function useWorks(pageNo = 1, pageSize = 20) {
  return useQuery({
    queryKey: ['works', pageNo, pageSize],
    queryFn: () => api.get<{ total: number; list: WorkVO[] }>(`/works?pageNo=${pageNo}&pageSize=${pageSize}`),
  });
}

export function useCreateWork() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (dto: WorkCreateDTO) => api.post<WorkVO>('/works', dto),
    onSuccess: () => { qc.invalidateQueries({ queryKey: ['works'] }); },
  });
}

export function useDeleteWork() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (workId: string) => api.delete(`/works/${workId}`),
    onSuccess: () => { qc.invalidateQueries({ queryKey: ['works'] }); },
  });
}

测试场景:

  1. 列表页加载展示 3 个 Mock 作品卡片
  2. 点击「创建作品」按钮,弹窗打开,填写表单,提交后列表刷新
  3. 删除按钮点击后弹出确认框,确认后列表移除该条目
  4. 点击作品卡片进入章节面板,展示章节列表
  • Task: 提交
git add -A && git commit -m "feat(workspace): 作品列表/创建/删除 + 章节管理面板"

Feature 3: 知识库工作台

涉及的 API 端点: GET /knowledge/:workId/entitiesPOST /knowledge/:workId/entitiesPATCH /knowledge/:workId/entities/:entityIdGET /knowledge/:workId/draftsPOST /knowledge/:workId/drafts/:draftId/confirmPOST /knowledge/:workId/drafts/:draftId/rejectGET /knowledge/:workId/graph

文件:

  • 创建: src/features/knowledge/components/EntityList.tsx
  • 创建: src/features/knowledge/components/DraftPanel.tsx
  • 创建: src/features/knowledge/components/GraphStub.tsx
  • 创建: src/features/knowledge/hooks/useKnowledge.ts

关键组件与 Props:

组件 Props 说明
EntityList workId: string 知识实体列表,支持按类型筛选、搜索关键字
DraftPanel workId: string; drafts: KnowledgeDraftVO[]; onConfirm: (draftId: string) => void; onReject: (draftId: string, reason: string) => void 知识草稿确认/拒绝面板,拒绝需填写原因
GraphStub workId: string; width?: number; height?: number 知识图谱可视化占位,后续接入 RAGFlow GraphRAG API

代码示例 — useKnowledgeDrafts Hook:

// src/features/knowledge/hooks/useKnowledge.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/api/client';
import type { KnowledgeDraftVO, KnowledgeEntityVO } from '@/types/openapi';

export function useKnowledgeEntities(workId: string) {
  return useQuery({
    queryKey: ['knowledge', workId, 'entities'],
    queryFn: () => api.get<KnowledgeEntityVO[]>(`/knowledge/${workId}/entities`),
    enabled: !!workId,
  });
}

export function useKnowledgeDrafts(workId: string) {
  return useQuery({
    queryKey: ['knowledge', workId, 'drafts'],
    queryFn: () => api.get<KnowledgeDraftVO[]>(`/knowledge/${workId}/drafts`),
    enabled: !!workId,
  });
}

export function useConfirmDraft() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ workId, draftId }: { workId: string; draftId: string }) =>
      api.post(`/knowledge/${workId}/drafts/${draftId}/confirm`),
    onSuccess: (_, vars) => {
      qc.invalidateQueries({ queryKey: ['knowledge', vars.workId] });
    },
  });
}

export function useRejectDraft() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ workId, draftId, reason }: { workId: string; draftId: string; reason: string }) =>
      api.post(`/knowledge/${workId}/drafts/${draftId}/reject`, { reason }),
    onSuccess: (_, vars) => {
      qc.invalidateQueries({ queryKey: ['knowledge', vars.workId] });
    },
  });
}

测试场景:

  1. 实体列表加载展示,按类型(人物/地点/事件)筛选
  2. 草稿面板展示待确认列表,点击「确认」更新状态
  3. 点击「拒绝」,弹出拒绝原因输入框,提交后刷新
  4. 知识图谱占位区域正确渲染,显示 "Graph visualization coming soon" 占位文本
  • Task: 提交
git add -A && git commit -m "feat(knowledge): 知识实体列表 + 草稿确认/拒绝面板 + 图谱占位"

Feature 4: 智能体工作台

涉及的 API 端点: GET /agentsPOST /agentsGET /agents/:agentIdPATCH /agents/:agentIdDELETE /agents/:agentIdGET /agents/:agentId/slotsPATCH /agents/:agentId/slots

文件:

  • 创建: src/features/agent/components/AgentList.tsx
  • 创建: src/features/agent/components/AgentCreateForm.tsx
  • 创建: src/features/agent/components/SlotBindingPanel.tsx
  • 创建: src/features/agent/hooks/useAgents.ts

关键组件与 Props:

组件 Props 说明
AgentList onSelect: (agentId: string) => void 智能体列表卡片,展示名称、模型、状态
AgentCreateForm onCreated: (agent: AgentVO) => void; onCancel: () => void 创建智能体表单名称、system prompt、模型选择、温度等
SlotBindingPanel agentId: string; slots: AgentSlotVO[]; onBind: (slotId: string, toolId: string) => void 槽位-工具绑定面板,支持搜索可用工具

代码示例 — AgentList 组件:

// src/features/agent/components/AgentList.tsx
import { useAgents } from '../hooks/useAgents';

interface AgentListProps {
  onSelect: (agentId: string) => void;
}

export default function AgentList({ onSelect }: AgentListProps) {
  const { data, isLoading, error } = useAgents();

  if (isLoading) return <div className="p-4">加载中...</div>;
  if (error) return <div className="p-4 text-red-500">加载失败</div>;

  return (
    <div className="grid grid-cols-3 gap-4 p-4">
      {data?.list.map((agent) => (
        <div
          key={agent.id}
          className="border rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow"
          onClick={() => onSelect(agent.id)}
        >
          <h3 className="font-bold text-lg">{agent.name}</h3>
          <p className="text-sm text-gray-500 mt-1">{agent.model}</p>
          <span className={"inline-block mt-2 px-2 py-1 rounded text-xs " +
            (agent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500')
          }>
            {agent.status === 'active' ? '运行中' : '已停用'}
          </span>
        </div>
      ))}
    </div>
  );
}

测试场景:

  1. 智能体列表加载展示,每个卡片显示名称、模型、状态标签
  2. 点击「创建智能体」打开表单,填写 system prompt、选择模型提交后列表刷新
  3. 点击卡片进入槽位绑定面板,为智能体绑定/解绑工具槽位
  4. 删除智能体,确认后列表中移除
  • Task: 提交
git add -A && git commit -m "feat(agent): 智能体列表 + 创建表单 + 槽位绑定面板"

Feature 5: 市场 + 个人中心

涉及的 API 端点: GET /market/assetsGET /market/assets/:assetIdPOST /market/assets/:assetId/installGET /account/profilePATCH /account/profileGET /account/usage-statsGET /account/entitlements

文件:

  • 创建: src/features/market/components/MarketBrowse.tsx
  • 创建: src/features/market/hooks/useMarket.ts
  • 创建: src/features/account/components/PersonalCenter.tsx
  • 创建: src/features/account/components/UsageStats.tsx
  • 创建: src/features/account/hooks/useAccount.ts

关键组件与 Props:

组件 Props 说明
MarketBrowse onInstall: (assetId: string) => void 市场资源浏览页,支持按类型/标签筛选,展示资产卡片网格
PersonalCenter 个人中心:头像、昵称、配额详情、使用统计概览
UsageStats stats: UsageStatsVO 用量统计面板Token 消耗、存储用量、API 调用次数

代码示例 — UsageStats 组件:

// src/features/account/components/UsageStats.tsx
import type { UsageStatsVO } from '@/types/openapi';

interface UsageStatsProps {
  stats: UsageStatsVO;
}

export default function UsageStats({ stats }: UsageStatsProps) {
  const items: { label: string; value: string; used: number; total: number }[] = [
    { label: 'Token 消耗', value: `${(stats.tokenUsed / 1000).toFixed(1)}K`, used: stats.tokenUsed, total: stats.tokenQuota },
    { label: '存储用量', value: `${(stats.storageUsed / 1024 / 1024).toFixed(0)} MB`, used: stats.storageUsed, total: stats.storageQuota },
    { label: 'API 调用', value: `${stats.apiCalls}`, used: stats.apiCalls, total: stats.apiQuota },
  ];

  return (
    <div className="space-y-3 p-4">
      {items.map((item) => (
        <div key={item.label}>
          <div className="flex justify-between text-sm mb-1">
            <span className="text-gray-600">{item.label}</span>
            <span className="text-gray-900">{item.value} / {item.total > 0 ? item.total : '无限制'}</span>
          </div>
          <div className="w-full bg-gray-200 rounded-full h-2">
            <div
              className="bg-blue-500 h-2 rounded-full transition-all"
              style={{ width: item.total > 0 ? `${Math.min((item.used / item.total) * 100, 100)}%` : '0%' }}
            />
          </div>
        </div>
      ))}
    </div>
  );
}

测试场景:

  1. 市场页面加载展示资产卡片,筛选器切换有效
  2. 点击「安装」按钮,调用安装 API按钮变为「已安装」
  3. 个人中心显示用户信息、配额用量进度条
  4. 用量统计各指标数据正确渲染,百分比进度条与实际数据成比例
  • Task: 提交
git add -A && git commit -m "feat(market+account): 市场资源浏览 + 个人中心 + 用量统计"

完成标准

  • 6 个功能域页面可交互
  • 组件单元测试覆盖率 ≥75%Vitest + Testing Library
  • Hook 测试覆盖率 ≥80%
  • E2E 测试覆盖核心写作流程Playwright
  • ESLint + Prettier + TypeScript 类型检查通过
  • Vite build 成功