资讯详情

SpacetimeDB 实时活动动态(Activity Feed)设计与实现指南:以协同白板 Paint App 为实战案例

📅 2026/9/14 18:53:44 | 华诺云谱 👁 阅读
SpacetimeDB 实时活动动态(Activity Feed)设计与实现指南:以协同白板 Paint App 为实战案例
SpacetimeDB 实时活动动态Activity Feed设计与实现指南以协同白板 Paint App 为实战案例【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB本文围绕仓库中 11_activity_feed.md 这份功能规格文档展开讲解如何在 SpacetimeDB 驱动的实时协同白板应用中实现一套“可折叠侧栏 实时活动日志”的 Activity Feed包括服务端活动事件表的建模、统一写入与 100 条裁剪策略、各 reducer 的事件埋点以及客户端基于订阅的实时渲染。读完你可以直接照搬这套「事件表 logActivity 辅助函数 useTable 订阅渲染」的通用模式为自己的多人在线应用添加活动动态。一、功能规格速览Activity Feed 到底要做什么11_activity_feed.md是tools/llm-oneshot/apps/paint-app/prompts/模块化提示词体系中 16 个功能积木之一对应组合提示词 composed/10_activity.md 的第 10 级 activity。它描述的 Activity Feed 由 6 条需求组成#需求关键点1可折叠侧边面板Collapsible side panel不占用画布主区域2实时活动日志展示如 Alice added a rectangle、Bob erased some strokes、Carol joined3时间戳 头像色每条记录带时间戳并用该用户头像颜色标识4点击跳转点击条目可将画布平移到事件发生位置适用时5实时更新动作一旦发生日志立即刷新到所有协作者6容量上限仅保留最近 100 条更早的自动移除这份规格本身是给 LLM 生成代码用的“需求说明书”而仓库里恰好保存了两份由 LLM 生成的真实实现快照。本文以较新的paint-app-20260112-154500快照为基准逐条对照规格讲解其落地方式。该应用的整体架构约定见 language/typescript-spacetime.md后端为 SpacetimeDB TypeScript 模块模块名paint-app客户端为 React Vite TypeScript。二、数据模型一张 activity_entry 表承载全部活动事件Activity Feed 的基石是服务端一张公开可订阅的表。快照中的定义位于 backend/spacetimedb/src/schema.tsexport const ActivityEntry table( { name: activity_entry, public: true, indexes: [ { name: activity_entry_canvas_id, algorithm: btree, columns: [canvasId], }, ], }, { id: t.u64().primaryKey().autoInc(), canvasId: t.u64(), userIdentity: t.identity(), action: t.string(), // joined | left | added_stroke | added_shape | etc. details: t.string().optional(), // JSON with extra info locationX: t.f64().optional(), locationY: t.f64().optional(), createdAt: t.timestamp(), } );各字段的作用与规格需求的对应关系idu64 自增主键稳定唯一标识客户端渲染列表时用作 React key。canvasIdu64活动归属的画布。由于一个模块里可能同时存在多个画布必须按画布隔离动态表上建有名为activity_entry_canvas_id的btree 索引供按画布过滤filter时快速定位。userIdentityidentity触发事件的用户。规格要求每条记录展示用户名 头像颜色而用户名displayName与头像色avatarColor存在独立的user表因此这里只存身份标识渲染时再关联查询。actionstring动作类型规格中的可读文案added a rectangle、erased some strokes、joined均由它派生详见第四节。detailsstring可选携带额外信息的 JSON 字符串例如保存版本时的版本名。locationX/locationYf64可选事件发生的画布坐标是规格第 4 条点击条目跳转到对应位置的数据支撑详见第六节。createdAttimestamp事件时间戳直接取 reducer 执行时的ctx.timestamp微秒级 Unix 时间戳规格第 3 条时间戳即来源于此。三、统一写入入口logActivity() 与仅保留 100 条的裁剪策略规格第 6 条要求Keeps last 100 entries (older entries auto-removed)。实现没有把裁剪逻辑散落在各 reducer 中而是收敛到一个公共辅助函数 logActivity()function logActivity( ctx: any, canvasId: bigint, userIdentity: any, action: string, details?: string, x?: number, y?: number ): void { ctx.db.activityEntry.insert({ id: 0n, canvasId, userIdentity, action, details, locationX: x, locationY: y, createdAt: ctx.timestamp, }); // Trim to last 100 entries const entries: any[] []; for (const entry of ctx.db.activityEntry.activity_entry_canvas_id.filter( canvasId )) { entries.push(entry); } entries.sort((a, b) Number(b.createdAt.microsSinceUnixEpoch - a.createdAt.microsSinceUnixEpoch) ); for (let i 100; i entries.length; i) { ctx.db.activityEntry.id.delete(entries[i].id); } }这个函数体现了两个值得借鉴的设计点先插后裁、同事务完成先插入新事件再对当前画布的全部活动记录按createdAt降序排序从下标 100 开始逐条删除。由于 SpacetimeDB 的 reducer 是确定性的原子执行单元插入与裁剪要么同时生效、要么都不生效因此表内数据量永远不会超过 100 当前画布一批事件也不存在客户端已拉取但服务端未清理的中间态。裁剪粒度是画布而非全局过滤条件限定在canvasId上多画布之间互不影响——每个画布各自维护最近 100 条而不是所有画布共用一个 100 条预算。四、事件埋点哪些动作会写进活动日志规格示例中的三类事件加入画布、添加矩形、擦除笔迹以及更多动作都在对应 reducer 内调用logActivity()写入。从 backend/spacetimedb/src/index.ts 可以梳理出完整的事件清单触发点reducer / 生命周期action 值是否携带坐标对应规格示例join_canvas首次加入joined否Carol joinedleave_canvas/clientDisconnectedleft否Carol leftadd_stroke笔刷drew否Alice drewadd_stroke橡皮擦erased否Bob erased some strokesadd_shapeadded_rectangle/added_ellipse/added_line/added_arrow是x, yAlice added a rectangleadd_text_elementadded_text/added_sticky是x, y—add_commentadded_comment是x, y—save_versionsaved_version否details 带版本名—restore_versionrestored_version否—clear_canvascleared_canvas否—几个值得注意的实现细节加入/离开事件与画布访问控制耦合join_canvas只在首次建立 presence 记录时才记录joined重复进入同一画布只更新 presence 状态不再刷屏式插入活动离开则同时出现在主动调用的leave_canvasreducer 与断线清理的clientDisconnected生命周期钩子中。橡皮擦与笔刷共用add_stroke靠tool参数区分动作名对应代码为tool eraser ? erased : drew恰好还原了规格中 Bob erased some strokes 这类语义。形状与文本/评论类事件会传入x、y坐标logActivity(ctx, canvasId, ctx.sender, added_rectangle, undefined, x, y)这是规格第 4 条点击跳转所需的空间数据来源。客户端渲染时服务端存储的下划线动作名通过action.replace(/_/g, )转成可读文案——例如added_rectangle在界面上显示为 added rectangle见 client/src/App.tsx。五、客户端实时渲染订阅 折叠侧栏 条目组件5.1 实时数据来源useTable 订阅规格第 5 条Activity feed updates in real-time as actions happen由 SpacetimeDB 的订阅推送机制保证。客户端通过 SDK 的useTable钩子订阅整张表任何客户端向服务端写入/删除activity_entry行后增量都会实时推送到所有已订阅的客户端并触发重渲染const [activityEntries] useTable(tables.activityEntry);见 client/src/App.tsx。因为表声明为public: true参与画布的客户端均可直接订阅无需自定义权限过滤。5.2 画布过滤 二次排序截断拿到全表数据后客户端先按当前激活画布过滤再按时间降序排序并slice(0, 100)与服务端裁剪形成双重保险const canvasActivity [ ...activityEntries.filter(a a.canvasId activeCanvasId), ] .sort((a, b) Number( b.createdAt.microsSinceUnixEpoch - a.createdAt.microsSinceUnixEpoch ) ) .slice(0, 100);见 client/src/App.tsx。即便服务端因历史数据或批处理暂时多出一两条客户端渲染层也严格限制在 100 条以内。5.3 可折叠侧栏与条目渲染规格第 1 条可折叠侧边面板落实为右侧面板showRightPanel状态控制整个面板的显隐面板内以标签页切换users | activity | versions | comments四个子视图见 client/src/App.tsx。切到activity标签后活动条目按规格渲染为头像色圆点 加粗用户名 动作文案 时间戳的结构{activeTab activity ( div classNameactivity-feed {activities.length 0 ? ( div classNameempty-state div classNameempty-state-textNo activity yet/div /div ) : ( activities.map(activity { const user users.find( u u.identity.toHexString() activity.userIdentity.toHexString() ); return ( div key{activity.id.toString()} classNameactivity-item div classNameactivity-dot style{{ background: user?.avatarColor || #4cf490 }} / div classNameactivity-content div classNameactivity-text strong{user?.displayName || Someone}/strong{ } {activity.action.replace(/_/g, )} /div div classNameactivity-time {formatTime(activity.createdAt)} /div /div /div ); }) )} /div )}见 client/src/App.tsx。这段渲染同时覆盖了规格第 2、3 条动作文案来自action字段用户名与头像色通过userIdentity关联user表取得找不到用户时回退为 Someone 与默认绿#4cf490时间戳由formatTime将微秒时间戳格式化为可读时间。界面整体遵循 SpacetimeDB 暗色品牌风格styles.css 中的--stdb-green等 CSS 变量。六、点击跳转Click to Pan规格预留的空间坐标支撑规格第 4 条Click an entry to pan to that location (if applicable)特意加了限定词适用时——并非所有事件都有明确的画布位置例如 Carol joined 没有坐标。实现的对应策略是服务端数据层已完整支持ActivityEntry表设计了可选的locationX/locationY字段凡是带空间语义的事件添加形状、添加文本/便签、评论都在写入时记录坐标客户端渲染层以文本呈现为主从该快照的App.tsx实现看活动条目当前以信息展示为主若要在条目上实现点击跳转直接读取activity.locationX/activity.locationY驱动画布视口平移即可——这正是规格中if applicable所允许的实现弹性。七、容量上限的端到端闭环把规格第 6 条Keeps last 100 entries在两端拼起来可以看到完整的闭环写入侧logActivity()每次插入后立即按画布裁剪保证服务端表不会无限增长见 index.ts读取侧客户端排序后slice(0, 100)保证 UI 永远只渲染最近 100 条见 App.tsx。这种服务端负责数据治理、客户端负责展示优化的分工与同一应用里 Presence、Cursor 等高频表的处理思路一致都依托 SpacetimeDB 的订阅推送实现免轮询的实时同步。八、在你自己项目里复刻这套模式如果你要在自己的 SpacetimeDB 模块里加 Activity Feed最小落地路径就是照搬三步建模声明一张公开的activity_entry表public: truecanvasId上的 btree 索引字段含userIdentity、action、可选的locationX/Y与createdAt埋点仿照logActivity()写一个统一辅助函数在需要记录事件的 reducer 里调用并让该函数在插入后完成每画布仅保留 100 条的裁剪订阅渲染客户端用useTable(tables.activityEntry)订阅整表按画布过滤、按时间倒序、截取 100 条再以头像色 用户名 动作文案 时间戳的结构渲染进可折叠侧栏。这份规格文档所在的 prompts/ 体系本身也说明了它的使用方式将language/*.md语言/架构设定与composed/*.md累积功能规格拼接后交给 LLM即可生成完整应用——这也是仓库中两份带时间戳的实现快照如 paint-app-20260112-154500的由来。如果你希望验证效果可进入快照目录分别安装客户端client/React Vite与后端backend/spacetimedb/SpacetimeDB TypeScript 模块的依赖并启动用两个浏览器窗口打开同一画布即可看到活动日志实时刷新。【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。