Workbench

编写 React 管理页面

从一个可读取、可刷新的页面开始,再按需添加分页和实时订阅。

需要自定义布局、分页或 React 组件时,使用 View。Plugin 提供服务端 API 和 React 页面,Workbench 负责登录与打开、关闭页面。 如果只有状态和几个按钮,先用更短的 Content。

Workbench 为 View 提供占满当前编辑窗格的挂载容器。页面可使用 height: 100% 填满可用高度, 并自行管理内容滚动、CSS Flex/Grid 和业务选项卡。可独立打开、停靠或关闭的页面通过 publication 声明,由工作区管理。

开始前,宿主应已启用 Workbench,Plugin 能正常启动。下面分为五份文件:页面声明、服务端实现、查询声明、React 入口和页面组件。 RpcTarget 是可以由页面调用的服务端对象;scope 将 React 组件与它对应的页面 API 关联。

先完成五份文件的最小页面,再按检查结果验证。只有需要参数路由、订阅或其他客户端时才继续扩展;依赖与构建说明发布包必须满足的版本和模块身份。

默认路径:snapshot + mutation

将 browser-safe DTO、API 和静态 definition 放进 src/workbench.ts:

import type { RpcTarget } from 'capnweb'
import { workbench } from '@pluxel/workbench'

export type OrdersSnapshot = Readonly<{
	revision: number
	openOrders: number
}>

export interface OrdersApi extends RpcTarget {
	snapshotDto(): Promise<OrdersSnapshot>
	refresh(): Promise<void>
}

export const OrdersWorkbench = workbench.define({
	overview: workbench.view<OrdersApi>({
		renderer: workbench.entry(import.meta.url, './ui/overview.tsx'),
		placement: workbench.tab({ label: 'Orders', order: 20 }),
	}),
})

Definition 是固定 flat record。entry key 是稳定 declaration identity;workbench.entry() 的路径必须是相对当前 module 的 literal。 不要按 item、principal 或运行时状态增删 entry。

接着在 src/OrdersPlugin.ts 提供服务端实现。这个最小例子用内存计数,实际项目把读取和刷新替换为自己的业务方法:

import { BasePlugin, Plugin } from '@pluxel/core'
import { RpcTarget } from 'capnweb'
import { assertWorkbenchDto } from '@pluxel/workbench/server'
import { OrdersWorkbench, type OrdersApi, type OrdersSnapshot } from './workbench.ts'

@Plugin({ displayName: 'Orders' })
export class OrdersPlugin extends BasePlugin {
	private revision = 0

	protected override init() {
		this.ctx.workbench?.publish(OrdersWorkbench, {
			overview: () => new OrdersTarget(this),
		})
	}

	async snapshot() {
		return { revision: this.revision, openOrders: 0 }
	}

	async refresh() {
		this.revision += 1
	}
}

class OrdersTarget extends RpcTarget implements OrdersApi {
	constructor(private readonly orders: OrdersPlugin) {
		super()
	}
	async snapshotDto(): Promise<OrdersSnapshot> {
		const dto = await this.orders.snapshot()
		assertWorkbenchDto(dto, 'Orders snapshot')
		return dto
	}
	refresh(): Promise<void> {
		return this.orders.refresh()
	}
}

每次打开都创建一个新的 OrdersTarget。如果真实页面要按用户授权、按路由加载对象或持有订阅, 从 factory 参数取得 principal、params、signal,在这里检查权限并在关闭时清理资源。 此例没有订阅或长任务,因此不需要额外的空 disposer。

每个 renderer 声明一个 module-scoped scope 和资源:

// src/ui/overview.scope.ts
import { createWorkbenchRenderer } from '@pluxel/workbench/react'
import { OrdersWorkbench } from '../workbench.ts'

export const overviewScope = createWorkbenchRenderer(OrdersWorkbench.overview)

export const ordersQuery = overviewScope.query(({ api }) => ({
	queryKey: ['orders', 'snapshot'] as const,
	queryFn: () => api.snapshotDto(),
}))

export const refreshOrders = overviewScope.mutation(({ api }) => ({
	mutationFn: () => api.refresh(),
	workbench: { invalidates: [ordersQuery] },
}))

entry 保持零 props,只把 page 绑定到 scope:

// src/ui/overview.tsx
import { OrdersPage } from './orders-page.tsx'
import { overviewScope } from './overview.scope.ts'

export default overviewScope.render(OrdersPage)

最后创建 src/ui/orders-page.tsx,显示查询结果并调用刷新:

import { ordersQuery, refreshOrders } from './overview.scope.ts'

export function OrdersPage() {
	const orders = ordersQuery.useQuery()
	const refresh = refreshOrders.useMutation()

	if (orders.status === 'pending') return <p>Loading…</p>
	if (orders.status === 'error' && orders.data === undefined) {
		return (
			<button
				type="button"
				onClick={() => {
					void orders.refetch().catch(() => {})
				}}
			>
				读取失败,点击重试
			</button>
		)
	}

	return (
		<>
			{orders.status === 'error' && <p role="alert">刷新读取失败,当前显示最近一次成功的数据。</p>}
			{refresh.status === 'error' && <p role="alert">操作失败,请重试。</p>}
			<button type="button" disabled={refresh.isPending} onClick={() => refresh.mutate()}>
				Refresh {orders.data.openOrders} orders (revision {orders.data.revision})
			</button>
		</>
	)
}

每次 Bridge mount 都有独立 renderer owner 和 private QueryClient;不同 open、principal、params 或 Plugin generation 不共享 query、mutation、cache 或 subscription。

mutate() 将失败交给 mutation state;显式 refetch() 返回的 Promise 需要处理 rejection,错误同时保留在 query state 中。

检查结果

运行应用,从“应用”目录打开 Plugin 的 Orders 入口,应看到订单数量和 Refresh 按钮。点击后按钮在请求期间禁用,成功后重新读取并看到 revision 增加。 关闭再打开应重新读取;两个同时打开的页面不应共用操作状态。

查询选项、分页输入、错误代码和 Mantine Provider 见 查询、写入与页面资源。 认证、完整刷新和反向代理见 使用与排查工作台。

必须遵守的 renderer scope 边界

每个 renderer graph 只能有一个 server-definition value boundary。默认把它放在 <entry>.scope.ts:

import { createWorkbenchRenderer } from '@pluxel/workbench/react'
import { OrdersWorkbench } from '../workbench.ts'

export const overviewScope = createWorkbenchRenderer(OrdersWorkbench.overview)

其他 page/panel 只能 import 这个 scope 或其 resources,不能再 import/re-export/dynamic import server definition。Toolchain 会把这条 唯一边界改写成 browser-only projection,避免 browser 执行 Plugin implementation。构建错误会指出违规 module、specifier 和 期望 descriptor;直接按错误中的 createWorkbenchRenderer(Definition.entry) 形状修复。

低层 useWorkbench(exactDescriptor) 只能在 renderer default entry 保留这唯一边界;它不是普通 snapshot 页面首选路径。

页面 API 与宿主边界

  • 纯数据 RPC 方法使用 *Dto 后缀和显式返回类型,返回前用 assertWorkbenchDto() 校验可传输性;大列表使用 cursor/limit。
  • DTO 生产、输入校验与授权规则见 API 契约。
  • mutation 只有页面需要 result 时才返回 DTO;否则返回 void,用 subscription 或 typed invalidation 刷新。
  • API 不返回 Plugin、Context、database handle、native object、raw socket 或 Shell service。
  • host 的外观、导航、文档与可选管理能力见Host 能力,不从 Shell 内部取得服务。
  • route params 由 server match 后冻结;browser 不能提交 principal 或 authority object。

何时升级

  • read model 会在外部变化:query 通过 workbench.subscribe: ({ invalidate }) => api.watch(invalidate) 订阅权威 invalidation。 此时 mutation 若必然触发同一 subscription,不重复声明 invalidation。
  • 按 input 读取:使用 queryFamily(),为每个 input 返回有界、可移植且领域可读的 queryKey;失效时明确选 target(input) 或 all()。
  • callback/progress/cancel/lossless stream:由领域 API 返回具有自己 disposer 语义的 child RpcTarget。只有 server-side custom callback target 需要 dup() observer、释放每次 callback result,并在 open signal abort 时 unsubscribe。
  • 内容需要分栏:使用普通 CSS Flex/Grid 和 Mantine 组件,根据当前容器宽度响应;不要读取 Shell router、store 或 raw socket。

普通 scope.query() 负责 browser-side subscription 的 retain、abort 和 dispose。不要为了普通 latest snapshot 手写 observer target。

Placement 与参数化 route

为当前插件提供左右侧栏或底部工具时,使用 workbench.panel()。它复用相同 View renderer/API,切换或隐藏时卸载;需要长期编辑与独立草稿的功能继续使用下面的文档 placement。

Tab 适合插件提供的固定页面,入口也会出现在“应用”目录中。入口默认在当前工作区文档中导航; 需要并行操作时由用户点击活动标签后的“+”,或由业务显式调用 openDocument() 创建文档:

workbench.tab({
	label: 'Settings',
	icon: workbench.icons.Settings,
	group: { id: 'operations', label: 'Operations' },
	order: 30,
})

Route 适合业务应用和带参数的实体页面。固定集合页可声明 navigation,进入“应用”目录:

workbench.route('/accounts', {
	title: 'Accounts',
	navigation: { label: '账号管理' },
})

实体页使用参数路径:

account: workbench.view<AccountApi>({
	renderer: workbench.entry(import.meta.url, './ui/account.tsx'),
	placement: workbench.route('/accounts/:accountId', {
		title: 'Account',
		frame: 'shell',
	}),
})

Parameterized route 不进入固定 navigation。集合中的普通选择应使用 host.navigation?.navigate(path); 只有明确需要并行打开时,才使用:

host.navigation?.openDocument({
	path: `/accounts/${encodeURIComponent(account.id)}`,
	title: account.displayName,
	meta: 'Bot account',
})

浏览器地址跟随 uiBasePath:/accounts 在 '/__pluxel/workbench' 下对应 /__pluxel/workbench/accounts,不要求设置 navigation。 不同 Plugin/fork 路径重叠时,冲突入口改用带实例身份的完整地址;歧义短地址显示入口选择页。同一 Plugin 内静态路径优先于参数路径。 Shell 保留首页及内置管理路径;业务路由不要占用 apps、plugins、plugin-graph、security、logs、workbench、workbench-standalone 等路径及其子路径。 完整地址始终有效;publication 更新后重算短地址,冲突消失时恢复。固定跨插件链接应选择有业务辨识度的路径。

Server 会重新匹配 route,再把 frozen params 交给 factory。Browser 不能提交 principal 或 authority object。 Document renderer 可以用 host.document?.params、setTitle() 和 setDirty() 管理当前文档 chrome。 title 显示为原生标签名称,meta 显示为标签悬停说明;setTitle() 的覆盖随本次 View 释放,已替换的页面标签不会被旧 View 重置。

Host 能力

host 是固定的受限 facade:

  • locale、colorScheme;
  • notify()、confirm();
  • 可空的 relative navigation;
  • 可空的 parameterized document;

navigation 的路径必须以 / 开头,且是规范的 Plugin route 路径;不接受 query、hash 或站外 URL。 navigate() 在当前文档内切换 shell 页面,openDocument() 创建独立文档并采用传入的标题与说明。 目标声明为 standalone 时,两者都通过工作区的离开守卫执行完整页面导航;冲突路径使用包含 Plugin 实例身份的完整地址。

Renderer 不取得 generic HTTP client、任意 URL navigation、Shell router/store 或 raw WebSocket。 页面负责自己的内容布局和滚动,宿主负责原生标签、容器、分组、拖拽、分隔线和最大化。 移动文档或切换活动标签不会重新打开页面 API。当前文档导航到其他页面、关闭文档或原页面失效时, 旧页面的 Bridge、订阅和 opened handle 按其生命周期释放。面板的隐藏与释放规则见上下文面板。

借用宿主管理操作

官方 Shell 向 View 提供可选的 host.management,共享当前已认证会话的 unary management 操作。自定义 Shell 必须在 createWorkbenchViewHost({ management, ... }) 中显式绑定; 未绑定时该字段为 null,Renderer 应处理不可用状态。

这不转移会话所有权:没有 raw socket、订阅控制或 session disposal。关闭 View 后, 已缓存的 namespace 与独立保存的 method 都不能继续发起操作;已经接受的调用仍按原会话完成, 关闭不表示业务回滚。认证、权限检查和审计与 Shell 内建页面完全相同。

完整 View 参考:订阅后台变化

后台或其他页面会修改数据时,在前面的例子中加入 watch(),不再复制另一套页面。

  1. OrdersApi 增加 watch(observer: (revision: number) => void | Promise<void>): RpcTarget。
  2. Plugin 的领域服务提供 subscribe(listener): Disposable,每次提交后通知递增 revision。
  3. factory 改成 overview: ({ signal }) => new OrdersTarget(this, signal),target 保存本次 open 的 signal。
  4. target 用现有 helper 接管远端 observer:
import { createWorkbenchWatch } from '@pluxel/workbench/server'

// OrdersTarget 中;orders.subscribe() 返回领域订阅的 Disposable。
watch(observer: (revision: number) => void | Promise<void>): RpcTarget {
	return createWorkbenchWatch({
		observer,
		signal: this.signal,
		subscribe: (notify) => this.orders.subscribe(notify),
	})
}

然后只修改原有资源声明,entry 与页面组件保持相同:

export const ordersQuery = overviewScope.query(({ api }) => ({
	queryKey: ['orders', 'snapshot'] as const,
	queryFn: () => api.snapshotDto(),
	workbench: { subscribe: ({ invalidate }) => api.watch(invalidate) },
}))

export const refreshOrders = overviewScope.mutation(({ api }) => ({
	mutationFn: () => api.refresh(),
}))

refresh() 提交后必须触发同一权威通知,才可省略 invalidates。helper 用于最新状态失效提示,会合并中间 revision;逐条事件和可取消进度使用领域 capability。 观察者引用、callback result 和退订的完整规则见服务端最新状态通知。

依赖与构建

向 Workbench 发布 View/Attachment target 时,作者直接从 capnweb 导入 RpcTarget。最终 Host 应用 在 dependencies 安装运行时;target publisher 和提供 target class 的基础库声明宿主支持的精确 peer 版本,源码开发还声明同版本 dev dependency。当前 Workbench 支持 0.12.0;官方包使用 catalog:prod。 pluxel build 检查 publisher 的 peer、dev 与实际安装,生成 pluxel.workbenchCapnweb 制品事实。 开发 Vite、静态构建和生产动态来源先核对共享包的声明与实际解析,再接入宿主的原生模块;基础库由 peer 声明识别,无需手写制品标记。私有 RPC 保留自己的依赖解析。绕过标准加载器造成的第二份模块 仍会在 Workbench 打开 target 时被拒绝。openEntry() 的失败结果区分 invalid_target(不是宿主 RpcTarget)、reused_target(重复输出同一实例)、factory_timeout、target_unavailable 和未知 factory 异常的 factory_failed。Factory 失败结果包含 diagnosticId;页面只显示此标识,服务端日志用同一标识记录 node、entry、阶段和原始异常,供授权开发诊断定位。

RpcTarget 是 Cap’n Web 的能力对象,不归 Workbench 所有。同一份 browser-safe API contract 和 target class 可以用 Cap’n Web 的 new RpcStub(target) 独立测试,也可在确有 CLI 或其他客户端需求时由另一条明确拥有的 Cap’n Web session 挂载。 复用的是 contract、target class 和底层领域 service,不是已经打开的 target 实例:每个 session/open 都必须创建 fresh target,并由新的挂载方自己提供认证、授权、输入预算、取消和释放语义,不能假定 Workbench session 的保障仍然存在。 只有出现这种真实的第二消费者时,才将共用 DTO/API 从 workbench.ts 提取到中立 contract module 或独立 package subpath; 仅供 Workbench 使用时保持当前结构。

最后更新于

本页目录