第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 访问。
财务看板的数据结构
// 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; // 关联的邮件(如果有的话)
}
任务看板的数据结构
// 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 可以被三种方式修改:
1. Listener 自动修改
新邮件到达 → finance-email-tracker 提取金额 → 更新 financial-dashboard
2. Action 手动修改
用户点击 "添加支出" 按钮 → add-expense handler → 更新 financial-dashboard
3. AI 对话中修改
用户说 "把这笔 $50 的支出改成 $75" → AI 调用工具 → 更新 financial-dashboard
无论哪种方式修改,数据流都是一样的:
修改请求
↓
UIStateManager.set(stateId, newData)
↓
SQLite 持久化保存
↓
WebSocket 广播: { type: "ui_state_update", stateId, data }
↓
前端组件收到 → 自动重新渲染
第二层:UI State Manager
位置:ccsdk/ui-state-manager.ts
这是数据的"管家",负责增删改查和广播:
// 简化版
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/
财务看板组件
// 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>
);
}
任务看板组件
// 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
所有自定义组件都注册在一个中心注册表里:
const registry = {
'financial-dashboard': {
component: FinancialDashboard,
name: '财务看板',
icon: '📊',
},
'task-board': {
component: TaskBoard,
name: '任务看板',
icon: '✅',
},
};
前端的标签栏自动发现已注册的组件,显示为独立的标签页:
📨 收件箱 | 💬 聊天 | 📊 财务看板 | ✅ 任务看板
↑ ↑
自动发现 自动发现
你注册一个新组件,标签栏自动多一个标签。
前端怎么订阅数据更新?
位置:client/hooks/useUIState.ts
// 简化版
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,组件永远拿到最新数据。
完整联动示例
一封发票邮件到达后的完整链路:
📧 新邮件: "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")
│
▼
前端弹出通知
从收到邮件到看板更新,全自动,零人工干预。
本课小结
- 三层架构:存储(SQLite) → 数据模板(UI State) → 展示(React 组件)
- UI State 就是一个 JSON 对象,通过 stateId 访问
- 三种修改途径:Listener 自动、Action 手动、AI 对话
- 修改后自动广播到前端,组件实时刷新
- 组件自动发现,注册即显示为新标签