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

1129 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 退出码 0`public/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: 创建项目**
```bash
pnpm create vite@latest muse-studio --template react-ts
cd muse-studio
```
- [ ] **Step 2: 安装核心依赖**
```bash
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**
```typescript
// 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: 创建目录结构**
```bash
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**
```bash
npx tailwindcss init -p
```
```javascript
// muse-studio/tailwind.config.js
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}
```
- [ ] **Step 6: 提交**
```bash
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: 编写路由配置**
```tsx
// 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: 编写布局组件**
```tsx
// 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: 实现侧边栏导航**
```tsx
// 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: 创建占位页面**
```tsx
// 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 集成路由**
```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: 验证启动**
```bash
cd muse-studio && pnpm dev
```
浏览 `http://localhost:5173`,验证路由导航正常。
- [ ] **Step 7: 提交**
```bash
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 客户端**
```typescript
// 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: 复制类型包**
```bash
cp docs/api-contracts/generated/typescript/*.ts muse-studio/src/types/
```
- [ ] **Step 3: 编写类型入口**
```typescript
// 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: 提交**
```bash
git add -A && git commit -m "feat(api): 集成 API 客户端 + OpenAPI 类型包"
```
### Task 2.2: MSW Mock API 配置
**文件:**
- 创建: `src/api/mocks/handlers/` 下各模块 handler
- [ ] **Step 1: 安装 MSW**
```bash
pnpm add -D msw@2
npx msw init public/ --save
```
- [ ] **Step 2: 编写 Content Mock Handlers**
```typescript
// 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 入口**
```typescript
// 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**
```tsx
// src/main.tsx 开头追加
if (import.meta.env.DEV) {
const { worker } = await import('./api/mocks/browser');
await worker.start({ onUnhandledRequest: 'bypass' });
}
```
- [ ] **Step 5: 提交**
```bash
git add -A && git commit -m "feat(mock): 配置 MSW Mock APIContent handlers"
```
### Task 2.3: SSE 双通道客户端
**文件:**
- 创建: `src/lib/sse.ts`
```typescript
// 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: 提交**
```bash
git add -A && git commit -m "feat(sse): 实现 SSE 双通道客户端AI stream + Event stream"
```
### Task 2.4: IndexedDB 持久化层
**文件:**
- 创建: `src/lib/indexed-db.ts`
```typescript
// 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: 提交**
```bash
git add -A && git commit -m "feat(storage): 实现 IndexedDB 持久化层Block draft 安全网)"
```
### Task 2.5: Zustand 状态管理
**文件:**
- 创建: `src/stores/editorStore.ts`
- 创建: `src/stores/uiStore.ts`
```typescript
// 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() }),
}));
```
```typescript
// 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: 提交**
```bash
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`
```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`
```typescript
// 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: 编程式提交**
```bash
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:**
```typescript
// 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: 提交**
```bash
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:**
```typescript
// 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: 提交**
```bash
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` 组件:**
```tsx
// 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: 提交**
```bash
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` 组件:**
```tsx
// 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: 提交**
```bash
git add -A && git commit -m "feat(market+account): 市场资源浏览 + 个人中心 + 用量统计"
```
---
## 完成标准
- [ ] 6 个功能域页面可交互
- [ ] 组件单元测试覆盖率 ≥75%Vitest + Testing Library
- [ ] Hook 测试覆盖率 ≥80%
- [ ] E2E 测试覆盖核心写作流程Playwright
- [ ] ESLint + Prettier + TypeScript 类型检查通过
- [ ] Vite build 成功