看板小组件与数据
看板上的小组件有两类:助手搭建的小组件(一份生成式界面 spec,加上对读取类工具的绑定),以及插件提供的原生组件。刷新由宿主统一负责,所有组件共用一份缓存。
「看板」标签页由 dashboard 插件提供。它是一个有序的小组件(widget)列表,例如校园卡余额、下一节课和下一班校车。
你可以在对话里告诉助手想看哪些数字,例如「显示我的校园卡余额和下一班校车」,助手就会搭建或修改看板。你也可以长按一个小组件,调整它的顺序、大小或者删除它,还可以从小组件库里添加插件提供的小组件。
两类小组件
看板上的小组件分为两类,它们可以混排在一起。
助手搭建的小组件由 { spec, bindings } 描述。助手通过 dashboard.upsertWidget 写出一份生成式界面 spec 来决定它的样子。数据来自 bindings:每一项绑定把状态中的一个路径对应到一个读取类工具。
插件提供的小组件由 { source: { plugin, widget, config? } } 描述。它是插件自己的原生组件,在 contributes.widgets 里声明。看板会用一层错误边界把它包起来,这样一个小组件出错不会影响其他小组件。组件在内部调用 host.useToolData(...) 获取数据。
搭建看板时,助手会先查看 dashboard.catalog。如果有合适的插件小组件,它就直接把这个小组件放上看板(dashboard.addPluginWidget)。只有找不到合适的,它才自己搭建一个。
无论哪一类,小组件的外框都由看板来画。外框包括标题、数据的更新时间和长按菜单。
小组件模型
type Widget = {
id: string;
title: string;
size: 'sm' | 'md' | 'lg'; // sm = 半宽小块,md = 全宽,lg = 全宽大卡片
spec: Record<string, unknown>; // json-render spec(插件小组件为空)
bindings: Record<string, Binding>; // 状态中的 JSON pointer → 数据源
source?: { plugin: string; widget: string; config?: Record<string, unknown> };
position: number;
createdAt: number;
updatedAt: number;
};
type Binding = {
tool: string; // 只读工具 id,例如 "calendar.today"
args?: Record<string, unknown>;
path?: string; // 结果中的点路径;为空表示整个结果
ttlMinutes?: number; // 0 – 1440,默认 5
};size 有三种:sm 是半宽的小块,md 占满一行,lg 是占满一行的大卡片。
下面是一个由助手搭建的小组件,它显示今天的课程。bindings 把状态中的 /today 绑定到 timetable.today 工具,缓存 1 分钟。
{
"title": "下一节课",
"size": "md",
"spec": {
"root": "col",
"elements": {
"col": { "type": "Column", "props": { "gap": "sm" }, "children": ["h", "t"] },
"h": { "type": "Heading", "props": { "text": { "$template": "今天共 ${/today/count} 节课" }, "size": "md" } },
"t": { "type": "Text", "props": { "text": { "$template": "下一节:${/today/next/name} ${/today/next/time}" }, "tone": "muted", "size": null } }
}
},
"bindings": { "/today": { "tool": "timetable.today", "ttlMinutes": 1 } }
}小组件如何刷新
小组件的刷新由宿主统一负责,插件不需要自己处理。
所有读取类工具的结果都存在同一个存储里,以「工具 + 参数」作为键。助手搭建的小组件、插件小组件、插件页面和定时检查都共用这个存储。如果你用过 TanStack Query,会发现这是同一种模型,叫作 stale-while-revalidate:先显示缓存里的旧值,同时在后台获取新值。具体规则如下。
- 上一次的值会保存下来。 App 冷启动后,数字会立刻显示出来,并标明是多久以前的数据,例如「3 分钟前」。只有从来没有获取过数据时,才会显示骨架屏。
- 数据超过缓存有效期(TTL)后就算过期。 默认的 TTL 是 5 分钟。过期的查询只有在当前聚焦的页面上才会重新获取,时机是页面获得焦点时、App 回到前台时,以及页面可见期间的定时刷新。隐藏的标签页和在后台运行的 App 从不轮询。
- 下拉刷新会强制刷新当前页面上的所有查询。 同时发出的相同请求会共用同一个 promise,不会重复发送。
- 刷新失败时保留上一次的值, 并加上一个标记,例如「刷新失败 · 10 分钟前」。
- 定时检查也会写入这个存储。 所以后台的余额检查会顺便更新看板上的余额小组件。
因此,插件小组件不得自己设定时器轮询。请用 host.useToolData 加载数据,并按数据变化的快慢选择 TTL。余额这类数据适合 5–15 分钟,变化慢的数据适合 60 分钟以上。
编写插件小组件
下面是一个显示食堂就餐人数的插件小组件。它用 host.useToolData 读取 canteen.crowd 工具的结果,TTL 设为 2 分钟。小尺寸时显示 3 个食堂,其他尺寸显示 6 个。
import type { PluginWidgetProps } from '@pku-harness/sdk';
import { View } from 'react-native';
type Crowd = { name: string; percent: number }[];
export function CrowdWidget({ host, size }: PluginWidgetProps) {
const { Text, Skeleton } = host.components;
const t = host.useTheme();
const data = host.useToolData<{ canteens: Crowd }>('canteen.crowd', {}, { ttlMinutes: 2 });
if (data.status === 'loading') return <Skeleton width="60%" height={20} />;
if (!data.value) return <Text tone="muted">{data.error ?? '暂无数据'}</Text>;
const list = data.value.canteens.slice(0, size === 'sm' ? 3 : 6);
return (
<View style={{ gap: t.spacing.xs }}>
{list.map((c) => (
<Text key={c.name} variant="callout">{`${c.name} ${c.percent}%`}</Text>
))}
</View>
);
}写好之后,在 contributes.widgets 下注册这个组件,并为它写一段给助手看的 description。助手挑选小组件时,读的就是这段描述。详见 UI 扩展。