完成 Muse Studio React SPA 基座和六个用户端功能域,接入 MSW Mock、Tiptap 写作台、知识库资料管理、智能体槽位预检绑定、市场授权安装和个人中心用量权益视图。
1129 lines
34 KiB
Markdown
1129 lines
34 KiB
Markdown
# 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.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 API(Content 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();
|
||
// 自动保存到 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`
|
||
|
||
```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 成功
|