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 的 signInWithPasswordsignInWithOAuth 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 Hooksnpm install 时会自动安装 Git Hooks(cd .. && husky app/.husky),这是预期行为

练习题: 1. 使用 Cursor 的 @file 功能引用 AGENTS.mdapp/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 策略确保数据隔离

扩展挑战

  1. 拖拽排序:引入 @dnd-kit/core 库,实现任务卡片在列之间的拖拽移动,替代当前的按钮切换。
  2. Agent Studio 集成:让用户通过自然语言创建任务(如"帮我创建一个'修复登录 Bug'的任务"),利用 A2UI 协议动态生成任务创建表单。
  3. 实时协作:利用 Supabase Realtime,实现多用户同时查看和编辑任务时的实时更新。

第五部分:常见问题与排查指南

常见错误及解决方案

错误信息 原因 解决方案
npm install 失败 (ECONNRESET) package-lock.json 引用了阿里巴巴内部 npm 镜像源,外部无法访问 删除 app/package-lock.jsonapp/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 语法

调试技巧

  1. 利用 MSW Mock 模式隔离问题:遇到前后端交互问题时,切换到 MSW Mock 模式。如果 Mock 模式正常,说明问题在后端或 Supabase 配置;如果 Mock 模式也有问题,说明是前端代码问题。

  2. 查看 DataService 数据流:在 DataService.ts 中添加 console.log 跟踪数据流向。关键检查点:读取是否从 IndexedDB 获取、写入是否触发乐观更新、同步是否成功完成。

  3. 使用 npm run type-check 快速定位类型错误:TypeScript 的严格类型检查可以帮助快速定位代码问题。运行 npm run type-check 比完整构建更快,适合开发过程中频繁使用。


第六部分:学习路线推荐

官方文档推荐阅读顺序

  1. README.md — 快速入门、技术栈总览、项目结构。重点理解"AI 迭代地图"表格,它告诉你在修改不同功能时应该从哪个文件开始。
  2. AGENTS.md — AI 编码规范、技术栈详情、质量门禁。重点理解"禁止事项"和 Cursor Rules 自动触发机制。
  3. docs/Architecture.md — 系统架构、核心模块详解、数据流图。重点理解 DataService 的"Cache + Realtime"架构和 Agent Studio 的工作流程。
  4. app/supabase/setup.sql — 数据库 Schema 和 RLS 策略。重点理解组织管理的表结构和权限隔离策略。
  5. .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 的定位和标准实现