界面扩展
内置插件怎样通过 `contributes` 添加页面、设置项、标签页内容、看板小组件和快捷指令,并用 App 传入的 HostUI 组件搭建界面。
本页介绍内置插件怎样给 App 添加自己的界面。阅读前请先看内置插件。插件文档的小组件和页面用生成式界面的 JSON 描述,写法见声明式插件。
内置插件添加原生界面时,不需要导入任何 App 代码。每个页面和小组件都会通过 props 收到一个名为 HostUI 的对象,里面有 App 的组件、主题 token、触感反馈、导航和数据 hook。插件的界面全部用这些东西绘制,所以看起来和 App 的其他部分一致,App 改版后也照样能用。
插件添加的所有界面,都列在 definePlugin 的 contributes 里:
contributes: {
screens: { main: { title: '专注', component: FocusScreen } },
settings: [{ screen: 'main', title: '专注', subtitle: '专注时段', icon: 'clock' }],
widgets: { today: { title: '今日专注', description: '…', sizes: ['sm', 'md'], component: FocusWidget } },
commands: { focus: { description: '打开专注', aliases: ['专注'], run: (_i, { navigate }) => navigate('/p/focus/main') } },
tabs: [{ slot: 'schedule', screen: 'main' }], // 只有负责某个标签页的插件才需要
},页面
interface ScreenContribution {
title: string;
component: ComponentType<PluginScreenProps>;
presentation?: 'push' | 'modal'; // modal → /m/<plugin>/<screen>(表单);默认 push → /p/<plugin>/<screen>
}页面是一个 React 组件。默认以普通页面(push)的方式打开,表单请设置 presentation: 'modal'。组件会收到 PluginScreenProps,其中有 host、services、kv、invoke(toolId, input?)、params(路由的查询参数)和 cloud?。
import type { PluginScreenProps } from '@pku-harness/sdk';
import { useState } from 'react';
export function EditScreen({ host, params, invoke }: PluginScreenProps) {
const { SheetScreen, Field } = host.components;
const [text, setText] = useState(params.text ?? '');
return (
<SheetScreen title="编辑" primary="保存" onPrimary={async () => { await invoke('memory.update', { id: params.id, text }); host.dismiss(); }}>
<Field label="内容" value={text} onChangeText={setText} multiline />
</SheetScreen>
);
}打开页面用 host.navigate('/p/<plugin>/<screen>'),打开弹出页用 host.navigate('/m/<plugin>/<screen>?id=…'),关闭弹出页用 host.dismiss()。普通页面以 host.components.Screen 为根组件,弹出的表单页以 SheetScreen 为根组件。
设置项
interface SettingsContribution { screen: string; title: string; subtitle?: string; icon?: string }设置项是通往你某个页面的入口,点开后进入它指定的页面。除了这些设置项,「设置 → 插件」里的插件详情页还会列出插件的页面、小组件、工具和权限。
小组件
interface WidgetContribution {
title: string;
description: string; // 写给助手看:显示什么内容,config 起什么作用
sizes?: ('sm' | 'md' | 'lg')[];
defaultSize?: 'sm' | 'md' | 'lg';
configSchema?: z.ZodType<Record<string, unknown>>;
component: ComponentType<PluginWidgetProps>; // host, services, kv, invoke, size, config, cloud?
}小组件是看板上的一张卡片。编写时请注意以下几点:
- 卡片的外框由看板绘制,包括标题、背景、数据更新时间和长按菜单。你只需要绘制内容,并根据
size调整布局:sm是半宽的小卡片,md占满一行,lg是大号主卡片。 - 用
host.useToolData(tool, args, { ttlMinutes })加载数据(见看板)。刷新时机由 App 决定,所以不要轮询。 - 处理好三种状态。
status为'loading'时显示骨架屏;没有数据时显示错误文字,并让用户可以通过data.refresh()重试;还要处理未登录的情况。 - 小组件的
config由助手或用户选择,并用configSchema校验。每个字段用.describe()写说明,并在description里提到这些字段(「config.names limits it to certain canteens」),这样助手才知道有哪些配置。 - 每个小组件都放在一个错误边界(error boundary)里。即使它崩溃,看板也只在它的位置显示一条提示,其他小组件照常工作。
快捷指令
type CommandContribution = {
description: string; // 在 / 菜单里显示的一行说明
aliases?: string[]; // 别名,例如中文名:['余额']
argHint?: string; // 输入框占位提示;写了它,表示指令接受输入
} & (
| { prompt: string } // 作为用户消息发送;{{input}} 会被替换
| { run: (input: string, ctx: { services: HostServices; navigate: (href: string) => void; locale: AppLocale }) => Promise<string | void> | string | void }
);快捷指令是聊天输入框 / 菜单里的一项。指令名只能用小写字母、数字和连字符(^[a-z][a-z0-9-]*$)。如果两个插件用了同一个名字,App 会给它们加上插件 id 作为前缀,变成 /plugin:name。
指令有两种。prompt 指令把一段文字发给助手,就像用户自己输入的一样。run 指令在设备上运行,它返回的字符串作为提示显示在聊天里,不会发给模型。
commands: {
canteen: { description: '现在去哪个食堂', aliases: ['食堂', '吃饭'], argHint: '想吃什么 / 在哪(可空)', prompt: '现在哪些食堂还开着、人少?帮我挑一个{{input}}' },
shuttle: { description: '下一班校车(本地计算)', aliases: ['校车'], argHint: '如 燕园到新燕园', run: (input, { locale }) => quickAnswer(input, Date.now(), locale) },
},标签页
App 有两个标签页位置:dashboard 和 schedule,分别由提供它们的已启用插件填充,目前是 dashboard 插件和 schedule 插件。对话和设置两个标签页属于 App 本身。只有在你要替换这两个插件之一时,才需要提供标签页。
HostUI 要点
下面是 host 里最常用的成员:
| 成员 | 用途 |
|---|---|
host.components | Screen、SheetScreen、Text、Button、IconButton、ListGroup、ListRow、Chip、Surface、Glass、Stat、Ring、Sparkline、Field、DateTimeField、Switch、Checkbox、Segmented、Stepper、WeekdayPicker、SortableList、EmptyState、Skeleton、FadeIn、Icon、QRCode、GenUI、PluginWidget、Row、Column、Pressable、Hourglass |
host.useTheme() | 主题 token:colors、spacing、radius、typography、glass、courseColor(i)、contentWidth |
host.useToolData、useWidgetData、useBindings、refreshAll | 使用共享缓存的实时数据 |
host.useLocale() | 'zh-Hans' 或 'en' |
host.navigate、back、dismiss、ask(text) | 导航;ask 会切换到对话并发送一条消息 |
host.haptics | tap、select、success、warning(遵循用户设置) |
host.useNow(ms)、formatAgo(ms) | 每隔 ms 更新一次的当前时间;相对时间 |
host.useSecureDisplay({ brightness }) | 挂载期间禁止截屏(用于付款码) |
host.useStoreVersion() | 定时任务、待办、小组件或课表变化时重新渲染 |
host.clipboard?、host.shareLink? | 可选,使用前先判断是否存在 |
完整签名见 SDK → ui。
设计规则
- 必须使用
host.components和主题 token,不要写死颜色、字号或间距。 - 必须把所有面向用户的文字放进
strings.ts,用host.useLocale()选择语言。 - 必须使用宿主图标集里的图标名:
card、wifi、book、bell、clock、location、sparkles、key、person、qr、warning、check、calendar、bus、food、wallet、library、room、school、ticket、medal、chart、search、globe、lock等(完整列表见 App 的src/components/icon.tsx)。 - 应当给可点击的自定义内容加上
accessibilityLabel。 - 文案:中文要简短,不用感叹号;英文只有句首字母大写(sentence case),撇号用弯撇号(’)。