Lands the view-preset / server-persisted personal-layer feature that has been running in production, plus the hotfix for "the same preset shows different filter configs to different users" (物流问题-已出轨迹 on tblgsJTXhqwiDpsjeL5 / viwyszJmLQNOLhA9sh5). Root cause of the divergence: - usePersonalViewSync implicitly flushed any overlay edit by a view|update user INTO the shared preset row (last-writer-wins). Overlays are stale snapshots seeded at apply time, so one user's seed silently erased colleagues' filter changes (2026-09-17 02:55 the preset lost the 流转角色=售后 condition), and active overlays never converged because preset updates do not propagate. Fix (semantics: presets are shared definitions, edits are personal): - fork-on-edit: the first user edit strips the seed markers from the overlay (toolbar switches to the personal config); edits only ever write the user's personal row - shared presets change ONLY through the new explicit, confirmed "Update to current config" row action (canManage users) - un-edited preset overlays auto-follow redefinitions: applyPreset stamps a presetVersion (preset.lastModifiedTime); when the preset list refetches with a newer row the overlay re-seeds. Edited overlays carry no markers and are never clobbered. Legacy rows without a version re-seed exactly once - exits strip both seed markers from the persisted personal row Also lands the pending WIP shipped with the deployed build: select-choice visibility constraint, personal-view server sync + proxy, grid async records scope/request tracking, CLI resource display, preset i18n (10 locales), view_preset migrations, docs/skill updates, .zcode gitignore. Verified end-to-end on the live instance (headless chromium, real session): apply -> edit flips toolbar to Personal with ZERO shared-row PUTs; explicit update PUTs the shared row; API-side redefinition auto-reseeds an un-edited overlay on the next list refetch.
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
/**
|
||
* High-level API client. Each method maps to one REST endpoint of the
|
||
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
|
||
*
|
||
* Base/table/field/record/view endpoints are compatible with official teable
|
||
* open-api; workflow/automation endpoints use the project's own contract.
|
||
*/
|
||
import { request, ApiError } from './request';
|
||
import { IConfig } from './config';
|
||
|
||
export class Client {
|
||
constructor(private cfg: IConfig) {}
|
||
|
||
private get headers(): Record<string, string> {
|
||
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
|
||
}
|
||
|
||
private async call<T>(method: string, p: string, body?: unknown): Promise<T> {
|
||
const res = await request<T>(this.cfg.endpoint, p, { method, headers: this.headers, body });
|
||
return res.data;
|
||
}
|
||
|
||
// ---- auth / user ----
|
||
getMe() {
|
||
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
|
||
// (/api/auth/user/me is session-only and 403s for PAT.)
|
||
return this.call<unknown>('GET', '/api/auth/user');
|
||
}
|
||
|
||
// ---- access tokens (PAT) ----
|
||
listAccessTokens() {
|
||
return this.call<unknown[]>('GET', '/api/access-token');
|
||
}
|
||
createAccessToken(body: {
|
||
name: string;
|
||
description?: string;
|
||
scopes: string[];
|
||
baseIds?: string[];
|
||
spaceIds?: string[];
|
||
hasFullAccess?: boolean;
|
||
expiredTime: string;
|
||
}) {
|
||
return this.call<{ id: string; token: string }>('POST', '/api/access-token', body);
|
||
}
|
||
deleteAccessToken(id: string) {
|
||
return this.call<{ id: string }>('DELETE', `/api/access-token/${id}`);
|
||
}
|
||
|
||
// ---- base ----
|
||
listBases() {
|
||
// GET /api/base/access/all — lists bases the user can access.
|
||
return this.call<unknown[]>('GET', '/api/base/access/all');
|
||
}
|
||
getBase(baseId: string) {
|
||
return this.call<unknown>('GET', `/api/base/${baseId}`);
|
||
}
|
||
|
||
// ---- table ----
|
||
listTables(baseId: string) {
|
||
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
|
||
}
|
||
getTable(tableId: string) {
|
||
return this.call<unknown>('GET', `/api/table/${tableId}`);
|
||
}
|
||
createTable(baseId: string, body: unknown) {
|
||
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
|
||
}
|
||
updateTable(tableId: string, body: unknown) {
|
||
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
|
||
}
|
||
deleteTable(tableId: string, baseId?: string) {
|
||
const base = baseId ?? this.cfg.baseId;
|
||
return this.call<{ id: string }>('DELETE', `/api/base/${base}/table/${tableId}`);
|
||
}
|
||
|
||
// ---- field ----
|
||
listFields(tableId: string) {
|
||
return this.call<unknown[]>('GET', `/api/table/${tableId}/field`);
|
||
}
|
||
getField(tableId: string, fieldId: string) {
|
||
return this.call<unknown>('GET', `/api/table/${tableId}/field/${fieldId}`);
|
||
}
|
||
createField(tableId: string, body: unknown) {
|
||
return this.call<unknown>('POST', `/api/table/${tableId}/field`, body);
|
||
}
|
||
/** Rename/describe. NOTE: the update API accepts name/description only —
|
||
* options (incl. select choice visibilityFilter) must go through convertField. */
|
||
updateField(tableId: string, fieldId: string, body: unknown) {
|
||
return this.call<unknown>('PATCH', `/api/table/${tableId}/field/${fieldId}`, body);
|
||
}
|
||
/** PUT convert: the ONLY API path that persists field options (choices,
|
||
* visibilityFilter dependency config, etc.). Requires the field's current type. */
|
||
convertField(tableId: string, fieldId: string, body: unknown) {
|
||
return this.call<unknown>('PUT', `/api/table/${tableId}/field/${fieldId}/convert`, body);
|
||
}
|
||
deleteField(tableId: string, fieldId: string) {
|
||
return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/field/${fieldId}`);
|
||
}
|
||
/** Evaluate select-choice visibility dependency for one record+field.
|
||
* GET /api/table/:tableId/select-visibility/:recordId/:fieldId */
|
||
getVisibleChoices(tableId: string, recordId: string, fieldId: string) {
|
||
return this.call<{ visibleChoices: Array<{ id: string; name: string; color: string }> }>(
|
||
'GET',
|
||
`/api/table/${tableId}/select-visibility/${recordId}/${fieldId}`
|
||
);
|
||
}
|
||
|
||
// ---- record ----
|
||
getRecords(tableId: string, query?: Record<string, unknown>) {
|
||
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||
return this.call<unknown>('GET', `/api/table/${tableId}/record${qs}`);
|
||
}
|
||
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
|
||
private flattenQuery(query: Record<string, unknown>): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
for (const [k, v] of Object.entries(query)) {
|
||
if (v === undefined || v === null) continue;
|
||
if (Array.isArray(v)) {
|
||
v.forEach((item) => {
|
||
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
|
||
});
|
||
} else if (typeof v === 'object') {
|
||
out[k] = JSON.stringify(v);
|
||
} else if (k === 'search' && typeof v === 'string') {
|
||
// The records list API expects `search[]=value` (tuple), not a plain
|
||
// `search=value` string. https://github.com/colinhacks/zod
|
||
out['search[]'] = v;
|
||
} else {
|
||
out[k] = String(v);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
getRecord(recordId: string, query?: Record<string, unknown>) {
|
||
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`);
|
||
}
|
||
createRecord(tableId: string, body: unknown) {
|
||
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
|
||
}
|
||
updateRecord(tableId: string, body: unknown) {
|
||
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
|
||
}
|
||
updateRecordOne(recordId: string, body: unknown) {
|
||
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
|
||
}
|
||
deleteRecords(tableId: string, recordIds: string[]) {
|
||
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
|
||
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
|
||
}
|
||
|
||
// ---- view ----
|
||
listViews(tableId: string) {
|
||
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
|
||
}
|
||
getView(viewId: string) {
|
||
return this.call<unknown>('GET', `/api/view/${viewId}`);
|
||
}
|
||
createView(tableId: string, body: unknown) {
|
||
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
|
||
}
|
||
updateView(viewId: string, body: unknown) {
|
||
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
|
||
}
|
||
deleteView(viewId: string) {
|
||
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
|
||
}
|
||
|
||
// ---- view preset (self-built contract) ----
|
||
// Preset view configurations: VIEW-scoped named bundles of
|
||
// filter/sort/group(/options/columnMeta) shared with all collaborators.
|
||
listViewPresets(tableId: string, viewId?: string) {
|
||
return this.call<unknown[]>(
|
||
'GET',
|
||
`/api/table/${tableId}/view-preset${viewId ? `?viewId=${encodeURIComponent(viewId)}` : ''}`
|
||
);
|
||
}
|
||
createViewPreset(tableId: string, body: unknown) {
|
||
return this.call<unknown>('POST', `/api/table/${tableId}/view-preset`, body);
|
||
}
|
||
updateViewPreset(tableId: string, presetId: string, body: unknown) {
|
||
return this.call<unknown>('PUT', `/api/table/${tableId}/view-preset/${presetId}`, body);
|
||
}
|
||
deleteViewPreset(tableId: string, presetId: string) {
|
||
return this.call<void>('DELETE', `/api/table/${tableId}/view-preset/${presetId}`);
|
||
}
|
||
|
||
// ---- workflow (self-built contract) ----
|
||
listWorkflows(baseId: string) {
|
||
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
|
||
}
|
||
getWorkflow(workflowId: string) {
|
||
return this.call<IWorkflowVo>('GET', `/api/base/any/workflow/${workflowId}`);
|
||
}
|
||
/**
|
||
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
|
||
* route that resolves base from the workflow itself, but some auth/permission
|
||
* paths need the base-scoped route. Prefer this when baseId is known.
|
||
*/
|
||
getWorkflowInBase(baseId: string, workflowId: string) {
|
||
return this.call<IWorkflowVo>('GET', `/api/base/${baseId}/workflow/${workflowId}`);
|
||
}
|
||
createWorkflow(baseId: string, body: { name: string; description?: string }) {
|
||
return this.call<IWorkflowVo>('POST', `/api/base/${baseId}/workflow`, body);
|
||
}
|
||
updateWorkflow(
|
||
baseId: string,
|
||
workflowId: string,
|
||
body: { name?: string; description?: string }
|
||
) {
|
||
return this.call<IWorkflowVo>('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
|
||
}
|
||
deleteWorkflow(baseId: string, workflowId: string) {
|
||
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
|
||
}
|
||
activateWorkflow(baseId: string, workflowId: string) {
|
||
return this.call<{ snapshotId: string }>(
|
||
'POST',
|
||
`/api/base/${baseId}/workflow/${workflowId}/activate`
|
||
);
|
||
}
|
||
deactivateWorkflow(baseId: string, workflowId: string) {
|
||
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
|
||
}
|
||
testWorkflow(baseId: string, workflowId: string) {
|
||
return this.call<{ nodeResults: unknown[] }>(
|
||
'POST',
|
||
`/api/base/${baseId}/workflow/${workflowId}/test`
|
||
);
|
||
}
|
||
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
|
||
replaceNodes(
|
||
baseId: string,
|
||
workflowId: string,
|
||
body: { nodes: IWorkflowNodeVo[]; edges: IWorkflowEdgeVo[] }
|
||
) {
|
||
return this.call<IWorkflowVo>('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
|
||
}
|
||
|
||
// ---- secrets ----
|
||
listSecrets(baseId: string, workflowId: string) {
|
||
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
|
||
}
|
||
createSecret(
|
||
baseId: string,
|
||
workflowId: string,
|
||
body: { name: string; description?: string; value: string }
|
||
) {
|
||
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
|
||
}
|
||
updateSecret(
|
||
baseId: string,
|
||
workflowId: string,
|
||
secretId: string,
|
||
body: { name?: string; description?: string; value?: string }
|
||
) {
|
||
return this.call<unknown>(
|
||
'PATCH',
|
||
`/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`,
|
||
body
|
||
);
|
||
}
|
||
deleteSecret(baseId: string, workflowId: string, secretId: string) {
|
||
return this.call<unknown>(
|
||
'DELETE',
|
||
`/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`
|
||
);
|
||
}
|
||
|
||
// ---- execution history ----
|
||
listExecutions(baseId: string, workflowId: string) {
|
||
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
|
||
}
|
||
getExecution(baseId: string, workflowId: string, executionId: string) {
|
||
return this.call<unknown>(
|
||
'GET',
|
||
`/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`
|
||
);
|
||
}
|
||
rerunExecution(baseId: string, workflowId: string, executionId: string) {
|
||
return this.call<unknown>(
|
||
'POST',
|
||
`/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`
|
||
);
|
||
}
|
||
|
||
// ---- trigger (manual fire for testing) ----
|
||
fireButtonClick(tableId: string, recordId: string, fieldId: string) {
|
||
return this.call<{ runId: string; tableId: string; fieldId: string; record: unknown }>(
|
||
'POST',
|
||
`/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`
|
||
);
|
||
}
|
||
fireWebhook(baseId: string, workflowId: string, body?: unknown) {
|
||
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
|
||
return this.call<unknown>('POST', `/api/webhook/${baseId}/${workflowId}`, body);
|
||
}
|
||
|
||
// ---- SQL (read-only) ----
|
||
sql(baseId: string, sql: string) {
|
||
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
|
||
}
|
||
|
||
// ---- plugin ----
|
||
getPlugins() {
|
||
return this.call<unknown[]>('GET', '/api/plugin');
|
||
}
|
||
getPlugin(pluginId: string) {
|
||
return this.call<{
|
||
id: string;
|
||
name: string;
|
||
positions: string[];
|
||
description?: string;
|
||
detailDesc?: string;
|
||
url?: string;
|
||
logo?: string;
|
||
helpUrl?: string;
|
||
}>('GET', `/api/plugin/${encodeURIComponent(pluginId)}`);
|
||
}
|
||
updatePlugin(pluginId: string, body: Record<string, unknown>) {
|
||
return this.call<unknown>('PUT', `/api/plugin/${encodeURIComponent(pluginId)}`, body);
|
||
}
|
||
getCspOrigins() {
|
||
return this.call<{ origins: string[] }>('GET', '/api/plugin/csp-origins');
|
||
}
|
||
registerPlugin(body: Record<string, unknown>) {
|
||
return this.call<unknown>('POST', '/api/plugin', body);
|
||
}
|
||
listPluginConfigs(baseId: string) {
|
||
return this.call<unknown[]>('GET', `/api/base/${baseId}/plugin-config`);
|
||
}
|
||
upsertPluginConfig(baseId: string, pluginId: string, body: Record<string, unknown>) {
|
||
return this.call<unknown>('PUT', `/api/base/${baseId}/plugin-config/${pluginId}`, body);
|
||
}
|
||
|
||
// ---- plugin installs ----
|
||
listInstalls(baseId: string) {
|
||
return this.call<unknown[]>('GET', `/api/base/${baseId}/plugin-installs`);
|
||
}
|
||
createInstall(baseId: string, body: Record<string, unknown>) {
|
||
return this.call<unknown>('POST', `/api/base/${baseId}/plugin-installs`, body);
|
||
}
|
||
deleteInstall(baseId: string, installId: string) {
|
||
return this.call<unknown>('DELETE', `/api/base/${baseId}/plugin-installs/${installId}`);
|
||
}
|
||
|
||
// ---- plugin developers ----
|
||
listDevelopers() {
|
||
return this.call<unknown[]>('GET', '/api/plugin-developers');
|
||
}
|
||
addDeveloper(userIdOrEmail: string) {
|
||
// Backend reads @Body('userId') or @Body('email'). Send whichever looks right.
|
||
const isEmail = userIdOrEmail.includes('@');
|
||
const body = isEmail ? { email: userIdOrEmail } : { userId: userIdOrEmail };
|
||
return this.call<unknown>('POST', '/api/plugin-developers', body);
|
||
}
|
||
removeDeveloper(userId: string) {
|
||
return this.call<unknown>('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`);
|
||
}
|
||
|
||
// ---- subscription center (record subscriptions) ----
|
||
listSubscriptions(baseId: string, tableId?: string) {
|
||
const q = tableId ? `?tableId=${encodeURIComponent(tableId)}` : '';
|
||
return this.call<IRecordSubscriptionVo[]>('GET', `/api/base/${baseId}/subscription${q}`);
|
||
}
|
||
getSubscriptionEvents(baseId: string, subscriptionId: string) {
|
||
return this.call<IRecordSubscriptionEventVo[]>(
|
||
'GET',
|
||
`/api/base/${baseId}/subscription/${subscriptionId}/event`
|
||
);
|
||
}
|
||
createSubscription(baseId: string, body: IRecordSubscriptionRo) {
|
||
return this.call<IRecordSubscriptionVo>('POST', `/api/base/${baseId}/subscription`, body);
|
||
}
|
||
updateSubscription(baseId: string, subscriptionId: string, body: Partial<IRecordSubscriptionRo>) {
|
||
return this.call<IRecordSubscriptionVo>(
|
||
'PATCH',
|
||
`/api/base/${baseId}/subscription/${subscriptionId}`,
|
||
body
|
||
);
|
||
}
|
||
deleteSubscription(baseId: string, subscriptionId: string) {
|
||
return this.call<{ id: string }>(
|
||
'DELETE',
|
||
`/api/base/${baseId}/subscription/${subscriptionId}`
|
||
);
|
||
}
|
||
/** 立即发送(flush pending digest events) */
|
||
sendSubscriptionNow(baseId: string, subscriptionId: string) {
|
||
return this.call<unknown>('POST', `/api/base/${baseId}/subscription/${subscriptionId}/send`);
|
||
}
|
||
}
|
||
|
||
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
|
||
export type WorkflowNodeType = 'trigger' | 'action' | 'control';
|
||
export type WorkflowNodeCategory =
|
||
| 'recordCreated'
|
||
| 'recordUpdated'
|
||
| 'recordCondition'
|
||
| 'recordCreatedOrUpdated'
|
||
| 'formSubmitted'
|
||
| 'scheduledTime'
|
||
| 'buttonClick'
|
||
| 'webhook'
|
||
| 'script'
|
||
| 'sendEmail'
|
||
| 'httpRequest'
|
||
| 'createRecord'
|
||
| 'updateRecord'
|
||
| 'getRecords'
|
||
| 'triggerEnd';
|
||
|
||
export interface IWorkflowNodeVo {
|
||
id: string;
|
||
workflowId?: string;
|
||
type: WorkflowNodeType;
|
||
category: WorkflowNodeCategory | string;
|
||
config: Record<string, unknown> | null;
|
||
name?: string | null;
|
||
testResult?: Record<string, unknown> | null;
|
||
inputVariables?: unknown;
|
||
outputVariables?: unknown;
|
||
}
|
||
|
||
export interface IWorkflowEdgeVo {
|
||
id: string;
|
||
workflowId?: string;
|
||
sourceNodeId: string;
|
||
targetNodeId: string;
|
||
}
|
||
|
||
export interface IWorkflowVo {
|
||
id: string;
|
||
baseId: string;
|
||
name: string;
|
||
description?: string | null;
|
||
isActive: boolean;
|
||
hasDraft: boolean;
|
||
nodes: IWorkflowNodeVo[];
|
||
edges: IWorkflowEdgeVo[];
|
||
createdBy?: string;
|
||
createdTime?: string;
|
||
lastModifiedBy?: string | null;
|
||
lastModifiedTime?: string | null;
|
||
}
|
||
|
||
export { ApiError };
|
||
|
||
// ---- subscription types (mirror backend record_subscription contract) ----
|
||
export interface IRecordSubscriptionRo {
|
||
name: string;
|
||
tableId: string;
|
||
watchCreated?: boolean;
|
||
watchUpdated?: boolean;
|
||
fieldIds?: string[];
|
||
recordIds?: string[];
|
||
displayFieldId?: string | null;
|
||
attachCsv?: boolean;
|
||
filter?: Record<string, unknown> | null;
|
||
triggerOnEnter?: boolean;
|
||
sendMode?: 'digest' | 'instant';
|
||
recipients?: string[];
|
||
notifyInApp?: boolean;
|
||
scheduleType?: 'daily' | 'weekly' | 'hours' | 'minutes';
|
||
scheduleTime?: string;
|
||
scheduleWeekday?: number;
|
||
scheduleInterval?: number;
|
||
isActive?: boolean;
|
||
}
|
||
|
||
export interface IRecordSubscriptionVo extends IRecordSubscriptionRo {
|
||
id: string;
|
||
baseId: string;
|
||
createdBy: string;
|
||
createdTime: string;
|
||
lastModifiedTime?: string | null;
|
||
lastSentTime?: string | null;
|
||
pendingCount?: number;
|
||
}
|
||
|
||
export interface IRecordSubscriptionEventVo {
|
||
id: string;
|
||
subscriptionId: string;
|
||
recordId: string;
|
||
changeType: 'created' | 'updated';
|
||
changedFieldIds?: string[] | null;
|
||
createdTime: string;
|
||
sentTime?: string | null;
|
||
}
|