Instatic 管理后台 Dashboard:12 列可配置 Widget 网格的设计与实现
【免费下载链接】InstaticThe open-source alternative to Webflow, Framer and WordPress. Agentic self-hosted visual CMS outputting clean static pages. Users, roles, plugins, content, database, it's all there.项目地址: https://gitcode.com/GitHub_Trending/in/Instatic
导读
Instatic 是一个自托管的可视化 CMS,其管理后台入口/admin/dashboard是登录后的首页。本文围绕docs/features/dashboard.md展开,深入讲解 Dashboard 工作区的完整实现:12 列 Widget 网格的布局模型、borderless-tile-card 视觉模式、基于 dnd-kit 的拖拽与缩放、按用户持久化的布局存储、按域拆分的统计端点,以及如何从零注册一个第一方 Widget 或插件 Widget。读完本文,你将掌握这套网格系统从 CSS 到 React 状态再到服务端路由的完整调用链,并具备直接上手扩展 Dashboard 的能力。
TL;DR:Dashboard 的关键事实
- 页面入口:DashboardPage.tsx —— 管理后台首页,持有唯一的
DndContext、头部操作区、网格与组件库。 - 网格:
DashboardGrid为 12 列 × 70px 行高的 CSS Grid,auto-flow: dense允许后放置的 Widget 回填前面的空隙。 - Widget 注册表:
dashboardWidgetRegistry单例位于 registry.ts,第一方 Widget 在挂载时注册;拥有dashboard.widgets.register权限的插件可贡献更多 Widget。 - 交互:Widget 支持拖拽移动与缩放(列跨度 / 行跨度);拖放目标与缩放预览统一使用
--accent-3作为虚线指示。 - 自定义模式:虚线轮廓 + 底部停靠的
<BlockLibrary>(未使用 Widget 的组件库),由顶部工具栏按钮切换。 - 布局持久化:通过
useDashboardLayout按用户存储到服务端user_preferences表。 - 数据来源:大多数数据类 Widget 从
/admin/api/cms/dashboard/<domain>流式拉取(handleDashboardRoutes分发给各 Widget reader);AI 用量读取/admin/api/ai/audit;Domain 与 Site status 目前是本地状态块。
代码在哪里
Dashboard 的前端代码集中在一个目录下,分层非常清晰:
src/admin/pages/dashboard/ ├── DashboardPage.tsx — 页面入口,DndContext,头部 + 网格 + 组件库 ├── DashboardPage.module.css ├── widgetIcons.ts — 插件 Widget 图标名称解析助手 ├── components/ │ ├── DashboardGrid.tsx — 12 列网格,缩放把手,拖放预览 │ ├── DashboardGrid.module.css — 1px 间隙模式 + 自定义模式过渡 │ ├── BlockLibrary.tsx — 自定义模式下底部停靠的未使用 Widget 坞 │ ├── BlockLibrary.module.css │ ├── OnboardingPanel.tsx — 首次运行设置清单 │ ├── OnboardingPanel.module.css │ ├── LiquidProgressRing.tsx — 动画液体填充圆环(onboarding 完成度) │ └── LiquidProgressRing.module.css ├── hooks/ │ ├── useDashboardLayout.ts — 布局状态(位置 / 尺寸)+ DnD + 缩放数学 │ ├── useDashboardStats.ts — 按 Widget 的 CMS dashboard 端点 hooks │ ├── useDashboardWidgets.ts — 订阅实时 Widget 注册表 │ └── useOnboardingState.ts — onboarding 清单状态 └── widgets/ — 第一方 Widget(每个都是 DashboardWidgetDefinition) ├── ActivityWidget.tsx ├── AiUsageWidget.tsx ├── DomainWidget.tsx ├── MediaWidget.tsx ├── PagesWidget.tsx ├── PluginsWidget.tsx ├── PostsWidget.tsx ├── PublishQueueWidget.tsx ├── StatusWidget.tsx ├── StorageWidget.tsx ├── widgets.module.css — Widget 共享 CSS └── index.ts — registerFirstPartyDashboardWidgets()核心类型与注册表被抽到了框架无关的src/core/dashboard/:
src/core/dashboard/ ├── types.ts — DashboardWidgetDefinition、DashboardWidgetSize ... ├── registry.ts — DashboardWidgetRegistry 单例 ├── iconLookup.ts — Widget 使用的图标助手 └── index.ts — 统一出口(barrel)这里有个值得注意的架构约束:注册表本身放在src/core/dashboard,这样服务端 / SDK 代码可以在不引入 React 的前提下引用它;而useDashboardWidgets这个 React 订阅 hook 放在src/admin/pages/dashboard/hooks/,因为src/core/层被架构门禁(architecture gate)禁止引入运行时 React 依赖。
服务端统计端点位于:
server/handlers/cms/dashboard/ ├── index.ts — 路由处理器 + 端点注册表 ├── types.ts — 所有线上响应形状 + DashboardRequestContext ├── shared.ts — 被 2 个以上 reader 复用的 SQL 与类型转换助手 └── <widget>.ts — 每个 Widget 一个 reader(pages、posts、media、plugins、 publishLineup、activity、storage)网格布局:显式定位 + 固定行高
DashboardGrid是一个 12 列 CSS Grid,行高固定。每个 Widget 单元格通过三个 CSS 变量定位:
--col/--row—— 显式网格放置(持久化)--span: <N>—— 列跨度(3、4、6、8、12)--rows: <N>—— 行跨度(高度为若干行轨道)
核心 CSS 见 DashboardGrid.module.css:
.gridLayout { --row-h: 70px; --gap: 1px; /* 自定义模式下为 16px */ display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: var(--row-h); gap: var(--gap); } .cell { grid-column: var(--col) / span var(--span); grid-row: var(--row) / span var(--rows); background: transparent; /* widget 本体提供表面 */ }两个实现细节值得展开:
- 没有
grid-auto-flow:每个单元格都携带显式的grid-column/grid-row,所以用户可以在卡片之间刻意留出空隙。auto-flow 会把卡片悄悄重新压紧,抹掉用户的有意排版——从 DashboardGrid.module.css 的注释可以看到这正是开发者的明确取舍。 - 行高是单点常量:
GRID_ROW_HEIGHT = 70定义在 useDashboardLayout.ts,与 CSS 的--row-h保持同步。JS 侧的缩放数学(下文详述)直接引用该常量计算行增量,避免了跨文件重复的魔法数字。
自定义模式(Customize mode)
自定义模式把间隙从 1px 加宽到 16px,通过transition: gap 220ms cubic-bezier(0.4, 0, 0.2, 1)动画过渡。网格同时获得一条天蓝色调的虚线轮廓(--accent-3低透明度)作为可操作提示:
.editing { --gap: var(--space-2xl); outline-color: color-mix(in srgb, var(--accent-3) 18%, transparent); min-height: var(--grid-min-height); /* 自定义模式下为拖放预留空行 */ }这个过渡能工作,是因为 CSS Grid 的gap在所有主流浏览器中都可以原生动画;列是1fr,会随间隙插值自动调整宽度,卡片随之平滑重排,无需任何 JS 动画库。
关于实现有个微妙的 React 细节(见 DashboardGrid.tsx):查看 / 自定义两种模式共享同一个<GridSurface>DOM 节点,只是切换.editingclass。如果按模式返回两棵不同的 JSX 树,React 会卸载旧元素再挂载新元素——全新的元素没有可插值的先前 CSS 状态,gap过渡会静默失效。
1px 间隙模式(borderless tile)
每个 Widget 本体是--bg-surface-2(较亮),父级是--bg-surface(较暗)。1px 网格间隙透出父级颜色,形成无边框分隔线。悬停时 Widget 提升到--bg-surface-3——永远不要靠重绘边框来表达交互。
这是borderless-tile-card 模式的规范实现(设计原则见 docs/design.md)。任何需要等价表面的地方都应复用 Widget 基元,而不是重新实现这套模式。
Widget 体系:定义、注册与渲染
每个 Widget 都是一个DashboardWidgetDefinition(见 types.ts):
interface DashboardWidgetDefinition { id: string // 'storage', 'pages', 'activity', ... ownerId: string // 'core' 表示第一方 Widget name: string // 'Storage usage', 'Pages', ... description: string icon: PixelArtIconComponent defaultSize: DashboardWidgetSize // 初始列跨度 tint: DashboardWidgetTint // 'mint' | 'lilac' | 'sky' | 'peach' render: React.ComponentType<DashboardWidgetRendererProps> }尺寸规格(均为 12 的因子):
| Size | 含义 |
|---|---|
| 3 | 四分之一宽 |
| 4 | 三分之一宽 |
| 6 | 半宽 |
| 8 | 三分之二宽 |
| 12 | 全宽 |
类型定义上DashboardWidgetSize = 3 | 4 | 6 | 8 | 12,但DashboardWidgetRendererProps中的span是1..12的通用数字——自定义跨度其实也能工作,只是规范尺寸集合保证设计一致性并让缩放把手易于吸附(见 types.ts 注释)。
tint映射到mint/lilac/sky/peach,由Widget基元转换为--accent-1到--accent-4,用于标题圆点与图表点缀。第一方 Widget 直接导入像素艺术图标组件;插件 Widget 通过 SDK 提供iconName字符串,宿主在注册前经由 widgetIcons.ts 解析为具体组件(未知名称回退到ChartSolidIcon,保证渲染不崩溃)。
每个 Widget 的渲染器都组合共享的<Widget>基元,只从网格接收{ span, editing }。数据类 Widget 通过各自的 hook(usePagesStats、useStorageStats、usePublishLineupStats等)取数,不经过一个聚合式的 dashboard 请求。
第一方 Widget 清单
| id | 注册跨度 | 默认布局 | Tint | 展示内容 |
|---|---|---|---|---|
storage | 6 | 12 × 4 | sky | 磁盘总用量 + 媒体 / 插件 / 数据库细分 |
pages | 3 | 3 × 3 | lilac | 已发布、草稿、定时与近一周页面数 |
posts | 3 | 3 × 3 | peach | 文章总数、分类数、定时数与 28 天柱状图 |
media | 3 | 3 × 3 | peach | 文件数、总字节数与最新缩略图 |
status | 3 | 3 × 3 | mint | 本地站点 / 构建 / 备份 / 插件状态行 |
activity | 4 | 6 × 5 | peach | 基于审计日志的最近管理活动;端点要求audit.read |
publish | 4 | 6 × 5 | sky | 定时发布、最近发布与草稿内容行 |
plugins | 4 | 6 × 5 | mint | 已安装插件数量与生命周期状态行 |
domain | 3 | 6 × 3 | sky | 本地主域名与 HTTPS 验证行 |
ai-usage | 3 | 仅组件库 | lilac | 本月 AI 花费、对话数、顶级 scope 与每日花费迷你走势图 |
其中“注册跨度”是 Widget 的defaultSize,用户从 Block Library 拖出时使用;“默认布局”是新用户在 useDashboardLayout.ts 中DEFAULT_LAYOUT的初始网格;ai-usage虽是第一方 Widget,但刻意只放在 Block Library,不进默认网格。
实际注册代码见 widgets/index.ts,registerFirstPartyDashboardWidgets()由DashboardPage在模块导入时调用一次,通过registered布尔量保证幂等(HMR、测试、懒加载重复导入不会重复注册)。
插件贡献的 Widget
拥有dashboard.widgets.register权限的插件,可以从其 admin 窗口入口通过api.dashboard.widgets.register(...)注册 Widget(SDK 侧类型见 dashboardWidgets.ts)。关键约束:
- 插件 Widget 的
component运行在admin React 应用上下文(不在 QuickJS 沙箱中)——插件服务端代码沙箱化,但 admin / dashboard Widget 在进程内渲染; id必须以插件 id 为前缀(<pluginId>.<rest>),注册表在注册时强制执行(见 registry.ts 的assertValid);iconName由宿主解析为精选的像素艺术图标;- 注册表是
useSyncExternalStore风格的可订阅单例:新安装的插件无需刷新页面即可让 Widget 出现在 Dashboard 上。
插件属下的分析类块(如visitors、top-pages)是插件 Widget,而非第一方 Widget。它们不会种入默认布局;插件注册后用户可从 Block Library 添加,其保存的布局引用插件持有的 id。值得注意:插件被禁用 / 卸载时,unregisterByOwner(ownerId)会在运行时移除其全部 Widget,网格中对应槽位不会留下死块。
拖拽与缩放(Drag and drop)
DashboardPage拥有唯一的DndContext,让两个表面共享同一个 dnd-kit 会话:
- 网格—— 将自己注册为一个 droppable(
GRID_DROP_ID)。每个单元格成为useDraggable的“移动”源,以 widget id 标识。 - BlockLibrary—— 每个预览块注册为
useDraggable,id 形如library:<widgetId>。
页面级onDragEnd处理器区分两类来源:
拖拽来源 → 处理器行为 -----------------------------|---------------------- 现有单元格(widgetId) → 移动 Widget 到落点单元格 组件库块(library:<id>) → 在落点单元格添加 Widget,并从组件库移除在 DashboardPage.tsx 的handleDragEnd中还有一个特殊分支:网格来源的拖拽若落在LIBRARY_DROP_ID上,则调用removeWidget把 Widget 从 Dashboard 移除——这就是“拖回组件库即删除”的手势。
落点预览(Drop preview)
一个半透明幽灵(.dropPreview)跟踪提议的落点单元格。它以绝对定位渲染(不是网格项),因此top/left/width/height可以在单元格之间平滑过渡——CSS Grid 的grid-column-start并非在所有浏览器都可过渡,像素坐标是跨浏览器的最可靠路径。
幽灵只在落点有效时显示:如果提议的单元格与已有 Widget 重叠,dropTarget为null,幽灵隐藏。幽灵消失本身就是“该落点会被拒绝”的信号。
resolveDropTarget(DashboardPage.tsx)还有一个细节:当指针悬停在已占用单元格上时,会在同一列内向下扫描直到找到能容纳该 Widget 的第一个空行(上限 200 行防御性保护)——预览不会因指针路过已有块而闪烁,而是平滑地转移到下方的空位,而那里正是实际落点。预览与提交共用同一个resolveDropTarget函数,保证“所见即所落”。
缩放把手(Resize handles)
每个单元格有 4 个边缘把手 + 1 个角把手(DraggableCell中渲染,见 DashboardGrid.tsx):
┌─────────────────────────┐ │ ┌── top ──┐ │ │ │ │ │ │ left right │ │ │ │ │ │ └─ bottom ┘ [↘] │ ← 角把手 └─────────────────────────┘悬停单元格时把手淡入;悬停把手时更亮。可见的--accent-3中央导轨是视觉提示,实际抓取区域向边缘外延伸 8–14px。
- 左右边缘把手调整列跨度,上下边缘把手调整行跨度;
- 角把手同时调整两个轴,且在重叠的 12×12 区域中优先于边缘把手(
z-index: 11vs10)。
缩放数学在 useDashboardLayout.ts 中吸附为整数列 / 行增量。JS 读取与 CSS 相同的GRID_ROW_HEIGHT/GRID_GAP常量,因此缩放预览精确落在单元格边界上。MIN_COLS = 3、MAX_COLS = 12、MIN_ROWS = 2、MAX_ROWS = 8作为钳制范围。
缩放与移动之后都会运行一次碰撞消解:被移动 / 缩放 Widget 若与其他卡片重叠,重叠的兄弟会被向下推(row += 冲突卡片高度)直到布局无重叠;被操作的 Widget 本身钉死在用户放置的位置,绝不移走。
拖拽中的降级细节
DashboardPage.tsx 关闭了 dnd-kit 的autoScroll——因为默认的视口边缘自动滚动会在用户把卡片拖向底部“拖出即删除”药丸时误触发,页面会在指针下滑动。Dashboard 表面在正常视口下放得下,关闭自动滚动是安全的取舍。
同时,拖拽中的原单元格变为opacity: 0的占位(保留网格槽位,兄弟元素不回流),真正的拖拽视觉由DragOverlay渲染,否则用户会同时看到原卡片与浮层“一分为二”的错觉(见 DashboardGrid.module.css 的.dragging)。
布局持久化:按用户、跨设备
useDashboardLayout(...)是 Widget 位置、尺寸与顺序的单一事实来源。
| 动作 | 写入内容 |
|---|---|
| 移动 Widget | { widgetId, col, row } |
| 缩放 Widget | { widgetId, span, rows } |
| 从组件库添加 | 追加DashboardItem到用户布局 |
| 移除 Widget | 从布局中移除;Widget 回到组件库 |
布局持久化在服务端user_preferences表,键为dashboard-layout,端点为PUT /admin/api/cms/me/preferences/dashboard-layout(由handleUserPreferencesRoutes处理)。这是按用户而非按站点——每个用户拥有自己的 Dashboard 布局。
协议与校验(见 userPreferences.ts):
Wire format: /admin/api/cms/me/preferences/:key • GET → { value: T } 或 { value: null }(未设置——客户端回退到默认) • PUT → { value: T } upsert • DELETE → 重置为默认值得强调的是,DashboardLayoutSchema中col/row/rows在线上是可选的——早期持久化格式(v1 仅 size、v2 size+rows)可能仍残留在迁移窗口内的 localStorage 中,hook 的normalizeItem会把缺失位置规范化为合理默认值。干净安装后始终携带全部四个字段。
默认布局
新用户从一个默认布局开始(第一方 Widget 已预置)。useDashboardLayout(...)立即渲染DEFAULT_LAYOUT,仅当存在保存的dashboard-layout偏好时才替换进去。
DEFAULT_LAYOUT的完整定义(useDashboardLayout.ts):
const DEFAULT_LAYOUT: DashboardLayout = { items: [ { id: 'storage', col: 1, row: 1, size: 12, rows: 4 }, { id: 'pages', col: 1, row: 5, size: 3, rows: 3 }, { id: 'posts', col: 4, row: 5, size: 3, rows: 3 }, { id: 'media', col: 7, row: 5, size: 3, rows: 3 }, { id: 'status', col: 10, row: 5, size: 3, rows: 3 }, { id: 'activity', col: 1, row: 8, size: 6, rows: 5 }, { id: 'publish', col: 7, row: 8, size: 6, rows: 5 }, { id: 'plugins', col: 1, row: 13, size: 6, rows: 5 }, { id: 'domain', col: 7, row: 13, size: 6, rows: 3 }, ], onboardingDismissed: false, libraryHeight: LIBRARY_DEFAULT_HEIGHT, // 340 }默认布局只使用宿主无条件内置的第一方 Widget id。插件 Widget 不入默认网格——安装插件只是把 Widget 加入注册表,用户需要从“Add block”选择器拖入(或插件在安装后通过布局 API 持久化一次布局更新)。理由是:一个引用插件 id 的默认布局会在全新安装(插件尚未就绪)时留下视觉空洞。
乐观更新与防抖保存
useDashboardLayout的保存流有三个阶段:
- 挂载:先渲染默认布局(无白屏),同时并行发出 GET。若服务端有保存的布局则到达后替换;若从未保存过(404),已渲染的默认布局就是答案。
- 变更:立即乐观更新本地状态,并调度一个防抖 PUT(
SAVE_DEBOUNCE_MS = 600)。拖拽缩放期间的一连串变更会合并为一次网络调用。 - 卸载刷新:卸载时刷新任何待处理的保存,快速“变更后立即导航”不会丢失最后一次改动。
关键门控:只在初始 GET 完成后才保存,否则初始渲染会把默认布局覆盖到刚拉取的服务端状态上。布局中还携带onboardingDismissed与libraryHeight(Block Library 面板高度,钳制在 200–720px),一并持久化。
统计端点:按域拆分的扇出架构
Dashboard 把数据请求扇出为/admin/api/cms/dashboard/<domain>下的按域端点。每个 Widget 拥有一个 hook(usePagesStats、useMediaStats、useStorageStats……),恰好命中一个端点,因此 Widget 之间独立解锁,最慢的 reader(Activity)不会拖住其他部分:
| 端点 | Hook | 能力门控 | 响应形状(摘要) |
|---|---|---|---|
/dashboard/pages | usePagesStats | 已认证用户 | { total, published, drafts, scheduled, deltaPublishedThisWeek } |
/dashboard/posts | usePostsStats | 已认证用户 | { total, categories, scheduled, daily28 } |
/dashboard/media | useMediaStats | media.read | { count, totalBytes, latestThumbs[] } |
/dashboard/plugins | usePluginsStats | plugins.read | { total, active, disabled, errored, rows[] } |
/dashboard/storage | useStorageStats | 已认证用户 | { imageBytes, videoBytes, documentBytes, pluginBytes, databaseBytes, totalBytes, dialect } |
/dashboard/publish-lineup | usePublishLineupStats | 已认证用户 | { rows: [{ id, path, status, at }] } |
/dashboard/activity | useRecentActivityStats | audit.read | { rows: [{ id, action, actor, targetCode, targetText, createdAt }] } |
非 CMS 类第一方 Widget:
| Widget | 数据源 | 说明 |
|---|---|---|
ai-usage | listAiAudit(startOfMonthIso())->/admin/api/ai/audit | 将缺失ai.audit.read时的 403 映射为无权限空状态。 |
domain | 本地组件行 | 展示当前占位的主域名 / HTTPS 行。 |
status | 本地组件行 | 展示当前占位的站点 / 构建 / 备份 / 插件状态行。 |
服务端路由见 server/handlers/cms/dashboard/index.ts:DASHBOARD_READERS注册表把段名映射到 reader 函数 + 能力门控,handleDashboardRoutes在调用 reader 前执行requireCapability。能力为null的端点回退到“已认证用户”这一底线。
这套设计的收益很明确(见该文件头部注释):客户端从 Widget hooks并行发出所有端点请求,廉价域(Pages 两个计数,约 10ms)先返回,昂贵的 Activity 端点(audit_events 扫描 + 50 行投影,约 150ms)后到,Dashboard 渐进填充而非卡在最慢的 Widget 上;同时,不在用户网格中的 Widget 永远不会触发其端点调用。
时区感知的按日分桶
每个 dashboard 统计请求都携带?tz=<IANA>查询参数(取自浏览器Intl.DateTimeFormat().resolvedOptions().timeZone)。服务端在handleDashboardRoutes中通过resolveTimeZone(server/time.ts)解析并把时区线程化进DashboardRequestContext.timeZone。按日历日分桶的 reader(目前是 Posts 直方图)使用localDayKeyFactory(ctx.timeZone)把每个published_at映射为本地日键而非 UTC 日期——23:30 本地时间发布的文章会落进正确的柱中,而不是滚进下一个 UTC 日。
localDayKeyFactory复用Intl.DateTimeFormat(en-CAlocale 保证键格式恒为YYYY-MM-DD),并在服务端 JS而非 SQL 中计算日键——因为分桶边界取决于查看者的时区,数据库并不知道。这与db-postgres-isms架构门禁(禁止 Postgres 专有的::text类型转换)互相印证:可移植的日期截断 SQL 在两个方言上都很痛苦,而按日分桶在 JS 里既跨方言又正确(见 posts.ts 注释)。
不按时间戳分桶的端点同样接收?tz=参数但忽略它。
存储尺寸:三源合一
/dashboard/storage是唯一结合了 SQL 聚合、文件系统遍历与方言感知数据库探测的端点(storage.ts):
imageBytes/videoBytes/documentBytes—— 单个 SQL 遍历条件求和:coalesce(sum(case when mime_type like 'image/%' then size_bytes else 0 end), 0)(以及对应的video/%与兜底桶),作用于未删除的media_assets。任何非image/*/video/*的内容——音频、PDF、压缩包、mime_type 为 NULL 的行——都累加进documentBytes,三个子计数器保证加总等于媒体总量。pluginBytes—— 对<uploadsDir>/plugins/做递归fs.stat遍历(sumDirectoryBytes;目录不存在返回 0,单条目错误静默计零——这是用量估算,不是取证审计)。databaseBytes—— SQLite 对.db文件及其-wal/-shm副作用文件(存在时)stat;Postgres 执行select pg_database_size(current_database()),因为宿主机进程无法直接 stat PG 的磁盘文件。dialect——db.dialect原样输出,让 Widget 标题可显示 “SQLite” / “Postgres”。
没有配额——自托管的 Instatic 从不施加人为磁盘上限,因此 Widget 展示真实用量并把细分条拉满全宽。
客户端取数机制
每个 CMS hook 在挂载时通过useAsyncResource+apiRequest取数,在 JSON 边界用 TypeBox schema 校验响应,发送查看者的tz查询参数,卸载时中止请求,失败时保持 skeleton / 空状态(useDashboardStats.ts)。schema 使用additionalProperties: true的宽松对象,服务端添加性变更不会触发边界校验而清空 Widget。
不存在共享的 dashboard 聚合请求;头部RangeTabs状态目前不改变第一方端点查询——第一方 Widget 的作用域固定在每个 hook 内(本周、28 天、本月等)。
Onboarding 面板
OnboardingPanel是显示在 Dashboard 顶部的首次运行清单(OnboardingPanel.tsx):
- 设置站点身份(Set site identity)
- 选择 Core Framework 导入
- 创建第一个页面
- 安装一个插件
- 邀请团队成员
状态位于useOnboardingState(...)(useOnboardingState.ts),通过Promise.allSettled并发读取当前站点、已安装插件与用户列表;单个端点失败软失败为对应步骤“未开始”,不会让 Dashboard 崩溃。步骤判定逻辑:
- 站点身份:
site.name非默认 “Untitled Site” 或已设置 favicon; - Framework 导入:
site.settings.framework已填充。默认active(敦促用户做出明确决定),选定模式后翻转完成; - 第一个页面:站点页面数 ≥ 2(种子 Home 页不算完成条件);
- 插件:任意插件已安装;
- 团队:用户表人数 > 1。
面板按用户可关闭,并与 dashboard 布局偏好(dashboard-layout)一起持久化;useDashboardLayout.restoreOnboarding()把同一偏好标志翻回可见。
一个值得注意的联动:onboarding 的 Framework 导入直接通过cmsAdapter写入settings.framework(没有实时编辑器 / reconcile),而 Site 编辑器的 store 是会话级单例,usePersistence的挂载加载在站点已水合时会提前返回——因此成功的导入会派发CMS_SITE_RELOAD_EVENT让编辑器重新拉取。这正是 OnboardingPanel.test.tsx 所回归覆盖的行为。
Cookbook:动手扩展 Dashboard
注册一个第一方 Widget
// src/admin/pages/dashboard/widgets/MyWidget.tsx import { type DashboardWidgetDefinition, type DashboardWidgetRendererProps, } from '@core/dashboard' import { ChartSolidIcon } from 'pixel-art-icons/icons/chart-solid' import { Widget } from '@ui/components/Widget' function MyWidgetBody({ span, editing }: DashboardWidgetRendererProps) { return ( <Widget widgetId="my-stat" title="My stat" icon={ChartSolidIcon} tint="sky" span={span} editing={editing} > <div>42</div> </Widget> ) } export const MyWidget: DashboardWidgetDefinition = { id: 'my-stat', ownerId: 'core', name: 'My stat', description: 'Custom stat tile', defaultSize: 4, tint: 'sky', icon: ChartSolidIcon, render: MyWidgetBody, }在 widgets/index.ts 中注册:
import { MyWidget } from './MyWidget' import { dashboardWidgetRegistry } from '@core/dashboard' export function registerFirstPartyDashboardWidgets() { // ... 既有 widgets dashboardWidgetRegistry.register(MyWidget) }就这些。用户会在 BlockLibrary 中看到它;拖入网格后布局即持久化。
注意:icon必须是直接导入的像素艺术图标组件(from 'pixel-art-icons/icons/<name>'),不能是懒加载的 Icon 包装组件(见 types.ts 注释)。
注册一个插件 Widget
拥有dashboard.widgets.register权限的插件从其 admin 窗口入口通过api.dashboard.widgets.register(...)注册 Widget(权限声明见 capabilities.ts):
api.dashboard.widgets.register({ id: 'acme.analytics.pageviews', // 必须以插件 id 为前缀 name: 'Page views', description: 'Trailing 30-day views', iconName: 'trending-up', // 由宿主解析为像素艺术图标 defaultSize: 4, tint: 'sky', component: PageViewsWidget, // 普通 React 组件,组合宿主 <Widget> 基元 })该component运行在admin React 应用中(不在 QuickJS 沙箱内)。插件服务端代码沙箱化;插件 Dashboard Widget 不沙箱化。
按能力门控 Widget 数据
Dashboard Widget 定义没有requires字段。敏感数据在供给 Widget 的端点处门控:
const DASHBOARD_READERS = { 'activity': { reader: readRecentActivity, capability: 'audit.read' }, }handleDashboardRoutes在调用 reader 前执行requireCapability。Widget hook 把失败请求视为非致命空 / skeleton 状态,因此没有该能力的用户不会收到受保护的有效载荷。这也是一个安全修复:Activity 端点(含 actor 显示名、邮箱 gravatar、动作与目标)曾是每个已认证用户都能经 Dashboard 读到的审计级数据,现在与专用审计端点同门控(见 server/handlers/cms/dashboard/index.ts 注释,标注为 A2 修复)。
为网格新增一个尺寸
尺寸被约束为3 | 4 | 6 | 8 | 12(12 的因子)。新增一个值:
- 更新 types.ts 中的
DashboardWidgetSize。 - 更新 BlockLibrary 的预览块(每个库块展示其
defaultSize)。 - 如果新尺寸需要特殊处理,更新 useDashboardLayout.ts 的网格数学(通常不需要——CSS Grid 自动处理)。
回退到默认布局
hook 没有页内重置控件。它每次挂载都从DEFAULT_LAYOUT开始,若存在dashboard-layout偏好则替换为存储布局。清除该用户偏好(DELETE /admin/api/cms/me/preferences/dashboard-layout)即可让下一次挂载停留在种子默认布局上。
从库添加时的放置行为
addWidget有两种路径(useDashboardLayout.ts):
- 提供
col/row(从库拖拽或程序化放置):落在指定单元格,碰撞消解把重叠兄弟下推; - 两者都省略(点击添加):追加到所有既有 Widget 之下的第一个空行——底部永远有空位,无需重叠检查,也不会推挤任何卡片。
禁止模式(Forbidden patterns)
| 模式 | 应使用 |
|---|---|
| 手动重新实现 borderless-tile-card 外观 | <Widget tint="..."> |
用--bg-body(纯黑)作 Widget 本体填充 | --bg-surface-2—— 间隙透出父级颜色 |
| 悬停改边框而非色调 | 背景色调提升(-surface-2→-3) |
| 发明新尺寸(如 5 列) | 保持 12 因子网格尺寸 |
| 通过 editor store 派发 dashboard 数据 | 使用 useDashboardStats.ts 的按 Widget hooks —— dashboard 自包含 |
| 给 Widget 添加页面专属 UI | Widget 只做只读 KPI / 活动展示;编辑请用工作区 |
| 在默认布局之外硬编码 Widget 位置 | 加入DEFAULT_LAYOUT(useDashboardLayout.ts);用户可自行移动 |
在 Widget 内部读取useEditorStore | dashboard 在 admin shell 中而非编辑器中——这里没有挂载 editor store |
这些约束背后有架构测试兜底:如 css-token-policy.test.ts、noTailwindUtilities.test.ts 与 button-primitive-usage.test.ts 确保样式令牌、工具类与按钮基元的使用边界不被破坏。
相关文档与源码索引
- docs/architecture.md —— 系统总览(
/admin/dashboard工作区) - docs/editor.md —— 更广的 admin shell 上下文
- docs/design.md —— borderless-tile-card 设计原则
- docs/reference/ui-primitives.md ——
Widget、WidgetList、LiquidProgressRing、图表 - docs/reference/design-tokens.md ——
--accent-*、--bg-surface-*设计令牌 - 事实来源文件:
- DashboardPage.tsx —— 页面入口
- DashboardGrid.tsx 与 DashboardGrid.module.css —— 规范网格实现
- widgets/index.ts —— 第一方注册
- registry.ts —— 注册表单例
- types.ts ——
DashboardWidgetDefinition - useDashboardLayout.ts —— 布局状态 + DnD
- useDashboardStats.ts —— 统计取数
- server/handlers/cms/dashboard/index.ts ——
/admin/api/cms/dashboard路由处理器 + 端点注册表 - server/handlers/cms/dashboard/types.ts —— 每个响应形状 +
DashboardRequestContext - server/handlers/cms/dashboard/posts.ts —— Posts Widget reader(时区感知直方图)
- server/time.ts ——
resolveTimeZone+localDayKeyFactory(共享按日分桶工具)
- 结构门禁:
- css-token-policy.test.ts
- noTailwindUtilities.test.ts
- button-primitive-usage.test.ts
总结
Instatic 的 Dashboard 是一套设计克制、边界清晰的组件化系统:DashboardGrid用 12 列显式定位网格承载所有 Widget;dashboardWidgetRegistry让第一方与插件 Widget 通过同一套DashboardWidgetDefinition协议共存;useDashboardLayout以乐观更新 + 防抖保存把布局按用户持久化到user_preferences;服务端按域拆分的统计端点配合时区感知的分桶与能力门控,让数据渲染既渐进又安全。理解这条从 CSS 变量、React 状态到服务端 reader 的完整链路后,新增一个 Dashboard Widget——无论是第一方统计块还是插件分析块——都只是“一个定义 + 一行注册”的工程量。
【免费下载链接】InstaticThe open-source alternative to Webflow, Framer and WordPress. Agentic self-hosted visual CMS outputting clean static pages. Users, roles, plugins, content, database, it's all there.项目地址: https://gitcode.com/GitHub_Trending/in/Instatic
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考