搞懂看板系统——数据怎么存、怎么显示、怎么跟 Actions 和 Listeners 联动。

第10课:数据看板 —— UI State 与自定义组件

本课目标

搞懂看板系统——数据怎么存、怎么显示、怎么跟 Actions 和 Listeners 联动。


三层架构

看板系统分三层,各管各的:

graph TD L3["第三层:React 组件(Component)<br>FinancialDashboard.tsx / TaskBoard.tsx<br>-- 怎么画出来"] L2["第二层:数据模板(UI State Template)<br>financial-dashboard.ts / task-board.ts<br>-- 数据长什么样"] L1["第一层:持久化存储(SQLite ui_states 表)<br>stateId -> JSON data<br>-- 数据存在哪"] L3 --> L2 --> L1

为什么分三层? 因为关注点不同:存储管"保存",模板管"结构",组件管"展示"。改一层不影响其他层。


第一层:UI State(数据本体)

位置:agent/custom_scripts/ui-states/

UI State 就是一个 JSON 对象,存在数据库里,通过 stateId 访问。

财务看板的数据结构

code
// agent/custom_scripts/ui-states/financial-dashboard.ts

export const config = {
    stateId: "financial-dashboard",
    name: "财务仪表盘",
    description: "跟踪收入和支出",
    initialData: {
        transactions: [],      // 交易记录列表
        categories: [          // 分类
            "办公", "餐饮", "交通", "订阅", "工具", "其他"
        ],
        monthlyBudget: 5000,   // 月预算
    }
};

// 类型定义
interface FinancialDashboardState {
    transactions: Transaction[];
    categories: string[];
    monthlyBudget: number;
}

interface Transaction {
    date: string;
    type: "expense" | "income" | "refund";
    amount: number;
    vendor: string;
    category: string;
    emailId?: string;        // 关联的邮件(如果有的话)
}

任务看板的数据结构

code
// agent/custom_scripts/ui-states/task-board.ts

export const config = {
    stateId: "task-board",
    name: "任务看板",
    description: "管理从邮件中提取的待办事项",
    initialData: {
        tasks: [],
        columns: ["todo", "in_progress", "done"],
    }
};

interface TaskBoardState {
    tasks: Task[];
    columns: string[];
}

interface Task {
    id: string;
    title: string;
    priority: "high" | "medium" | "low";
    status: "todo" | "in_progress" | "done";
    deadline?: string;
    source?: string;          // "邮件: Invoice #123"
    emailId?: string;
    createdAt: string;
}

数据怎么被修改?

UI State 可以被三种方式修改:

code
1. Listener 自动修改
   新邮件到达 → finance-email-tracker 提取金额 → 更新 financial-dashboard

2. Action 手动修改
   用户点击 "添加支出" 按钮 → add-expense handler → 更新 financial-dashboard

3. AI 对话中修改
   用户说 "把这笔 $50 的支出改成 $75" → AI 调用工具 → 更新 financial-dashboard

无论哪种方式修改,数据流都是一样的:

code
修改请求
  ↓
UIStateManager.set(stateId, newData)
  ↓
SQLite 持久化保存
  ↓
WebSocket 广播: { type: "ui_state_update", stateId, data }
  ↓
前端组件收到 → 自动重新渲染

第二层:UI State Manager

位置:ccsdk/ui-state-manager.ts

这是数据的"管家",负责增删改查和广播:

code
// 简化版
class UIStateManager {
    // 获取数据
    async get(stateId: string) {
        const row = db.get('SELECT data FROM ui_states WHERE stateId = ?', stateId);
        if (!row) {
            // 第一次访问,用模板的 initialData 初始化
            const template = this.templates.get(stateId);
            await this.set(stateId, template.config.initialData);
            return template.config.initialData;
        }
        return JSON.parse(row.data);
    }

    // 更新数据
    async set(stateId: string, data: any) {
        db.run(
            'INSERT OR REPLACE INTO ui_states (stateId, data, updatedAt) VALUES (?, ?, ?)',
            [stateId, JSON.stringify(data), new Date().toISOString()]
        );

        // 关键:通知所有前端客户端
        this.broadcast({
            type: 'ui_state_update',
            stateId,
            data
        });
    }
}

第三层:React 组件

位置:client/components/custom/

财务看板组件

code
// client/components/custom/FinancialDashboard.tsx(简化版)

function FinancialDashboard({ state }) {
    const { transactions, monthlyBudget } = state;

    // 计算本月支出
    const thisMonth = transactions.filter(t =>
        t.type === 'expense' &&
        new Date(t.date).getMonth() === new Date().getMonth()
    );
    const totalExpense = thisMonth.reduce((sum, t) => sum + t.amount, 0);

    return (
        <div>
            {/* 预算概览 */}
            <div className="budget-bar">
                <div>本月支出: ${totalExpense} / ${monthlyBudget}</div>
                <div style={{ width: `${(totalExpense/monthlyBudget)*100}%` }}
                     className="progress-bar" />
            </div>

            {/* 交易列表 */}
            <table>
                <thead>
                    <tr><th>日期</th><th>供应商</th><th>分类</th><th>金额</th></tr>
                </thead>
                <tbody>
                    {transactions.map(t => (
                        <tr key={t.date + t.vendor}>
                            <td>{t.date}</td>
                            <td>{t.vendor}</td>
                            <td>{t.category}</td>
                            <td className={t.type === 'expense' ? 'red' : 'green'}>
                                {t.type === 'expense' ? '-' : '+'}${t.amount}
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>

            {/* 操作按钮 */}
            <button onClick={() => triggerAction('add-expense')}>
                + 添加支出
            </button>
            <button onClick={() => triggerAction('add-income')}>
                + 添加收入
            </button>
        </div>
    );
}

任务看板组件

code
// client/components/custom/TaskBoard.tsx(简化版)

function TaskBoard({ state }) {
    const { tasks, columns } = state;

    return (
        <div className="kanban-board">
            {columns.map(column => (
                <div key={column} className="kanban-column">
                    <h3>{column === 'todo' ? '待办' :
                         column === 'in_progress' ? '进行中' : '已完成'}</h3>
                    {tasks
                        .filter(t => t.status === column)
                        .map(task => (
                            <div key={task.id} className="task-card">
                                <span className={`priority-${task.priority}`}>
                                    {task.priority}
                                </span>
                                <p>{task.title}</p>
                                <small>{task.source}</small>
                                {task.deadline &&
                                    <small>截止: {task.deadline}</small>}
                            </div>
                        ))
                    }
                </div>
            ))}
        </div>
    );
}

组件注册和自动发现

位置:client/components/custom/ComponentRegistry.ts

所有自定义组件都注册在一个中心注册表里:

code
const registry = {
    'financial-dashboard': {
        component: FinancialDashboard,
        name: '财务看板',
        icon: '📊',
    },
    'task-board': {
        component: TaskBoard,
        name: '任务看板',
        icon: '✅',
    },
};

前端的标签栏自动发现已注册的组件,显示为独立的标签页:

code
📨 收件箱 | 💬 聊天 | 📊 财务看板 | ✅ 任务看板
                       ↑              ↑
                   自动发现          自动发现

你注册一个新组件,标签栏自动多一个标签。


前端怎么订阅数据更新?

位置:client/hooks/useUIState.ts

code
// 简化版
function useUIState(stateId: string) {
    const [data, setData] = useState(null);

    useEffect(() => {
        // 初始加载
        fetch(`/api/ui-state/${stateId}`)
            .then(res => res.json())
            .then(setData);

        // 订阅实时更新
        websocket.on('ui_state_update', (msg) => {
            if (msg.stateId === stateId) {
                setData(msg.data);  // 自动更新!
            }
        });
    }, [stateId]);

    return data;
}

组件只管"画",不管数据怎么来——通过这个 hook,组件永远拿到最新数据。


完整联动示例

一封发票邮件到达后的完整链路:

code
📧 新邮件: "Invoice #456 from AWS - $129.99"
    │
    ▼ IMAP 同步
SQLite 存入邮件
    │
    ▼ 触发事件
Listener: finance-email-tracker
    │
    ├─ 关键词检测: "invoice" ✓
    │
    ├─ callAgent() → AI 分析
    │  返回: { is_financial: true, type: "expense",
    │          amount: 129.99, vendor: "AWS", category: "云服务" }
    │
    ├─ uiState.set('financial-dashboard', 新数据)
    │    │
    │    ├─ SQLite 持久化
    │    └─ WebSocket 广播
    │         │
    │         ▼
    │    前端 FinancialDashboard 组件自动刷新
    │    表格里多了一行: "AWS | 云服务 | -$129.99"
    │
    ├─ label(emailId, 'Finance') → 邮件被贴标签
    │
    └─ notify("💰 检测到支出: $129.99 - AWS")
         │
         ▼
    前端弹出通知

从收到邮件到看板更新,全自动,零人工干预。


本课小结

  1. 三层架构:存储(SQLite) → 数据模板(UI State) → 展示(React 组件)
  2. UI State 就是一个 JSON 对象,通过 stateId 访问
  3. 三种修改途径:Listener 自动、Action 手动、AI 对话
  4. 修改后自动广播到前端,组件实时刷新
  5. 组件自动发现,注册即显示为新标签

沿着当前专题继续,或返回课程目录重新整理阅读顺序。

返回课程目录