跳转到内容

读写表格数据

表格数据是插件最常碰的东西。所有数据访问都经后端进程的 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)
// 建表(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 —— 多条操作永远优先批量。

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 里把选项文字换成选项 id
const 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 包装成模型工具,几乎每个读写方法都有真实用法。