PKU harness

看板小组件与数据

看板上的小组件有两类:助手搭建的小组件(一份生成式界面 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 扩展。

本页目录