Skip to content

Frontend Getting Started

worable edited this page Oct 12, 2026 · 1 revision

快速上手:开发插件前端

做一个最小的插件前端,包含一个公开页面、一个 Finder 和一个管理后台页面,跑通从写代码到在后台看到效果的完整链路。字段细节先不用记,跑通后再查前端清单。

前置

  • 仓库已 pnpm install,开发服务能跑起来(见运行项目)。
  • 已经知道怎么创建插件后端。若还没有,先看插件开发准备。

1. 生成脚手架

pnpm plugin:create my-plugin --name "我的插件" --frontend

会生成:

packages/plugins/my-plugin/
├── src/index.ts                 # 插件后端(definePlugin)
├── frontend/
│   ├── package.json
│   └── src/
│       ├── index.tsx            # 服务端入口:页面 / Finder / 路由声明
│       └── admin.tsx            # 客户端入口:交互式管理后台页面
├── tsconfig.json
└── package.json

两个前端入口的分工,是理解这套系统的关键:

  • index.tsx 在服务端渲染。放公开页面(pages)、数据源(finders),以及路由声明(ui.adminRoutes)。
  • admin.tsx 在客户端渲染(文件首行 'use client')。放交互式管理后台页面组件(adminPages)。

2. 服务端入口:页面与 Finder

打开 frontend/src/index.tsx,脚手架已经写好了一个公开页面和一个 Finder:

import type { FinderContext, FrontendPackage } from '@stackpanel/sdk';
import { PageHeader } from '@stackpanel/ui';
import type { ReactElement } from 'react';
import { z } from 'zod';

function MyPluginPage(): ReactElement {
  return (
    <main className="w-full space-y-8">
      <PageHeader title="我的插件" description="公开页面。" />
    </main>
  );
}

const statusSchema = z.object({
  plugin: z.string(),
  version: z.string(),
  status: z.string(),
});

const finders = {
  'my-plugin.status': async (_input: Record<string, unknown>, ctx: FinderContext) =>
    ctx.api.get('/admin/my-plugin/status', statusSchema),
};

export const frontend: FrontendPackage = {
  pages: [{ path: '/my-plugin', component: 'my-plugin/index' }],
  pageComponents: { 'my-plugin/index': MyPluginPage },
  finders,
  ui: {
    adminRoutes: [
      {
        path: '/overview',
        component: 'admin/overview', // 名字要和 admin.tsx 的 adminPages 键一致
        permission: 'my-plugin:admin',
        nav: { label: '我的插件', group: '插件' },
        data: [{ finder: 'my-plugin.status', as: 'status' }],
      },
    ],
  },
};

// 必须保留:平台靠 default 导出解析本包。
export default frontend;

要点:

  • pages[].component 是组件在 pageComponents 里的键,不是函数名。
  • data 声明了页面要预先取哪些数据:{ finder, as } 表示用 my-plugin.status 这个 Finder,把结果注入组件的 props.data.status。
  • ui.adminRoutes 只声明路由;组件不在这里。

3. 客户端入口:管理后台页面

打开 frontend/src/admin.tsx:

'use client';

import type { AdminPageComponentProps } from '@stackpanel/sdk';
import { PageHeader } from '@stackpanel/ui';
import type { ReactElement } from 'react';
import { z } from 'zod';

const statusSchema = z.object({
  plugin: z.string(),
  version: z.string(),
  status: z.string(),
});

function MyPluginAdminPage(props: AdminPageComponentProps): ReactElement {
  const status = props.data.status as z.infer<typeof statusSchema> | undefined;
  return (
    <main className="w-full space-y-8">
      <PageHeader title="我的插件" description="管理入口。" />
      {status ? (
        <dl className="grid gap-3 rounded-lg border bg-card p-4 text-sm sm:grid-cols-3">
          <div>
            <dt className="text-muted-foreground">版本</dt>
            <dd className="mt-1 font-mono">{status.version}</dd>
          </div>
          <div>
            <dt className="text-muted-foreground">状态</dt>
            <dd className="mt-1">{status.status}</dd>
          </div>
        </dl>
      ) : null}
    </main>
  );
}

export const adminPages = {
  'admin/overview': MyPluginAdminPage, // 键必须匹配 ui.adminRoutes[].component
} as const;

要点:

  • 首行必须是 'use client'。
  • 导出名必须是 adminPages,类型为 Record<component名, 组件>。
  • 它同样通过 props.data 拿到 adminRoutes[].data 里声明的数据。

4. 编译

pnpm build:frontend

这一步会:

  1. 把 src/index.tsx 编成 frontend/dist/index.js,src/admin.tsx 编成 frontend/dist/admin.js;
  2. 重新生成 frontend/manifest.json(不要手改它);
  3. 把产物分发到 data/plugins/<id>/frontend/,并更新 apps/web/src/lib/frontend-registry.ts 与 frontend-client-registry.ts。

5. 运行与验证

开发内置插件时,改完后按顺序执行:

pnpm --filter @stackpanel/plugin-my-plugin typecheck   # 可选,先过类型
pnpm build:frontend                                     # 重新编译前端
# 若改了后端 src/index.ts:
pnpm --filter @stackpanel/plugin-my-plugin build
# 触发 API 重新 seed
touch apps/api/src/app.ts

然后在浏览器打开:

  • 公开页面:/my-plugin
  • 管理后台页面:/admin/plugin/my-plugin/overview(需管理员登录)

能正常看到内容,就说明两个入口都接通了。

打包上传给外部使用时执行 pnpm plugin:pack my-plugin,再到管理后台 /admin/plugins 上传启用。

6. 声明表单提交操作

纯展示之外,最常见的需求是提交。平台用**声明式操作(actions)**统一处理鉴权、输入校验、审计与转发,你在前端只写 action={executor},后端只提供对应路由。

第一步,在 src/index.tsx 的 ui.actions 里声明:

ui: {
  actions: [
    {
      id: 'my-plugin.create',
      method: 'POST',
      path: '/admin/my-plugin/items',
      permission: 'my-plugin:admin',
      input: [{ name: 'name', type: 'string', required: true, maxLength: 191 }],
    },
  ],
  adminRoutes: [/* ... */],
},

第二步,在 admin.tsx 里把它接到表单上:

function MyPluginAdminPage(props: AdminPageComponentProps): ReactElement {
  const create = props.actions['my-plugin.create'];
  return (
    <form action={create} className="grid gap-3">
      <input name="name" required className="h-9 rounded-lg border bg-background px-3" />
      <button type="submit" className="h-9 w-fit rounded-lg bg-primary px-3 text-primary-foreground">
        创建
      </button>
    </form>
  );
}

props.actions['<action id>'] 是一个可直接传给原生 <form action> 的 Server Action。详见管理后台扩展。

Clone this wiki locally