OPC-Starter - 完整学习教程
OPC-Starter - 完整学习教程
教程级别: 从零到一 预计学习时间: 8-12 小时 前置知识: React(Hooks、函数组件)、TypeScript(类型系统)、HTML/CSS 基础
环境搭建指南
系统要求
- 操作系统: macOS、Windows、Linux 均可
- Node.js: >= 20.x(推荐 LTS 版本)
- npm: >= 10.x
- Git: 任意最新版本
- Supabase 账户: 仅真实后端模式需要(MSW Mock 模式不需要)
安装步骤
# 步骤 1:克隆仓库
git clone https://github.com/alibaba/opc-starter.git
cd opc-starter
# 步骤 2:安装应用依赖(在 app 子目录下)
npm --prefix app install
# 步骤 3:以 MSW Mock 模式启动(推荐,无需配置后端)
VITE_ENABLE_MSW=true npm run dev:test
如果 npm install 因 ECONNRESET 错误失败(内部镜像源不可达),执行以下修复:
cd app
rm -rf node_modules package-lock.json
npm install --registry https://registry.npmjs.org/
cd ..
验证安装
# 启动开发服务器后,浏览器打开 http://localhost:5173
# 应看到登录页面
# 使用测试账号登录
# 邮箱: test@example.com
# 密码: 888888
# 登录成功后应看到 OPC-Starter 仪表盘
预期输出:
- 浏览器打开 http://localhost:5173 显示登录页面
- 输入测试账号后成功跳转到仪表盘
- 左侧显示导航栏,顶部显示 Header
- 右下角显示 Agent Studio 悬浮对话框按钮
第一部分:入门篇
1.1 项目结构与核心概念
概念讲解:
OPC-Starter 采用模块化的目录结构,每个目录有明确的职责划分。理解项目结构是高效使用和扩展 OPC-Starter 的第一步。项目遵循"关注点分离"原则,将认证、UI 组件、数据服务、状态管理等分到不同目录,使 AI 编码工具能够快速定位和修改代码。
核心概念:
- SPA(单页应用):OPC-Starter 基于 Vite 构建,所有页面在客户端渲染
- 模块化架构:认证、组织、Agent、数据同步各自独立
- AGENTS.md:AI 编码工具的"使用说明书",Cursor/Qoder 可直接解析
- BMAD 方法论:结构化的 AI 辅助开发流程,存放在 _bmad/ 目录
代码示例:
# 查看项目顶层结构
ls -la
预期输出:
_bmad/ # BMAD 方法论配置(PRD 模板、架构文档)
app/ # 应用主目录(所有源代码)
docs/ # 项目文档(Architecture.md、DESIGN_TOKENS.md 等)
AGENTS.md # AI 编码规范指南
package.json # 根目录代理脚本(供 AI 工具从根目录执行命令)
# 查看 app/src 核心目录结构
ls app/src/
预期输出:
auth/ # 认证模块(AuthContext、AuthProvider、ProtectedRoute)
components/ # React 组件
agent/ # Agent Studio(A2UI 渲染、对话窗口)
business/ # 业务组件(头像、同步状态)
layout/ # 布局组件(Header、Sidebar、MainLayout)
organization/ # 组织架构组件(OrgTree、成员管理)
ui/ # 基础 UI 组件(shadcn/ui 风格)
config/ # 路由和常量配置
hooks/ # 自定义 React Hooks
lib/ # 工具库
agent/ # Agent 客户端(SSE、工具执行)
reactive/ # 响应式数据层
supabase/ # Supabase 客户端封装
pages/ # 页面组件
services/ # 服务层
data/ # DataService(核心数据访问层)
mocks/ # MSW Mock 处理器
stores/ # Zustand 状态管理
types/ # TypeScript 类型定义
utils/ # 工具函数
练习题:
1. 打开 app/src/config/routes.tsx,找出当前注册了哪些路由。
2. 查看 app/src/stores/ 目录,列出所有 Zustand Store 文件及其命名。
1.2 MSW Mock 模式与开发环境
概念讲解:
MSW(Mock Service Worker)是 OPC-Starter 的核心开发工具之一。它通过拦截浏览器中的网络请求,返回预定义的模拟数据,使前端开发无需依赖真实后端。这意味着你可以在没有 Supabase 账户、没有数据库的情况下运行和开发完整的 OPC-Starter 应用。
MSW Mock 模式的工作原理:
1. 当 VITE_ENABLE_MSW=true 时,应用在启动时初始化 MSW
2. MSW 注册 Service Worker 拦截所有发往 Supabase 的 API 请求
3. 返回 app/src/mocks/ 中定义的模拟数据
4. 测试账号来自 app/cypress/fixtures/users.json
代码示例:
# 启动 MSW Mock 模式
VITE_ENABLE_MSW=true npm run dev:test
# 查看 Mock 测试账号配置
cat app/cypress/fixtures/users.json
预期输出(示例):
[
{
"email": "test@example.com",
"password": "888888"
}
]
# 查看环境变量配置模板
cat app/env.local.example
预期输出:
VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
VITE_DASHSCOPE_API_KEY=your_dashscope_api_key # 可选,用于 Agent LLM
# 切换到真实 Supabase 模式(需要 Supabase 账户)
cp app/env.local.example app/.env.local
# 编辑 app/.env.local,填入真实的 Supabase URL 和 Key
npm run dev
练习题:
1. 在 MSW Mock 模式下登录后,打开浏览器 DevTools 的 Network 标签,观察哪些请求被 MSW 拦截了。
2. 查看 app/src/mocks/ 目录,了解有哪些 Mock 处理器。
第二部分:进阶篇
2.1 认证系统与路由守卫
概念讲解:
OPC-Starter 的认证系统基于 Supabase Auth,提供了完整的用户认证功能,包括邮箱/密码登录和 OAuth 社交登录。认证状态通过 AuthContext 在组件树中共享,ProtectedRoute 组件确保未登录用户被重定向到登录页。
认证流程:
1. 用户在登录页输入凭据
2. AuthProvider 调用 Supabase Auth 的 signInWithPassword 或 signInWithOAuth
3. Supabase 返回 JWT Token
4. AuthContext 更新认证状态,触发路由重定向
5. ProtectedRoute 检查认证状态,未认证则重定向到 /login
代码示例: 基于官方架构文档 v1.1.0
// app/src/auth/AuthProvider.tsx
// 认证提供者 - 监听 Supabase 认证状态变化并同步到 AuthContext
import { createContext, useContext, useEffect, useState } from 'react';
import type { Session, User } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase/client';
// 定义认证上下文类型
interface AuthContextType {
session: Session | null; // 当前会话
user: User | null; // 当前用户
loading: boolean; // 加载状态
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
}
// 创建认证上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 认证提供者组件 - 包裹整个应用
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 获取当前会话(Supabase 会自动从 localStorage 恢复)
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setLoading(false);
});
// 监听认证状态变化(登录、登出、Token 刷新)
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
}
);
// 清理订阅
return () => subscription.unsubscribe();
}, []);
// 登录方法
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw error;
};
// 登出方法
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
};
return (
<AuthContext.Provider value={{ session, user: session?.user ?? null, loading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
// 自定义 Hook - 在组件中获取认证状态
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth 必须在 AuthProvider 内部使用');
}
return context;
}
// app/src/auth/ProtectedRoute.tsx
// 路由守卫 - 保护需要认证的页面
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthProvider';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { session, loading } = useAuth();
// 加载中显示空白或加载动画
if (loading) {
return <div className="flex items-center justify-center h-screen">加载中...</div>;
}
// 未认证则重定向到登录页
if (!session) {
return <Navigate to="/login" replace />;
}
// 已认证则渲染子组件
return <>{children}</>;
}
// app/src/config/routes.tsx
// 路由配置示例 - 使用 ProtectedRoute 包裹需要认证的页面
import { Routes, Route } from 'react-router-dom';
import { ProtectedRoute } from '@/auth/ProtectedRoute';
import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
export function AppRoutes() {
return (
<Routes>
{/* 公开路由 - 无需认证 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护路由 - 需要认证 */}
<Route
path="/"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
</Routes>
);
}
注意事项: - 在 MSW Mock 模式下,Supabase Auth 的登录请求会被 MSW 拦截并返回模拟的 JWT Token - 浏览器白屏时,可能是 localStorage 中残留了过期的 Token,需清除站点数据 - ProtectedRoute 应包裹所有需要认证的页面路由,不要遗漏
练习题:
1. 尝试在 MSW Mock 模式下,不使用测试账号而是输入错误的密码,观察错误处理行为。
2. 在 routes.tsx 中添加一个新的公开路由 /about,指向一个简单的 AboutPage 组件。
2.2 DataService 数据访问与 Zustand 状态管理
概念讲解:
DataService 是 OPC-Starter 的数据访问核心,采用"本地优先"架构。所有数据操作(读取和写入)都通过 DataService 进行,它封装了 IndexedDB 本地缓存和 Supabase 远程 API 的交互逻辑。
数据流路径: - 读取:UI → Zustand Store → DataService → IndexedDB(100% 本地) - 写入:UI → Zustand Store → DataService → IndexedDB(乐观更新)→ Supabase(异步) - 同步:Supabase Realtime → DataService → IndexedDB → Zustand Store → UI
Zustand Store 作为 UI 和 DataService 之间的桥梁,负责管理组件级别的状态,并通过 DataService 进行持久化。
代码示例: 基于官方架构文档 v1.1.0
// app/src/stores/useProfileStore.ts
// 用户信息状态管理 - 演示 DataService + Zustand 的协作方式
import { create } from 'zustand';
import type { Profile } from '@/types/profile';
// 定义 Store 的状态和操作
interface ProfileState {
profile: Profile | null; // 当前用户信息
loading: boolean; // 加载状态
error: string | null; // 错误信息
// 操作方法
fetchProfile: (userId: string) => Promise<void>; // 获取用户信息
updateProfile: (updates: Partial<Profile>) => Promise<void>; // 更新用户信息
}
export const useProfileStore = create<ProfileState>((set) => ({
profile: null,
loading: false,
error: null,
// 获取用户信息 - 通过 DataService 从 IndexedDB 读取
fetchProfile: async (userId: string) => {
set({ loading: true, error: null });
try {
// DataService 会先从 IndexedDB 本地缓存读取
// 如果缓存不存在,则从 Supabase 获取并缓存到 IndexedDB
const profile = await DataService.profiles.getById(userId);
set({ profile, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
// 更新用户信息 - 乐观更新策略
updateProfile: async (updates: Partial<Profile>) => {
if (!useProfileStore.getState().profile) return;
// 1. 乐观更新:先更新本地状态,用户立即看到变化
const previousProfile = useProfileStore.getState().profile;
const optimisticProfile = { ...previousProfile, ...updates };
set({ profile: optimisticProfile });
try {
// 2. 通过 DataService 写入 IndexedDB 并异步同步到 Supabase
await DataService.profiles.update(optimisticProfile);
} catch (error) {
// 3. 失败时回滚到之前的状态
set({ profile: previousProfile, error: (error as Error).message });
}
},
}));
// app/src/pages/DashboardPage.tsx
// 在页面组件中使用 Zustand Store 的示例
import { useEffect } from 'react';
import { useAuth } from '@/auth/AuthProvider';
import { useProfileStore } from '@/stores/useProfileStore';
export function DashboardPage() {
const { user } = useAuth();
const { profile, loading, fetchProfile } = useProfileStore();
// 页面加载时获取用户信息
useEffect(() => {
if (user) {
fetchProfile(user.id);
}
}, [user, fetchProfile]);
if (loading) return <div>加载用户信息...</div>;
if (!profile) return <div>未找到用户信息</div>;
return (
<div className="p-6">
<h1 className="text-2xl font-bold">欢迎, {profile.full_name}</h1>
<p className="text-gray-500 mt-2">这是你的仪表盘</p>
</div>
);
}
注意事项: - 禁止直接操作 IndexedDB 或 Supabase:所有数据操作必须通过 DataService,否则会破坏缓存一致性和离线同步功能 - 乐观更新需考虑回滚:写入失败时需要回滚 Zustand Store 的状态到更新前的值 - 避免在 useEffect 中创建订阅泄漏:使用 Supabase Realtime 时确保在组件卸载时取消订阅
练习题:
1. 在 DashboardPage 中添加一个"更新昵称"按钮,点击后调用 updateProfile 修改用户的 full_name。
2. 查看 DataService 的适配器目录 app/src/services/data/adapters/,了解适配器模式如何处理数据序列化。
第三部分:高级篇
3.1 Agent Studio 与 A2UI 协议
概念讲解:
Agent Studio 是 OPC-Starter 最具创新性的功能,它实现了 A2UI(Agent to UI)协议——一种让 AI Agent 通过自然语言描述动态生成 UI 组件的标准协议。在 2026 年的 AI Agent 协议栈(A2A → MCP → AG-UI → A2UI)中,A2UI 负责最上层的前端渲染环节。
工作流程:
1. 用户在悬浮对话框(AgentWindow)中输入自然语言指令
2. SSE 客户端将请求流式发送到 Supabase Edge Function(ai-assistant)
3. Edge Function 调用百炼 API(Qwen-Plus 模型),使用 OpenAI 兼容格式
4. AI 返回两种内容:A2UI 指令(描述要渲染的 UI 组件)和工具调用(导航、上下文获取)
5. A2UI Renderer 从组件注册表(Registry)查找对应组件并动态渲染
6. Tool Executor 执行工具调用
代码示例: 基于官方架构文档 v1.1.0
// app/src/lib/agent/sseClient.ts
// SSE 流客户端 - 与 AI Assistant Edge Function 通信
import type { AgentMessage, A2UIInstruction, ToolCall } from '@/types/agent';
interface SSEClientOptions {
onMessage: (message: AgentMessage) => void; // 接收到消息回调
onA2UI: (instruction: A2UIInstruction) => void; // 接收到 A2UI 指令回调
onToolCall: (toolCall: ToolCall) => void; // 工具调用回调
onError: (error: Error) => void; // 错误回调
onComplete: () => void; // 流结束回调
}
// 创建 SSE 连接,发送用户消息并流式接收 AI 响应
export async function createSSEConnection(
message: string, // 用户输入的自然语言消息
options: SSEClientOptions
): Promise<void> {
// 获取当前用户的认证 Token
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('未登录');
// 调用 Supabase Edge Function,建立 SSE 连接
const response = await fetch(`${supabaseUrl}/functions/v1/ai-assistant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`, // JWT Token 认证
},
body: JSON.stringify({ message }), // 用户消息
});
if (!response.ok) throw new Error(`请求失败: ${response.status}`);
// 读取 SSE 流
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error('无法读取响应流');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 解析 SSE 数据行(格式: data: {...}\n\n)
const lines = chunk.split('\n').filter(line => line.startsWith('data: '));
for (const line of lines) {
const data = JSON.parse(line.slice(6));
// 根据数据类型分发到不同的回调
if (data.type === 'message') {
options.onMessage(data);
} else if (data.type === 'a2ui') {
options.onA2UI(data);
} else if (data.type === 'tool_call') {
options.onToolCall(data);
}
}
}
options.onComplete();
}
// app/src/components/agent/a2ui/A2UIRenderer.tsx
// A2UI 渲染器 - 根据 A2UI 指令动态渲染对应的 UI 组件
import type { A2UIInstruction } from '@/types/agent';
import { a2uiRegistry } from './registry';
interface A2UIRendererProps {
instructions: A2UIInstruction[]; // AI 返回的 A2UI 指令列表
}
export function A2UIRenderer({ instructions }: A2UIRendererProps) {
return (
<div className="a2ui-container space-y-4">
{instructions.map((instruction, index) => {
// 从注册表中查找对应的 React 组件
const Component = a2uiRegistry[instruction.componentType];
if (!Component) {
console.warn(`未注册的 A2UI 组件类型: ${instruction.componentType}`);
return null;
}
// 动态渲染组件,传入 AI 生成的 props
return (
<Component
key={index}
{...instruction.props}
/>
);
})}
</div>
);
}
注意事项:
- A2UI 协议版本:当前使用 v0.8 版本,API 可能发生不兼容变更,建议封装渲染层隔离变更
- 禁止使用未注册的组件:A2UI 指令中的 componentType 必须在 Registry 中注册,否则会静默跳过
- 禁止直接调用 LLM API:必须通过 ai-assistant Edge Function 中转,不要在前端直接暴露 API Key
- SSE 连接断开处理:网络不稳定时 SSE 连接可能断开,需要实现自动重连机制
练习题:
1. 在 Agent Studio 悬浮对话框中输入"帮我导航到设置页面",观察 Agent 如何调用导航工具。
2. 查看 app/src/components/agent/a2ui/registry.ts,了解当前注册了哪些 A2UI 组件类型。
3.2 自定义数据实体与扩展
概念讲解:
OPC-Starter 提供了明确的扩展指南来添加新的数据实体。每个数据实体需要四个部分:TypeScript 类型定义、数据适配器、Zustand Store 和数据库 Schema。这种"类型 → 适配器 → Store → SQL"的扩展路径保证了新实体与 DataService 的本地优先架构完全兼容。
代码示例: 基于官方架构文档 v1.1.0 的扩展指南
// 步骤 1:定义 TypeScript 类型
// app/src/types/task.ts
export interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'in_progress' | 'done';
assignee_id: string | null;
organization_id: string;
created_at: string;
updated_at: string;
}
// 步骤 2:创建数据适配器
// app/src/services/data/adapters/taskAdapter.ts
import type { Task } from '@/types/task';
// 适配器负责数据库行数据与 TypeScript 类型之间的转换
export const taskAdapter = {
// 将数据库行转换为 TypeScript 对象
fromDB(row: Record<string, unknown>): Task {
return {
id: row.id as string,
title: row.title as string,
description: row.description as string,
status: row.status as Task['status'],
assignee_id: row.assignee_id as string | null,
organization_id: row.organization_id as string,
created_at: row.created_at as string,
updated_at: row.updated_at as string,
};
},
// 将 TypeScript 对象转换为数据库行
toDB(task: Partial<Task>): Record<string, unknown> {
const row: Record<string, unknown> = {};
if (task.title !== undefined) row.title = task.title;
if (task.description !== undefined) row.description = task.description;
if (task.status !== undefined) row.status = task.status;
if (task.assignee_id !== undefined) row.assignee_id = task.assignee_id;
if (task.organization_id !== undefined) row.organization_id = task.organization_id;
return row;
},
};
// 步骤 3:创建 Zustand Store
// app/src/stores/useTaskStore.ts
import { create } from 'zustand';
import type { Task } from '@/types/task';
interface TaskState {
tasks: Task[];
loading: boolean;
error: string | null;
fetchTasks: (organizationId: string) => Promise<void>;
addTask: (task: Omit<Task, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
updateTaskStatus: (taskId: string, status: Task['status']) => Promise<void>;
}
export const useTaskStore = create<TaskState>((set, get) => ({
tasks: [],
loading: false,
error: null,
fetchTasks: async (organizationId: string) => {
set({ loading: true, error: null });
try {
// 通过 DataService 获取任务列表(从 IndexedDB 本地缓存读取)
const tasks = await DataService.tasks.getByOrganization(organizationId);
set({ tasks, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
addTask: async (task) => {
const previousTasks = get().tasks;
// 乐观更新:先添加到本地状态
const tempId = `temp_${Date.now()}`;
const optimisticTask: Task = {
...task,
id: tempId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
set({ tasks: [...previousTasks, optimisticTask] });
try {
// 通过 DataService 持久化
await DataService.tasks.create(task);
} catch (error) {
// 失败时回滚
set({ tasks: previousTasks, error: (error as Error).message });
}
},
updateTaskStatus: async (taskId, status) => {
const previousTasks = get().tasks;
// 乐观更新
set({
tasks: previousTasks.map(t =>
t.id === taskId ? { ...t, status, updated_at: new Date().toISOString() } : t
),
});
try {
await DataService.tasks.update(taskId, { status });
} catch (error) {
set({ tasks: previousTasks, error: (error as Error).message });
}
},
}));
-- 步骤 4:更新数据库 Schema
-- 在 app/supabase/setup.sql 中添加
-- 任务表
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT CHECK (status IN ('todo', 'in_progress', 'done')) DEFAULT 'todo',
assignee_id UUID REFERENCES profiles(id),
organization_id UUID REFERENCES organizations(id) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- RLS 策略:用户只能访问自己所属组织的任务
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "用户可访问所属组织的任务"
ON tasks FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM organization_memberships
WHERE user_id = auth.uid()
)
);
注意事项:
- SQL 变更集中管理:所有 SQL 必须写在 app/supabase/setup.sql 中,禁止创建独立的 SQL 文件
- RLS 策略必须启用:每个新表都必须启用 Row Level Security,避免数据泄漏
- 乐观更新的 ID 处理:乐观更新时使用临时 ID(如 temp_ 前缀),同步完成后替换为真实 ID
练习题:
1. 为 Task 实体添加一个 priority 字段(low/medium/high),完成从类型定义到 SQL Schema 的完整扩展。
2. 在 app/src/pages/ 创建一个 TaskListPage 页面组件,展示任务列表并支持状态切换。
3.3 AI Coding 最佳实践与 BMAD 方法论
概念讲解:
OPC-Starter 的核心设计理念之一是 AI-First。通过 AGENTS.md、Cursor Rules 和 BMAD 方法论,让 AI 编码工具(Cursor、Qoder)能够高效理解和修改项目代码。
最佳实践:
1. 让 AI 先读 AGENTS.md:AGENTS.md 包含技术栈、编码规范、质量门禁和常见问题
2. 使用 AI 迭代地图:README 中提供了"想做什么 → 从哪里开始 → 下一步改哪里"的清晰指引
3. 执行质量检查:每次 AI 修改代码后运行 npm run ai:check
代码示例: 基于官方 AGENTS.md v2.0
# AI Coding 工作流程
# 1. 让 Cursor 读取 AGENTS.md(自动触发)
# Cursor 打开项目时会自动解析 AGENTS.md 和 .cursor/rules/
# 2. 使用 AI 迭代地图定位修改位置
# 例如:想新增一个页面
# 从哪里开始: app/src/config/routes.tsx
# 下一步: app/src/pages/ 创建页面组件
# 然后: app/src/components/layout/MainLayout/ 添加导航
# 验证: npm run type-check
# 3. 核心质量检查命令
npm run ai:check
# 等同于: lint:check + format:check + type-check + coverage + build
# 4. 完整质量检查(包含 E2E 测试)
./scripts/quality_check.sh
# 查看质量门禁配置
# 覆盖率阈值: lines 25%, branches 18%
npm run coverage
预期输出:
> 运行单元测试并生成覆盖率报告
> 如果覆盖率低于阈值,构建失败
> 显示各模块的覆盖率详情
注意事项:
- 不要使用 Tailwind CSS v2/v3 语法:OPC-Starter 使用 Tailwind CSS v4,如 bg-opacity-*、bg-gradient-to-* 等旧语法已废弃
- 不要创建新文档文件:优先更新现有文档(AGENTS.md、Architecture.md),不创建新文档
- Husky Git Hooks:npm install 时会自动安装 Git Hooks(cd .. && husky app/.husky),这是预期行为
练习题:
1. 使用 Cursor 的 @file 功能引用 AGENTS.md 和 app/src/config/routes.tsx,让 AI 帮你添加一个新的 /tasks 路由。
2. 运行 npm run ai:check,确认当前代码通过所有质量门禁。
第四部分:实战项目
项目需求
构建一个任务管理看板应用,支持在组织内创建和管理任务。项目综合运用以下知识点: 1. 自定义数据实体扩展(Task 类型、适配器、Store、SQL) 2. 认证系统与路由守卫(ProtectedRoute 保护看板页面) 3. DataService 数据访问与状态管理(Zustand Store + 乐观更新)
项目设计
- 技术架构:基于 OPC-Starter 的 DataService + Zustand + React 组件模式
- 功能特性:三栏看板(待办/进行中/已完成)、拖拽状态切换、新建任务
- 数据隔离:通过组织管理模块实现多租户任务隔离
完整实现代码
// app/src/types/task.ts
// 知识点:自定义数据实体 - 类型定义
export interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'in_progress' | 'done';
assignee_id: string | null;
organization_id: string;
created_at: string;
updated_at: string;
}
// app/src/services/data/adapters/taskAdapter.ts
// 知识点:自定义数据实体 - 数据适配器
import type { Task } from '@/types/task';
export const taskAdapter = {
fromDB(row: Record<string, unknown>): Task {
return {
id: row.id as string,
title: row.title as string,
description: row.description as string,
status: row.status as Task['status'],
assignee_id: row.assignee_id as string | null,
organization_id: row.organization_id as string,
created_at: row.created_at as string,
updated_at: row.updated_at as string,
};
},
toDB(task: Partial<Task>): Record<string, unknown> {
const row: Record<string, unknown> = {};
if (task.title !== undefined) row.title = task.title;
if (task.description !== undefined) row.description = task.description;
if (task.status !== undefined) row.status = task.status;
if (task.assignee_id !== undefined) row.assignee_id = task.assignee_id;
if (task.organization_id !== undefined) row.organization_id = task.organization_id;
return row;
},
};
// app/src/stores/useTaskStore.ts
// 知识点:DataService 数据访问与 Zustand 状态管理
import { create } from 'zustand';
import type { Task } from '@/types/task';
interface TaskState {
tasks: Task[];
loading: boolean;
error: string | null;
fetchTasks: (organizationId: string) => Promise<void>;
addTask: (task: Omit<Task, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
updateTaskStatus: (taskId: string, status: Task['status']) => Promise<void>;
}
export const useTaskStore = create<TaskState>((set, get) => ({
tasks: [],
loading: false,
error: null,
fetchTasks: async (organizationId: string) => {
set({ loading: true, error: null });
try {
const tasks = await DataService.tasks.getByOrganization(organizationId);
set({ tasks, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
addTask: async (task) => {
const previousTasks = get().tasks;
const tempId = `temp_${Date.now()}`;
const optimisticTask: Task = {
...task,
id: tempId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
set({ tasks: [...previousTasks, optimisticTask] });
try {
await DataService.tasks.create(task);
} catch (error) {
set({ tasks: previousTasks, error: (error as Error).message });
}
},
updateTaskStatus: async (taskId, status) => {
const previousTasks = get().tasks;
set({
tasks: previousTasks.map(t =>
t.id === taskId ? { ...t, status, updated_at: new Date().toISOString() } : t
),
});
try {
await DataService.tasks.update(taskId, { status });
} catch (error) {
set({ tasks: previousTasks, error: (error as Error).message });
}
},
}));
// app/src/pages/KanbanPage.tsx
// 知识点:综合运用 - 认证系统 + DataService + Zustand + 路由
import { useEffect, useState } from 'react';
import { useAuth } from '@/auth/AuthProvider';
import { useTaskStore } from '@/stores/useTaskStore';
import type { Task } from '@/types/task';
// 看板列配置
const COLUMNS: { status: Task['status']; title: string }[] = [
{ status: 'todo', title: '待办' },
{ status: 'in_progress', title: '进行中' },
{ status: 'done', title: '已完成' },
];
export function KanbanPage() {
// 知识点:认证系统 - 获取当前用户信息
const { user } = useAuth();
// 知识点:Zustand 状态管理 - 使用 Task Store
const { tasks, loading, fetchTasks, addTask, updateTaskStatus } = useTaskStore();
// 新建任务的表单状态
const [newTaskTitle, setNewTaskTitle] = useState('');
const [newTaskDesc, setNewTaskDesc] = useState('');
// 页面加载时获取任务数据
useEffect(() => {
// 使用默认组织 ID(实际应用中应从组织选择器获取)
if (user) {
fetchTasks('default-org-id');
}
}, [user, fetchTasks]);
// 处理新建任务
const handleAddTask = async () => {
if (!newTaskTitle.trim()) return;
await addTask({
title: newTaskTitle.trim(),
description: newTaskDesc.trim(),
status: 'todo',
assignee_id: user?.id ?? null,
organization_id: 'default-org-id',
});
// 清空表单
setNewTaskTitle('');
setNewTaskDesc('');
};
// 处理拖拽状态变更
const handleStatusChange = async (taskId: string, newStatus: Task['status']) => {
await updateTaskStatus(taskId, newStatus);
};
if (loading) return <div className="p-6">加载任务...</div>;
return (
<div className="p-6 h-full">
<h1 className="text-2xl font-bold mb-6">任务看板</h1>
{/* 新建任务表单 */}
<div className="mb-6 flex gap-3 items-end">
<div className="flex-1">
<input
type="text"
placeholder="任务标题"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
className="w-full border rounded px-3 py-2 text-sm"
/>
</div>
<div className="flex-1">
<input
type="text"
placeholder="任务描述(可选)"
value={newTaskDesc}
onChange={(e) => setNewTaskDesc(e.target.value)}
className="w-full border rounded px-3 py-2 text-sm"
/>
</div>
<button
onClick={handleAddTask}
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700"
>
添加任务
</button>
</div>
{/* 三栏看板 */}
<div className="grid grid-cols-3 gap-4 h-[calc(100%-120px)]">
{COLUMNS.map(({ status, title }) => (
<div key={status} className="bg-gray-100 rounded-lg p-4">
<h2 className="font-semibold mb-3">{title} ({tasks.filter(t => t.status === status).length})</h2>
<div className="space-y-2 overflow-y-auto h-[calc(100%-40px)]">
{tasks
.filter(task => task.status === status)
.map(task => (
<div key={task.id} className="bg-white rounded p-3 shadow-sm border">
<h3 className="font-medium text-sm">{task.title}</h3>
{task.description && (
<p className="text-xs text-gray-500 mt-1">{task.description}</p>
)}
{/* 状态切换按钮 */}
<div className="mt-2 flex gap-1">
{COLUMNS.filter(c => c.status !== status).map(col => (
<button
key={col.status}
onClick={() => handleStatusChange(task.id, col.status)}
className="text-xs px-2 py-1 rounded bg-gray-200 hover:bg-gray-300"
>
→ {col.title}
</button>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
// app/src/config/routes.tsx(添加看板路由)
// 知识点:认证系统 - 使用 ProtectedRoute 保护新页面
import { Routes, Route } from 'react-router-dom';
import { ProtectedRoute } from '@/auth/ProtectedRoute';
import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { KanbanPage } from '@/pages/KanbanPage'; // 新增
export function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
{/* 新增看板路由 */}
<Route path="/kanban" element={<ProtectedRoute><KanbanPage /></ProtectedRoute>} />
</Routes>
);
}
-- app/supabase/setup.sql(添加任务表)
-- 知识点:自定义数据实体 - 数据库 Schema
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT CHECK (status IN ('todo', 'in_progress', 'done')) DEFAULT 'todo',
assignee_id UUID REFERENCES profiles(id),
organization_id UUID REFERENCES organizations(id) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "用户可访问所属组织的任务"
ON tasks FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM organization_memberships
WHERE user_id = auth.uid()
)
);
代码解析
- Task 类型定义(知识点:自定义数据实体):定义了任务的完整数据结构,包含状态枚举和组织归属
- taskAdapter(知识点:自定义数据实体):负责数据库行与 TypeScript 对象的双向转换,是 DataService 的关键组件
- useTaskStore(知识点:DataService + Zustand):使用乐观更新策略,先更新本地状态再异步同步,失败时自动回滚
- KanbanPage(知识点:认证系统 + Zustand):使用
useAuth获取当前用户,useTaskStore管理任务状态,实现三栏看板 - 路由配置(知识点:认证系统):KanbanPage 被 ProtectedRoute 包裹,未登录用户无法访问
- SQL Schema(知识点:自定义数据实体):包含 RLS 策略确保数据隔离
扩展挑战
- 拖拽排序:引入
@dnd-kit/core库,实现任务卡片在列之间的拖拽移动,替代当前的按钮切换。 - Agent Studio 集成:让用户通过自然语言创建任务(如"帮我创建一个'修复登录 Bug'的任务"),利用 A2UI 协议动态生成任务创建表单。
- 实时协作:利用 Supabase Realtime,实现多用户同时查看和编辑任务时的实时更新。
第五部分:常见问题与排查指南
常见错误及解决方案
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
npm install 失败 (ECONNRESET) |
package-lock.json 引用了阿里巴巴内部 npm 镜像源,外部无法访问 |
删除 app/package-lock.json 和 app/node_modules,执行 npm install --registry https://registry.npmjs.org/ |
浏览器白屏 / ERR_NAME_NOT_RESOLVED |
浏览器 localStorage 中残留了上次会话的过期 Token,Supabase 客户端尝试向 placeholder.supabase.co 发起请求 |
清除浏览器站点数据:Chrome → F12 → Application → Storage → Clear site data |
| 控制台出现 WebSocket 连接警告 | MSW 模式下,Supabase Realtime 的 WebSocket 连接没有对应的 Mock Handler | 预期行为,不影响功能。可忽略 |
npm run ai:check 覆盖率不达标 |
单元测试覆盖率低于阈值(lines 25%, branches 18%) | 为新添加的代码补充单元测试 |
Husky 安装错误 cd .. && husky app/.husky |
从 app/ 目录执行 npm install 时 Husky 尝试从仓库根目录安装 |
预期行为,Husky 需要从根目录管理 Git Hooks |
| TypeScript 类型错误 | 使用了旧版 Tailwind CSS 语法或不兼容的 API | 检查 .cursor/rules/ 中的规范,确保使用 Tailwind CSS v4 语法 |
调试技巧
-
利用 MSW Mock 模式隔离问题:遇到前后端交互问题时,切换到 MSW Mock 模式。如果 Mock 模式正常,说明问题在后端或 Supabase 配置;如果 Mock 模式也有问题,说明是前端代码问题。
-
查看 DataService 数据流:在
DataService.ts中添加console.log跟踪数据流向。关键检查点:读取是否从 IndexedDB 获取、写入是否触发乐观更新、同步是否成功完成。 -
使用
npm run type-check快速定位类型错误:TypeScript 的严格类型检查可以帮助快速定位代码问题。运行npm run type-check比完整构建更快,适合开发过程中频繁使用。
第六部分:学习路线推荐
官方文档推荐阅读顺序
- README.md — 快速入门、技术栈总览、项目结构。重点理解"AI 迭代地图"表格,它告诉你在修改不同功能时应该从哪个文件开始。
- AGENTS.md — AI 编码规范、技术栈详情、质量门禁。重点理解"禁止事项"和 Cursor Rules 自动触发机制。
- docs/Architecture.md — 系统架构、核心模块详解、数据流图。重点理解 DataService 的"Cache + Realtime"架构和 Agent Studio 的工作流程。
- app/supabase/setup.sql — 数据库 Schema 和 RLS 策略。重点理解组织管理的表结构和权限隔离策略。
- .cursor/rules/ — 按文件类型分类的编码规范。在实际开发中参考,确保代码符合项目标准。
推荐进阶资源
- Supabase 官方文档(supabase.com/docs)— 深入学习 Auth、Realtime、Edge Functions、RLS 等核心功能,OPC-Starter 深度依赖 Supabase 生态
- Zustand 官方仓库(github.com/pmndrs/zustand)— 理解轻量状态管理的最佳实践,OPC-Starter 的所有 Store 都基于 Zustand
- A2UI 协议规范(搜索"A2UI Agent to UI protocol 2026")— 了解 2026 年 AI Agent 协议栈中 A2UI 的定位和标准实现