ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Instatic 管理后台 Dashboard:12 列可配置 Widget 网格的设计与实现

Instatic 管理后台 Dashboard:12 列可配置 Widget 网格的设计与实现 Instatic 管理后台 Dashboard12 列可配置 Widget 网格的设计与实现【免费下载链接】InstaticThe open-source alternative to Webflow, Framer and WordPress. Agentic self-hosted visual CMS outputting clean static pages. Users, roles, plugins, content, database, its 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;DRDashboard 的关键事实页面入口DashboardPage.tsx —— 管理后台首页持有唯一的DndContext、头部操作区、网格与组件库。网格DashboardGrid为 12 列 × 70px 行高的 CSS Gridauto-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 readerAI 用量读取/admin/api/ai/auditDomain 与 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 一个 readerpages、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查看 / 自定义两种模式共享同一个GridSurfaceDOM 节点只是切换.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.tsinterface 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.ComponentTypeDashboardWidgetRendererProps }尺寸规格均为 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 通过各自的 hookusePagesStats、useStorageStats、usePublishLineupStats等取数不经过一个聚合式的 dashboard 请求。第一方 Widget 清单id注册跨度默认布局Tint展示内容storage612 × 4sky磁盘总用量 媒体 / 插件 / 数据库细分pages33 × 3lilac已发布、草稿、定时与近一周页面数posts33 × 3peach文章总数、分类数、定时数与 28 天柱状图media33 × 3peach文件数、总字节数与最新缩略图status33 × 3mint本地站点 / 构建 / 备份 / 插件状态行activity46 × 5peach基于审计日志的最近管理活动端点要求audit.readpublish46 × 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.tsregisterFirstPartyDashboardWidgets()由DashboardPage在模块导入时调用一次通过registered布尔量保证幂等HMR、测试、懒加载重复导入不会重复注册。插件贡献的 Widget拥有dashboard.widgets.register权限的插件可以从其 admin 窗口入口通过api.dashboard.widgets.register(...)注册 WidgetSDK 侧类型见 dashboardWidgets.ts。关键约束插件 Widget 的component运行在admin React 应用上下文不在 QuickJS 沙箱中——插件服务端代码沙箱化但 admin / dashboard Widget 在进程内渲染id必须以插件 id 为前缀pluginId.rest注册表在注册时强制执行见 registry.ts 的assertValidiconName由宿主解析为精选的像素艺术图标注册表是useSyncExternalStore风格的可订阅单例新安装的插件无需刷新页面即可让 Widget 出现在 Dashboard 上。插件属下的分析类块如visitors、top-pages是插件 Widget而非第一方 Widget。它们不会种入默认布局插件注册后用户可从 Block Library 添加其保存的布局引用插件持有的 id。值得注意插件被禁用 / 卸载时unregisterByOwner(ownerId)会在运行时移除其全部 Widget网格中对应槽位不会留下死块。拖拽与缩放Drag and dropDashboardPage拥有唯一的DndContext让两个表面共享同一个 dnd-kit 会话网格—— 将自己注册为一个 droppableGRID_DROP_ID。每个单元格成为useDraggable的“移动”源以 widget id 标识。BlockLibrary—— 每个预览块注册为useDraggableid 形如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幽灵隐藏。幽灵消失本身就是“该落点会被拒绝”的信号。resolveDropTargetDashboardPage.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.tsWire format: /admin/api/cms/me/preferences/:key • GET → { value: T } 或 { value: null }未设置——客户端回退到默认 • PUT → { value: T } upsert • DELETE → 重置为默认值得强调的是DashboardLayoutSchema中col/row/rows在线上是可选的——早期持久化格式v1 仅 size、v2 sizerows可能仍残留在迁移窗口内的 localStorage 中hook 的normalizeItem会把缺失位置规范化为合理默认值。干净安装后始终携带全部四个字段。默认布局新用户从一个默认布局开始第一方 Widget 已预置。useDashboardLayout(...)立即渲染DEFAULT_LAYOUT仅当存在保存的dashboard-layout偏好时才替换进去。DEFAULT_LAYOUT的完整定义useDashboardLayout.tsconst 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已渲染的默认布局就是答案。变更立即乐观更新本地状态并调度一个防抖 PUTSAVE_DEBOUNCE_MS 600。拖拽缩放期间的一连串变更会合并为一次网络调用。卸载刷新卸载时刷新任何待处理的保存快速“变更后立即导航”不会丢失最后一次改动。关键门控只在初始 GET 完成后才保存否则初始渲染会把默认布局覆盖到刚拉取的服务端状态上。布局中还携带onboardingDismissed与libraryHeightBlock Library 面板高度钳制在 200–720px一并持久化。统计端点按域拆分的扇出架构Dashboard 把数据请求扇出为/admin/api/cms/dashboard/domain下的按域端点。每个 Widget 拥有一个 hookusePagesStats、useMediaStats、useStorageStats……恰好命中一个端点因此 Widget 之间独立解锁最慢的 readerActivity不会拖住其他部分端点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 类第一方 WidgetWidget数据源说明ai-usagelistAiAudit(startOfMonthIso())-/admin/api/ai/audit将缺失ai.audit.read时的 403 映射为无权限空状态。domain本地组件行展示当前占位的主域名 / HTTPS 行。status本地组件行展示当前占位的站点 / 构建 / 备份 / 插件状态行。服务端路由见 server/handlers/cms/dashboard/index.tsDASHBOARD_READERS注册表把段名映射到 reader 函数 能力门控handleDashboardRoutes在调用 reader 前执行requireCapability。能力为null的端点回退到“已认证用户”这一底线。这套设计的收益很明确见该文件头部注释客户端从 Widget hooks并行发出所有端点请求廉价域Pages 两个计数约 10ms先返回昂贵的 Activity 端点audit_events 扫描 50 行投影约 150ms后到Dashboard 渐进填充而非卡在最慢的 Widget 上同时不在用户网格中的 Widget 永远不会触发其端点调用。时区感知的按日分桶每个 dashboard 统计请求都携带?tzIANA查询参数取自浏览器Intl.DateTimeFormat().resolvedOptions().timeZone。服务端在handleDashboardRoutes中通过resolveTimeZoneserver/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.tsimageBytes/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副作用文件存在时statPostgres 执行select pg_database_size(current_database())因为宿主机进程无法直接 stat PG 的磁盘文件。dialect——db.dialect原样输出让 Widget 标题可显示 “SQLite” / “Postgres”。没有配额——自托管的 Instatic 从不施加人为磁盘上限因此 Widget 展示真实用量并把细分条拉满全宽。客户端取数机制每个 CMS hook 在挂载时通过useAsyncResourceapiRequest取数在 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” 或已设置 faviconFramework 导入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 widgetIdmy-stat titleMy stat icon{ChartSolidIcon} tintsky span{span} editing{editing} div42/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.tsapi.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 | 1212 的因子。新增一个值更新 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 添加页面专属 UIWidget 只做只读 KPI / 活动展示编辑请用工作区在默认布局之外硬编码 Widget 位置加入DEFAULT_LAYOUTuseDashboardLayout.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 ——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 ——DashboardWidgetDefinitionuseDashboardLayout.ts —— 布局状态 DnDuseDashboardStats.ts —— 统计取数server/handlers/cms/dashboard/index.ts ——/admin/api/cms/dashboard路由处理器 端点注册表server/handlers/cms/dashboard/types.ts —— 每个响应形状 DashboardRequestContextserver/handlers/cms/dashboard/posts.ts —— Posts Widget reader时区感知直方图server/time.ts ——resolveTimeZonelocalDayKeyFactory共享按日分桶工具结构门禁css-token-policy.test.tsnoTailwindUtilities.test.tsbutton-primitive-usage.test.ts总结Instatic 的 Dashboard 是一套设计克制、边界清晰的组件化系统DashboardGrid用 12 列显式定位网格承载所有 WidgetdashboardWidgetRegistry让第一方与插件 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, its all there.项目地址: https://gitcode.com/GitHub_Trending/in/Instatic创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表