读写表格数据
表格数据是插件最常碰的东西。所有数据访问都经后端进程的 cap.dataEngine 走 —— 先在 manifest 声明:
{ "runtime": "node", "capabilities": ["dataEngine"] }每个方法都是 (args, ctx) 两段式:args 是业务参数,ctx 是调用上下文 —— 谁(actor)在哪个项目(projectId)里操作:
import { CapabilityClient } from '@xinghan/plugin-sdk/server'
const cap = new CapabilityClient({ baseUrl: process.env.CAPABILITY_BASE_URL, // 宿主注入 token: process.env.CAPABILITY_TOKEN,})
const ctx = { actor, projectId } // actor 从请求头 x-actor 取,别造假身份actor 贯穿治理链路:写入历史、协同广播、权限判定都记在这个身份上。把宿主注入的真实身份透传下去,是插件行为可审计的前提。
// 字段结构(列定义:id/name/type/properties)const fields = await cap.dataEngine.getFields({ tableNodeId }, ctx)
// 查询记录:过滤/排序/分页都在 opts 里(QueryOptions)const page = await cap.dataEngine.queryRecords( { tableNodeId, opts: { limit: 100, offset: 0 } }, ctx,)// ⚠️ 行是**扁平**的:page.records: [{ _recordId, <fieldId>: value, … }] ; page.total// 记录 id 在 _recordId;字段值直接挂在行对象上(没有 data 嵌套)
// 单条 / 批量按 id 取const rec = await cap.dataEngine.getRecord({ tableNodeId, recordId }, ctx)const recs = await cap.dataEngine.getRecords({ tableNodeId, recordIds }, ctx)
// 视图列表(id/名称/类型)const views = await cap.dataEngine.getViews({ tableNodeId }, ctx)opts.filters 是一组 { fieldId, op, value },filtersConjunction 决定它们之间是 AND(默认)还是 OR:
const overdue = await cap.dataEngine.queryRecords( { tableNodeId, opts: { filters: [ { fieldId: f状态, op: '!=', value: opt已完成 }, // 单选字段:比的是选项 id { fieldId: f截止日期, op: '<', value: Date.now() }, // 日期字段:毫秒时间戳 ], filtersConjunction: 'and', sorts: [{ fieldId: f截止日期, desc: false }], // 最早逾期的排最前 limit: 200, }, }, ctx,)op 的全部取值:'=' '!=' '<' '<=' '>' '>=' 'contains' 'notContains' 'in'(value 传数组)'isNull' 'notNull'(后两个不需要 value)。
两条经验:
- 记录值按 fieldId 键控,不是字段名 —— 先
getFields建映射再读值; - 大表别拉全量:
opts.limit不传默认返回全集 —— 显式分页,统计走aggregate。
// 单格await cap.dataEngine.updateCell({ tableNodeId, recordId, fieldId, value }, ctx)
// 批量改格(一次提交,一次撤销)await cap.dataEngine.updateCells({ tableNodeId, cells: [{ recordId, fieldId, value }] }, ctx)
// 新增记录(单条/批量;一次几百行没问题,数千行的大导入分批 ——// 注意每批是独立的撤销单元)await cap.dataEngine.addRecord({ tableNodeId, initial: { [fieldId]: value } }, ctx)await cap.dataEngine.addRecords({ tableNodeId, records: [{ initial: {...} }] }, ctx)
// 删除await cap.dataEngine.deleteRecord({ tableNodeId, recordId }, ctx)结构操作:建表、加列、建视图
Section titled “结构操作:建表、加列、建视图”// 建表(fields 可选,建完再 addFields 也行)const { tableNodeId } = await cap.dataEngine.addTable( { name: '客户线索', parentId: folderNodeId }, // parentId 缺省挂项目根 ctx,)
// 加列:fieldId 由你生成(惯例 `fld_` 前缀 + 随机串);type 是数字枚举const { fieldIds } = await cap.dataEngine.addFields( { tableNodeId, fields: [ { fieldId: `fld_${randomId()}`, name: '姓名', type: 1 }, // 1 = 文本 { fieldId: `fld_${randomId()}`, name: '成交额', type: 2 }, // 2 = 数字 { fieldId: `fld_${randomId()}`, name: '状态', type: 3, // 3 = 单选 properties: { options: [ { id: 'opt_new', text: '新线索' }, { id: 'opt_won', text: '已成交' }, ] } }, ], }, ctx,)
// 建视图:type 传 '<你的插件id>:<viewKey>' 就能建自己贡献的表视图await cap.dataEngine.createView( { tableNodeId, name: '卡片', type: 'xinghan.card-view:cards' }, ctx,)字段 type 枚举(与引擎一致):1 文本 · 2 数字 · 3 单选 · 4 多选 · 5 日期时间 · 6 勾选 · 11 附件。单选/多选的 properties.options 形如 [{ id, text }],选项 id 由你生成(惯例 opt_ 前缀)。
批量版(addFields/addRecords/updateCells)与单条版语义等价,但批量是一个 changeset:一次审批、一次 Ctrl+Z —— 多条操作永远优先批量。
聚合:统计别拉全量
Section titled “聚合:统计别拉全量”const agg = await cap.dataEngine.aggregate( { tableNodeId, opts: { fieldId: f成交额, ops: ['count', 'sum', 'avg'], // 一次遍历同时算多种 filters: [{ fieldId: f状态, op: '=', value: 'opt_won' }], groupBy: { fieldId: f负责人 }, // 按人分组 → 结果进 groups }, }, ctx,)// 全部聚合操作:'min' | 'max' | 'count' | 'sum' | 'avg'// 另有时间窗/按天分桶参数(做趋势图用),见类型定义 AggregateOptions插件的写入没有”后门”:每次写最终变成宿主的 changeset 走统一管线 ——
- 用户可撤销:你的插件写了 100 个格子,用户 Ctrl+Z 一次全部回滚;
- 进历史:改动记在
actor名下,可回溯; - 协同同步:其它在线端实时收到,与用户手动编辑无异。
推论:别自己实现”撤销”(再写一遍旧值会产生新历史项),把批量改动合成一次 updateCells/addRecords 调用,天然一次可撤销。
单元格值的存储形状由字段类型决定:文本是字符串、数字是 number、单选存选项 id(不是选项文字)、日期是毫秒时间戳、关联/附件是结构化数组。写入前用 getFields 看目标字段的 type 与 properties(选项表就在 properties 里):
// 想把「状态」写成"已完成"?先从字段 properties 里把选项文字换成选项 idconst fields = await cap.dataEngine.getFields({ tableNodeId }, ctx)const statusField = fields.find((f) => f.name === '状态')!const options = (statusField.properties as { options?: { id: string; text: string }[] }) ?.options ?? []const done = options.find((o) => o.text === '已完成')if (!done) throw new Error('「状态」字段没有"已完成"选项')
await cap.dataEngine.updateCell( { tableNodeId, recordId, fieldId: statusField.id, value: done.id }, // 写 id,不是文字 ctx,)端到端示例:把逾期任务批量打标
Section titled “端到端示例:把逾期任务批量打标”把上面的碎片拼成一个真实端点 —— 查出所有未完成且已过期的任务,一次批量写入(用户一次撤销):
router.post('/api/mark-overdue', async (ctx) => { const actor = (ctx.headers['x-actor'] as string) ?? 'anonymous' const { projectId, tableNodeId } = ctx.request.body as { projectId: string; tableNodeId: string } const cc = { actor, projectId }
// 1. 字段映射(按名字找列,拿到 fieldId 与选项 id) const fields = await cap.dataEngine.getFields({ tableNodeId }, cc) const byName = new Map(fields.map((f) => [f.name, f])) const due = byName.get('截止日期') const status = byName.get('状态') const tag = byName.get('标记') if (!due || !status || !tag) { ctx.status = 400 ctx.body = { ok: false, message: '表里需要「截止日期/状态/标记」三列' } return }
// 2. 服务端过滤,只拉需要的行 const page = await cap.dataEngine.queryRecords( { tableNodeId, opts: { filters: [ { fieldId: due.id, op: '<', value: Date.now() }, { fieldId: status.id, op: '!=', value: doneOptionId(status) }, ], limit: 500, }, }, cc, )
// 3. 一次批量写 = 历史里一条,用户 Ctrl+Z 一次全回滚 await cap.dataEngine.updateCells( { tableNodeId, cells: page.records.map((r) => ({ recordId: r._recordId, fieldId: tag.id, value: '⚠️ 逾期', })), }, cc, ) ctx.body = { ok: true, data: { marked: page.records.length } }})进阶:审批单模式(propose 而非直写)
Section titled “进阶:审批单模式(propose 而非直写)”不想直写、想让改动先过用户审批?写方法普遍支持 prId 参数(配合 cap.dataPr.create 建审批单)——你的写入变成”提议”,进 GitHub PR 式的审批页由用户合并。读方法也支持 prId 做读分叉(叠加提议值预览)。适合”agent 建议改 500 行”这类大改动场景,细节见类型定义。
capability 调用失败抛 CapabilityError,带结构化 code(参数不合法、not_found 等)。对用户可见的路径把 message 转成人话,别把原始错误串直接糊到界面上。
- 查重插件
plugins/marketplace/dup-check/:典型的”读全表 → 分析 → 给用户看结果”; - Agent 插件的工具集
plugins/builtin/agent-anthropic/src/agent/toolset/:把 dataEngine 包装成模型工具,几乎每个读写方法都有真实用法。