完成 Muse Studio React SPA 基座和六个用户端功能域,接入 MSW Mock、Tiptap 写作台、知识库资料管理、智能体槽位预检绑定、市场授权安装和个人中心用量权益视图。
34 KiB
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 SPA,TanStack Query 数据层,Zustand 状态管理,Tiptap/ProseMirror 编辑器,MSW Mock API,SSE 双通道实时通信,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退出码 0;仅public/mockServiceWorker.js生成文件有 unused eslint-disable warning。- 已用 Playwright + 本机 Chrome 冒烟
/agents、/market、/account,页面非空且无明显加载失败或重叠。
未完成或后续增强:
- 尚未补 Playwright E2E 自动化用例。
- 尚未按完成标准统计组件/Hook 覆盖率百分比。
public/mockServiceWorker.jslint 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 API(Content 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();
// 自动保存到 IndexedDB(debounce 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 /works、POST /works、GET /works/:workId、DELETE /works/:workId、GET /works/:workId/chapters、POST /works/:workId/chapters、PATCH /works/:workId/chapters/:chapterId、DELETE /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'] }); },
});
}
测试场景:
- 列表页加载展示 3 个 Mock 作品卡片
- 点击「创建作品」按钮,弹窗打开,填写表单,提交后列表刷新
- 删除按钮点击后弹出确认框,确认后列表移除该条目
- 点击作品卡片进入章节面板,展示章节列表
- Task: 提交
git add -A && git commit -m "feat(workspace): 作品列表/创建/删除 + 章节管理面板"
Feature 3: 知识库工作台
涉及的 API 端点: GET /knowledge/:workId/entities、POST /knowledge/:workId/entities、PATCH /knowledge/:workId/entities/:entityId、GET /knowledge/:workId/drafts、POST /knowledge/:workId/drafts/:draftId/confirm、POST /knowledge/:workId/drafts/:draftId/reject、GET /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] });
},
});
}
测试场景:
- 实体列表加载展示,按类型(人物/地点/事件)筛选
- 草稿面板展示待确认列表,点击「确认」更新状态
- 点击「拒绝」,弹出拒绝原因输入框,提交后刷新
- 知识图谱占位区域正确渲染,显示 "Graph visualization coming soon" 占位文本
- Task: 提交
git add -A && git commit -m "feat(knowledge): 知识实体列表 + 草稿确认/拒绝面板 + 图谱占位"
Feature 4: 智能体工作台
涉及的 API 端点: GET /agents、POST /agents、GET /agents/:agentId、PATCH /agents/:agentId、DELETE /agents/:agentId、GET /agents/:agentId/slots、PATCH /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>
);
}
测试场景:
- 智能体列表加载展示,每个卡片显示名称、模型、状态标签
- 点击「创建智能体」打开表单,填写 system prompt、选择模型,提交后列表刷新
- 点击卡片进入槽位绑定面板,为智能体绑定/解绑工具槽位
- 删除智能体,确认后列表中移除
- Task: 提交
git add -A && git commit -m "feat(agent): 智能体列表 + 创建表单 + 槽位绑定面板"
Feature 5: 市场 + 个人中心
涉及的 API 端点: GET /market/assets、GET /market/assets/:assetId、POST /market/assets/:assetId/install、GET /account/profile、PATCH /account/profile、GET /account/usage-stats、GET /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>
);
}
测试场景:
- 市场页面加载展示资产卡片,筛选器切换有效
- 点击「安装」按钮,调用安装 API,按钮变为「已安装」
- 个人中心显示用户信息、配额用量进度条
- 用量统计各指标数据正确渲染,百分比进度条与实际数据成比例
- Task: 提交
git add -A && git commit -m "feat(market+account): 市场资源浏览 + 个人中心 + 用量统计"
完成标准
- 6 个功能域页面可交互
- 组件单元测试覆盖率 ≥75%(Vitest + Testing Library)
- Hook 测试覆盖率 ≥80%
- E2E 测试覆盖核心写作流程(Playwright)
- ESLint + Prettier + TypeScript 类型检查通过
- Vite build 成功