Instatic 管理后台 Dashboard:12 列可配置 Widget 网格的设计与实现
2026/9/16 13:20:05 网站建设 项目流程

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 本体提供表面 */ }

两个实现细节值得展开:

  1. 没有grid-auto-flow:每个单元格都携带显式的grid-column/grid-row,所以用户可以在卡片之间刻意留出空隙。auto-flow 会把卡片悄悄重新压紧,抹掉用户的有意排版——从 DashboardGrid.module.css 的注释可以看到这正是开发者的明确取舍。
  2. 行高是单点常量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中的span1..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(usePagesStatsuseStorageStatsusePublishLineupStats等)取数,不经过一个聚合式的 dashboard 请求

第一方 Widget 清单

id注册跨度默认布局Tint展示内容
storage612 × 4sky磁盘总用量 + 媒体 / 插件 / 数据库细分
pages33 × 3lilac已发布、草稿、定时与近一周页面数
posts33 × 3peach文章总数、分类数、定时数与 28 天柱状图
media33 × 3peach文件数、总字节数与最新缩略图
status33 × 3mint本地站点 / 构建 / 备份 / 插件状态行
activity46 × 5peach基于审计日志的最近管理活动;端点要求audit.read
publish46 × 5sky定时发布、最近发布与草稿内容行
plugins46 × 5mint已安装插件数量与生命周期状态行
domain36 × 3sky本地主域名与 HTTPS 验证行
ai-usage3仅组件库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 上。

插件属下的分析类块(如visitorstop-pages)是插件 Widget,而非第一方 Widget。它们不会种入默认布局;插件注册后用户可从 Block Library 添加,其保存的布局引用插件持有的 id。值得注意:插件被禁用 / 卸载时,unregisterByOwner(ownerId)会在运行时移除其全部 Widget,网格中对应槽位不会留下死块。


拖拽与缩放(Drag and drop)

DashboardPage拥有唯一的DndContext,让两个表面共享同一个 dnd-kit 会话:

  1. 网格—— 将自己注册为一个 droppable(GRID_DROP_ID)。每个单元格成为useDraggable的“移动”源,以 widget id 标识。
  2. 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 重叠,dropTargetnull,幽灵隐藏。幽灵消失本身就是“该落点会被拒绝”的信号

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 = 3MAX_COLS = 12MIN_ROWS = 2MAX_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 → 重置为默认

值得强调的是,DashboardLayoutSchemacol/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的保存流有三个阶段:

  1. 挂载:先渲染默认布局(无白屏),同时并行发出 GET。若服务端有保存的布局则到达后替换;若从未保存过(404),已渲染的默认布局就是答案。
  2. 变更:立即乐观更新本地状态,并调度一个防抖 PUT(SAVE_DEBOUNCE_MS = 600)。拖拽缩放期间的一连串变更会合并为一次网络调用。
  3. 卸载刷新:卸载时刷新任何待处理的保存,快速“变更后立即导航”不会丢失最后一次改动。

关键门控:只在初始 GET 完成后才保存,否则初始渲染会把默认布局覆盖到刚拉取的服务端状态上。布局中还携带onboardingDismissedlibraryHeight(Block Library 面板高度,钳制在 200–720px),一并持久化。


统计端点:按域拆分的扇出架构

Dashboard 把数据请求扇出为/admin/api/cms/dashboard/<domain>下的按域端点。每个 Widget 拥有一个 hook(usePagesStatsuseMediaStatsuseStorageStats……),恰好命中一个端点,因此 Widget 之间独立解锁,最慢的 reader(Activity)不会拖住其他部分:

端点Hook能力门控响应形状(摘要)
/dashboard/pagesusePagesStats已认证用户{ total, published, drafts, scheduled, deltaPublishedThisWeek }
/dashboard/postsusePostsStats已认证用户{ total, categories, scheduled, daily28 }
/dashboard/mediauseMediaStatsmedia.read{ count, totalBytes, latestThumbs[] }
/dashboard/pluginsusePluginsStatsplugins.read{ total, active, disabled, errored, rows[] }
/dashboard/storageuseStorageStats已认证用户{ imageBytes, videoBytes, documentBytes, pluginBytes, databaseBytes, totalBytes, dialect }
/dashboard/publish-lineupusePublishLineupStats已认证用户{ rows: [{ id, path, status, at }] }
/dashboard/activityuseRecentActivityStatsaudit.read{ rows: [{ id, action, actor, targetCode, targetText, createdAt }] }

非 CMS 类第一方 Widget:

Widget数据源说明
ai-usagelistAiAudit(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.DateTimeFormaten-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 的因子)。新增一个值:

  1. 更新 types.ts 中的DashboardWidgetSize
  2. 更新 BlockLibrary 的预览块(每个库块展示其defaultSize)。
  3. 如果新尺寸需要特殊处理,更新 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 添加页面专属 UIWidget 只做只读 KPI / 活动展示;编辑请用工作区
在默认布局之外硬编码 Widget 位置加入DEFAULT_LAYOUT(useDashboardLayout.ts);用户可自行移动
在 Widget 内部读取useEditorStoredashboard 在 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 ——WidgetWidgetListLiquidProgressRing、图表
  • 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询