Skip to content

协议增强:实现 Airtable 风格的 Comments & Activity Timeline(Feed/Chatter 协议标准) #731

@hotlong

Description

@hotlong

背景

Airtable 等现代 SaaS 平台广泛采用了右侧 Activity Feed 面板,将字段变更、评论(支持@提及)、系统任务活动等融合为统一时间线,这为协同、审核、历史追踪带来极大便利。

当前 @objectstack/spec 协议已经有

  • feeds: true / activities: true / trackHistory: true 等能力开关(Data Protocol)
  • 字段级的审计开关和变更跟踪能力
  • UI 层有 record:activity 组件,但 record:chatter 未定义(空壳),也缺乏 Feed/Comment 数据结构

目标

  1. 对标 Airtable/Notion/Salesforce,补全和标准化 Comment/Feed 协议,支持 audit + comment + mention + reaction + 通知订阅(一致时间线)
  2. 让 UI 配置模式下能声明式插入"所有活动"侧边栏,兼容过滤、操作、@提及等能力

主要差距和需求

  • 缺少 FeedItem/Comment/Mention 数据协议(应统一 comments���field_change、task、note、approval 等为 FeedItemType 并支持 @Mentions、Reactions)
  • record:chatter 组件 props 必须补全,支持评论输入/过滤/订阅等配置
  • RecordActivity/Chatter 组件应支持过滤(all/changes/comments/... 过滤)、unifiedTimeline 等属性
  • Feed 评论支持 @提及(mention)、回复(线程)、reaction、编辑删除、附件
  • 补全 API 协议(GET/POST/PUT/DELETE /feed、/reactions、/subscribe 等端点约定)

推荐实现步骤

  1. 创建 src/data/feed.zod.ts,定义 FeedItemType、FeedItemSchema、MentionSchema、ReactionSchema
  2. 增强 component.zod.ts,扩展 RecordActivityProps/RecordChatterProps 属性,并修正 ComponentPropsMap
  3. 定义 src/data/subscription.zod.ts(通知订阅能力,可选)
  4. 明确 API 端点模型约定

相关参考

任务参考实现(可分多个 PR 完善)

  • 定义 feed 协议基础 zod schema
  • 增强 UI 层协议与 props
  • API 约定与安全
  • 完善测试
    test

对标 Airtable 截图中的 右侧 Activity 面板(评论 + 字段变更历史 + @ Mentions)


🔍 一、截图分析:Airtable 的 Comments & Activity 功能拆解

从你提供的 Airtable 截图 image1 可以看到:

功能区 Airtable 实现 关键能力
Activity 侧边栏 记录详情页右侧固定面板 独立区域、可折叠
Activity Feed "All activity" 下拉过滤 + 时间线 字段变更 + 评论混合排列
字段变更历史 显示字段名、旧值→新值、时间戳 REGION → Asia-Pacific, STATUS → Active
评论 (Comments) 底部 "Leave a comment" 输入框 富文本、@mentions
通知铃铛 面板顶部通知开关 订阅/取消该记录的变更通知
操作者信息 "You edited using Omni 1d ago" 用户头像、来源、时间
扩展历史 "Upgrade to extend your history!" 付费门控的历史深度

🟢 二、当前协议已有的基础设施

A. Data Protocol — 能力开关(✅ 良好)

export const ObjectCapabilities = z.object({
  trackHistory: z.boolean().default(false).describe('Enable field history tracking for audit compliance'),
  feeds: z.boolean().default(false).describe('Enable social feed, comments, and mentions (Chatter-like)'),
  activities: z.boolean().default(false).describe('Enable standard tasks and events tracking'),
  // ...
});

评估: feeds: trueactivities: truetrackHistory: true 这三个开关已经存在,定义了启用的意图,但 没有对应��� Feed/Comment 数据模型协议

B. Field Protocol — 字段级变更追踪(✅ 存在)

trackFeedHistory: z.boolean().optional().describe('Track field changes in Chatter/activity feed (Salesforce pattern)'),
auditTrail: z.boolean().default(false).describe('Enable detailed audit trail for this field'),

评估: 字段级别的 trackFeedHistory 可控制哪些字段的变更出现在 Feed 中(类似 Airtable 截图中只显示了 REGION, OPPORTUNITIES, STATUS 等关键字段变更)。

C. UI Protocol — Activity 组件(🟡 骨架存在)

export const RecordActivityProps = z.object({
  types: z.array(z.enum(['task', 'event', 'email', 'call', 'note'])).optional(),
  limit: z.number().int().positive().default(10),
  showCompleted: z.boolean().default(false),
  aria: AriaPropsSchema.optional(),
});

以及 ComponentPropsMap 中的注册:

// Record
'record:details': RecordDetailsProps,
'record:related_list': RecordRelatedListProps,
'record:highlights': RecordHighlightsProps,
'record:activity': RecordActivityProps,
'record:chatter': EmptyProps,          // ← 空壳!
'record:path': RecordPathProps,

D. Page Protocol — Lead Detail 示例用法(✅ 演示存在)

{
  type: 'record:activity',
  id: 'lead_activity',
  label: 'Activity Timeline',
  properties: {
    types: ['task', 'event', 'email', 'call', 'note'],
    limit: 20,
    showCompleted: false,
  },
},
// History Tab
{
  type: 'record:related_list',
  id: 'field_history',
  label: 'Field History',
  properties: {
    objectName: 'field_history',
    relationshipField: 'record_id',
    columns: ['field', 'old_value', 'new_value', 'changed_by', 'changed_date'],
  },
},

E. System Protocol — Audit(✅ 丰富)

src/system/audit.zod.ts 有完整的 AuditEventSchemaAuditEventActorAuditEventChange 等,适合后端存储。


🔴 三、关键差距分析(对标 Airtable)

# 差距 严重度 说明
G1 record:chatter 是 EmptyProps 🔴 Critical Comments/Mentions 组件没有任何 Props 定义,无法声明式配置
G2 缺少 FeedItem / Comment 数据模型 🔴 Critical feeds: true 开关存在,但没有 src/data/feed.zod.ts 定义 Feed Item 的数据结构
G3 Activity 缺少 comment 类型 🟠 Major RecordActivityProps.types 只有 ['task', 'event', 'email', 'call', 'note'],没有 'comment''field_change'
G4 缺少 Activity Filter / Stream 控制 🟠 Major Airtable 有 "All activity" 下拉菜单可筛选(只看评论 / 只看变更),当前没有 filterMode
G5 缺少通知订阅协议 🟠 Major Airtable 的铃铛图标控制记录级订阅,当前无 RecordSubscription 协议
G6 缺少 @mention 协议 🟡 Medium Feeds 描述提到 "mentions" 但无 Mention 的目标类型定义(user / team / record)
G7 History 与 Comment 未统一 Timeline 🟡 Medium CRM 示例中 Activity 和 History 放在不同 Tab,Airtable 是混合 Timeline
G8 缺少 Reaction / Emoji 协议 🟢 Nice-to-have Airtable/Notion 风格的 reaction(👍❤️🎉)

🛠️ 四、推荐实现方案

Phase 1: Data Protocol — Feed & Comment 数据模型

需要创建 src/data/feed.zod.ts

// src/data/feed.zod.ts — Feed & Comment Data Protocol
import { z } from 'zod';

/**
 * Feed Item Type
 * Unified activity types for the record timeline
 */
export const FeedItemType = z.enum([
  'comment',        // User comment (rich text + mentions)
  'field_change',   // Field value change (audit)
  'task',           // Task created/completed
  'event',          // Calendar event
  'email',          // Email sent/received
  'call',           // Phone call logged
  'note',           // Internal note
  'file',           // File uploaded/deleted
  'record_create',  // Record was created
  'record_delete',  // Record was deleted
  'approval',       // Approval action (submit/approve/reject)
  'sharing',        // Sharing change
  'system',         // System-generated event
]);
export type FeedItemType = z.infer<typeof FeedItemType>;

/**
 * @mention Target
 */
export const MentionSchema = z.object({
  type: z.enum(['user', 'team', 'record']).describe('Mention target type'),
  id: z.string().describe('Target ID'),
  name: z.string().describe('Display name for rendering'),
  offset: z.number().int().min(0).describe('Character offset in body text'),
  length: z.number().int().min(1).describe('Length of mention token in body text'),
});
export type Mention = z.infer<typeof MentionSchema>;

/**
 * Field Change Entry (for field_change feed items)
 */
export const FieldChangeEntrySchema = z.object({
  field: z.string().describe('Field machine name'),
  fieldLabel: z.string().optional().describe('Field display label'),
  oldValue: z.unknown().optional().describe('Previous value'),
  newValue: z.unknown().optional().describe('New value'),
  oldDisplayValue: z.string().optional().describe('Human-readable old value'),
  newDisplayValue: z.string().optional().describe('Human-readable new value'),
});
export type FieldChangeEntry = z.infer<typeof FieldChangeEntrySchema>;

/**
 * Reaction (Emoji Reaction)
 */
export const ReactionSchema = z.object({
  emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'),
  userIds: z.array(z.string()).describe('Users who reacted'),
  count: z.number().int().min(1).describe('Total reaction count'),
});
export type Reaction = z.infer<typeof ReactionSchema>;

/**
 * Feed Item Schema
 * A single entry in the unified activity timeline.
 * 
 * @example Comment
 * {
 *   type: 'comment',
 *   body: 'Great progress on this deal! @jane.doe can you follow up?',
 *   mentions: [{ type: 'user', id: 'user_123', name: 'Jane Doe', offset: 36, length: 9 }],
 *   actor: { type: 'user', id: 'user_456', name: 'John Smith' }
 * }
 * 
 * @example Field Change
 * {
 *   type: 'field_change',
 *   changes: [
 *     { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' },
 *     { field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' }
 *   ],
 *   actor: { type: 'user', id: 'user_456', name: 'John Smith' }
 * }
 */
export const FeedItemSchema = z.object({
  /** Unique identifier */
  id: z.string().describe('Feed item ID'),

  /** Feed item type */
  type: FeedItemType.describe('Activity type'),

  /** Target record reference */
  object: z.string().describe('Object name (e.g., "account")'),
  recordId: z.string().describe('Record ID this feed item belongs to'),

  /** Actor (who performed the action) */
  actor: z.object({
    type: z.enum(['user', 'system', 'service', 'automation']).describe('Actor type'),
    id: z.string().describe('Actor ID'),
    name: z.string().optional().describe('Actor display name'),
    avatarUrl: z.string().url().optional().describe('Actor avatar URL'),
    source: z.string().optional().describe('Source application (e.g., "Omni", "API", "Studio")'),
  }).describe('Who performed this action'),

  /** Content (for comments/notes) */
  body: z.string().optional().describe('Rich text body (Markdown supported)'),
  
  /** @Mentions */
  mentions: z.array(MentionSchema).optional().describe('Mentioned users/teams/records'),

  /** Field changes (for field_change type) */
  changes: z.array(FieldChangeEntrySchema).optional().describe('Field-level changes'),

  /** Reactions */
  reactions: z.array(ReactionSchema).optional().describe('Emoji reactions on this item'),

  /** Reply threading */
  parentId: z.string().optional().describe('Parent feed item ID for threaded replies'),
  replyCount: z.number().int().min(0).default(0).describe('Number of replies'),

  /** Visibility */
  visibility: z.enum(['public', 'internal', 'private']).default('public')
    .describe('Visibility: public (all users), internal (team only), private (author + mentioned)'),

  /** Timestamps */
  createdAt: z.string().datetime().describe('Creation timestamp'),
  updatedAt: z.string().datetime().optional().describe('Last edit timestamp'),
  editedAt: z.string().datetime().optional().describe('When comment was last edited'),
  isEdited: z.boolean().default(false).describe('Whether comment has been edited'),
});
export type FeedItem = z.infer<typeof FeedItemSchema>;

Phase 2: UI Protocol — 增强 record:chatterrecord:activity

// Enhanced RecordActivityProps — Unified Timeline
export const RecordActivityProps = z.object({
  /** Activity types to display (unified enum) */
  types: z.array(FeedItemType).optional()
    .describe('Feed item types to show (default: all)'),
  
  /** Default filter mode (Airtable-style dropdown) */
  filterMode: z.enum(['all', 'comments_only', 'changes_only', 'tasks_only'])
    .default('all')
    .describe('Default activity filter'),
  
  /** Allow user to switch filter modes */
  showFilterToggle: z.boolean().default(true)
    .describe('Show filter dropdown in panel header'),
  
  /** Pagination */
  limit: z.number().int().positive().default(20)
    .describe('Number of items to load per page'),
  
  /** Show completed activities */
  showCompleted: z.boolean().default(false),
  
  /** Merge field_change + comment in a unified timeline */
  unifiedTimeline: z.boolean().default(true)
    .describe('Mix field changes and comments in one timeline (Airtable style)'),
  
  /** Show the comment input box at the bottom */
  showCommentInput: z.boolean().default(true)
    .describe('Show "Leave a comment" input at the bottom'),
  
  /** Enable @mentions in comments */
  enableMentions: z.boolean().default(true),
  
  /** Enable emoji reactions */
  enableReactions: z.boolean().default(false),
  
  /** Enable threaded replies */
  enableThreading: z.boolean().default(false),
  
  /** Show notification subscription toggle (bell icon) */
  showSubscriptionToggle: z.boolean().default(true)
    .describe('Show bell icon for record-level notification subscription'),
  
  /** ARIA accessibility */
  aria: AriaPropsSchema.optional(),
});

// New: RecordChatterProps (replaces EmptyProps)
export const RecordChatterProps = z.object({
  /** Panel position */
  position: z.enum(['sidebar', 'inline', 'drawer']).default('sidebar')
    .describe('Where to render the chatter panel'),
  
  /** Panel width (for sidebar/drawer) */
  width: z.union([z.string(), z.number()]).optional()
    .describe('Panel width (e.g., "350px", "30%")'),
  
  /** Collapsible */
  collapsible: z.boolean().default(true),
  defaultCollapsed: z.boolean().default(false),
  
  /** Feed configuration (delegates to RecordActivityProps) */
  feed: RecordActivityProps.optional(),
  
  /** ARIA accessibility */
  aria: AriaPropsSchema.optional(),
});

Phase 3: API Protocol — Feed 端点

// API Contract for Feed operations
// Would go in src/api/ or src/contracts/

// GET  /api/data/{object}/{recordId}/feed?type=all&limit=20&cursor=xxx
// POST /api/data/{object}/{recordId}/feed  { type: 'comment', body: '...' }
// PUT  /api/data/{object}/{recordId}/feed/{feedId}  { body: '...' }
// DELETE /api/data/{object}/{recordId}/feed/{feedId}

// POST /api/data/{object}/{recordId}/feed/{feedId}/reactions  { emoji: '👍' }
// POST /api/data/{object}/{recordId}/subscribe  { events: ['comment', 'field_change'] }
// DELETE /api/data/{object}/{recordId}/subscribe

Phase 4: Record Subscription (通知)

// src/data/subscription.zod.ts — Record-Level Notification Subscription
import { z } from 'zod';

export const RecordSubscriptionSchema = z.object({
  /** Target */
  object: z.string().describe('Object name'),
  recordId: z.string().describe('Record ID'),
  
  /** Subscriber */
  userId: z.string().describe('Subscribing user ID'),
  
  /** Events to subscribe to */
  events: z.array(z.enum([
    'comment',
    'mention',
    'field_change',
    'task',
    'approval',
    'all',
  ])).default(['all']).describe('Event types to receive notifications for'),
  
  /** Notification channels */
  channels: z.array(z.enum(['in_app', 'email', 'push', 'slack']))
    .default(['in_app'])
    .describe('Notification delivery channels'),
  
  /** Active */
  active: z.boolean().default(true),
  
  createdAt: z.string().datetime(),
});
export type RecordSubscription = z.infer<typeof RecordSubscriptionSchema>;

📊 五、总结评分

维度 当前评分 实施后目标 说明
Data Model (Feed/Comment) 🔴 D ⭐ A 缺少 FeedItemSchema,开关有但无数据结构
UI Component (Chatter) 🟡 C ⭐ A record:chatter 空壳,record:activity 缺少 unified timeline
API (Feed CRUD) 🔴 D ⭐ A apiMethodshistory 但无 feed 端点协议
Field History Tracking 🟢 B+ ⭐ A trackFeedHistory + auditTrail 字段级已定义
Notification/Subscription 🔴 D ⭐ A 无记录级订阅协议
@Mention 🔴 D ⭐ A feeds 描述提及 mentions 但无数据结构

📋 实施优先级

  1. P0src/data/feed.zod.ts (FeedItem + Comment + FieldChange + Mention 数据模型)
  2. P0 — 增强 RecordActivityProps (unifiedTimeline, filterMode, commentInput)
  3. P0 — 实现 RecordChatterProps 替换 EmptyProps
  4. P1src/data/subscription.zod.ts (记录级通知订阅)
  5. P1 — API Contract for Feed CRUD
  6. P2 — Reactions、Threading、Rich Media Attachments

需要我开 Issue 或直接创建 PR 来实现这些协议变更吗?

Metadata

Metadata

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions