From 092388e788a0cc8b189e89beaa9c3a87fc4882c3 Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Tue, 9 Jun 2026 16:26:17 +0800 Subject: [PATCH 01/17] fix: reduce base shortcut token overhead --- shortcuts/base/base_dryrun_ops_test.go | 13 +- shortcuts/base/base_execute_test.go | 153 +++++++++++ shortcuts/base/base_shortcuts_test.go | 87 +++++++ shortcuts/base/field_list.go | 3 +- shortcuts/base/field_ops.go | 111 ++++++-- shortcuts/base/field_search_options.go | 10 +- shortcuts/base/record_delete.go | 1 + shortcuts/base/record_get.go | 2 + shortcuts/base/record_list.go | 7 + shortcuts/base/record_ops.go | 36 ++- shortcuts/base/record_query.go | 37 ++- shortcuts/base/record_search.go | 4 + shortcuts/base/record_share_link_create.go | 3 +- shortcuts/base/table_ops.go | 3 +- shortcuts/drive/drive_export_common.go | 9 +- shortcuts/drive/drive_export_test.go | 8 + shortcuts/drive/drive_import.go | 13 +- shortcuts/drive/drive_import_test.go | 40 +++ skills/lark-base/SKILL.md | 19 +- .../references/dashboard-block-data-config.md | 2 + .../references/lark-base-dashboard-usecase.md | 238 ++++++++++++++++++ .../references/lark-base-dashboard.md | 5 + .../references/lark-drive-import.md | 3 +- 23 files changed, 766 insertions(+), 41 deletions(-) create mode 100644 skills/lark-base/references/lark-base-dashboard-usecase.md diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 32993008d..fef92dc6f 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -58,13 +58,22 @@ func TestDryRunBaseBlockOps(t *testing.T) { func TestDryRunFieldOps(t *testing.T) { ctx := context.Background() - listRT := newBaseTestRuntime( - map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, + listRT := newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "app_x"}, + map[string][]string{"table-id": {"tbl_1"}}, nil, map[string]int{"offset": -2, "limit": 999}, ) assertDryRunContains(t, dryRunFieldList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "offset=0", "limit=200") + multiListRT := newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "app_x"}, + map[string][]string{"table-id": {"tbl_1", "tbl_2"}}, + nil, + map[string]int{"offset": 0, "limit": 50}, + ) + assertDryRunContains(t, dryRunFieldList(ctx, multiListRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "GET /open-apis/base/v3/bases/app_x/tables/tbl_2/fields", "limit=50") + rt := newBaseTestRuntime( map[string]string{ "base-token": "app_x", diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 22626be2a..03a634eb9 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -855,6 +855,37 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("list multiple tables", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text", "style": map[string]interface{}{"type": "plain"}}, + }, "total": 1}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}}, + }, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "tbl_b"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"options": [`) || !strings.Contains(got, `"Todo"`) || strings.Contains(got, `"style"`) || strings.Contains(got, `"color"`) { + t.Fatalf("stdout=%s", got) + } + }) + t.Run("get", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -1223,6 +1254,92 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) + t.Run("search accepts query alias", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + searchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Title"}, + "field_id_list": []interface{}{"fld_title"}, + "record_id_list": []interface{}{"rec_1"}, + "data": []interface{}{[]interface{}{"Created by AI"}}, + "has_more": false, + }, + }, + } + reg.Register(searchStub) + if err := runShortcut( + t, + BaseRecordSearch, + []string{ + "+record-search", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--query", "Created", + "--search-field", "Title", + "--format", "json", + }, + factory, + stdout, + ); err != nil { + t.Fatalf("err=%v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil { + t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody)) + } + if body["keyword"] != "Created" { + t.Fatalf("captured body=%#v", body) + } + }) + + t.Run("list accepts page size alias", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + var gotLimit string + listStub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records", + OnMatch: func(req *http.Request) { + gotLimit = req.URL.Query().Get("limit") + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Title"}, + "field_id_list": []interface{}{"fld_title"}, + "record_id_list": []interface{}{"rec_1"}, + "data": []interface{}{[]interface{}{"Created by AI"}}, + "has_more": false, + }, + }, + } + reg.Register(listStub) + if err := runShortcut( + t, + BaseRecordList, + []string{ + "+record-list", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--page-size", "20", + "--format", "json", + }, + factory, + stdout, + ); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) { + t.Fatalf("stdout=%s", got) + } + if gotLimit != "20" { + t.Fatalf("limit query=%q, want 20", gotLimit) + } + }) + t.Run("search with filter json file", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) tmp := t.TempDir() @@ -1864,6 +1981,42 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) + t.Run("share link accepts record-id alias", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + shareStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/share_links/batch", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_share_links": map[string]interface{}{ + "rec_1": "https://example.test/rec_1", + "rec_2": "https://example.test/rec_2", + }, + }, + }, + } + reg.Register(shareStub) + err := runShortcut(t, BaseRecordShareLinkCreate, []string{ + "+record-share-link-create", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--record-id", "rec_1", + "--record-id", "rec_2", + "--record-id", "rec_1", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + body := string(shareStub.CapturedBody) + if !strings.Contains(body, `"record_ids":["rec_1","rec_2"]`) { + t.Fatalf("request body=%s", body) + } + if got := stdout.String(); !strings.Contains(got, "rec_1") || !strings.Contains(got, "rec_2") { + t.Fatalf("stdout=%s", got) + } + }) + t.Run("upload attachment", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index e304d4f18..d8a29194c 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -59,6 +59,70 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}} } +func TestRecordQueryAliases(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "filter": `{"logic":"and","conditions":[["Status","==","Todo"]]}`, + "sort": `[{"field":"Updated","desc":true}]`, + }, + nil, + nil, + ) + filter, err := parseRecordFilterFlag(runtime) + if err != nil { + t.Fatalf("filter err=%v", err) + } + if filter.(map[string]interface{})["logic"] != "and" { + t.Fatalf("filter=%#v", filter) + } + sortConfig, err := parseRecordSortFlag(runtime) + if err != nil { + t.Fatalf("sort err=%v", err) + } + if sortConfig[0].(map[string]interface{})["field"] != "Updated" { + t.Fatalf("sort=%#v", sortConfig) + } +} + +func TestRecordSelectionAliases(t *testing.T) { + runtime := newBaseTestRuntimeWithArrays( + map[string]string{}, + map[string][]string{ + "record-ids": {"rec_1", "rec_2"}, + "field-names": {"Name"}, + }, + nil, + nil, + ) + selection, err := resolveRecordSelection(runtime) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(selection.recordIDs) != 2 || selection.recordIDs[1] != "rec_2" { + t.Fatalf("recordIDs=%#v", selection.recordIDs) + } + if len(selection.selectFields) != 1 || selection.selectFields[0] != "Name" { + t.Fatalf("selectFields=%#v", selection.selectFields) + } +} + +func TestFieldSearchOptionsAlias(t *testing.T) { + runtime := newBaseTestRuntime(map[string]string{"field-name": "Status"}, nil, nil) + if got := fieldSearchOptionsRef(runtime); got != "Status" { + t.Fatalf("field ref=%q", got) + } + if err := BaseFieldSearchOptions.Validate(context.Background(), runtime); err != nil { + t.Fatalf("err=%v", err) + } +} + +func TestFieldSearchOptionsRequiresFieldRef(t *testing.T) { + err := BaseFieldSearchOptions.Validate(context.Background(), newBaseTestRuntime(map[string]string{}, nil, nil)) + if err == nil || !strings.Contains(err.Error(), "--field-id is required") { + t.Fatalf("err=%v", err) + } +} + func TestBaseAction(t *testing.T) { t.Run("missing action", func(t *testing.T) { runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil) @@ -1025,6 +1089,13 @@ func TestBaseRecordValidate(t *testing.T) { )); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") { t.Fatalf("err=%v", err) } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1"}, + nil, + map[string]int{"limit": 20, "page-size": 20}, + )); err == nil || !strings.Contains(err.Error(), "use only one") { + t.Fatalf("err=%v", err) + } if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") { t.Fatalf("err=%v", err) } @@ -1036,6 +1107,22 @@ func TestBaseRecordValidate(t *testing.T) { )); err != nil { t.Fatalf("record search flag validate err=%v", err) } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "b", "table-id": "tbl_1", "query": "Alice"}, + map[string][]string{"search-field": {"Name"}}, + nil, + nil, + )); err != nil { + t.Fatalf("record search query alias validate err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice", "query": "Bob"}, + map[string][]string{"search-field": {"Name"}}, + nil, + nil, + )); err == nil || !strings.Contains(err.Error(), "use only one") { + t.Fatalf("err=%v", err) + } if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime( map[string]string{ "base-token": "b", diff --git a/shortcuts/base/field_list.go b/shortcuts/base/field_list.go index 7851a6f91..21b3d0586 100644 --- a/shortcuts/base/field_list.go +++ b/shortcuts/base/field_list.go @@ -18,9 +18,10 @@ var BaseFieldList = common.Shortcut{ AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), - tableRefFlag(true), + {Name: "table-id", Type: "string_array", Desc: "table ID (must start with tbl if ID) or name; repeat to list fields for multiple tables", Required: true}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, + {Name: "full", Type: "bool", Desc: "return full field objects (style/property/formula/lookup internals); default returns compact id/name/type/options for lower context cost"}, }, DryRun: dryRunFieldList, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 4774c95a7..c7de980e4 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -16,11 +16,14 @@ func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common. offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) - return common.NewDryRunAPI(). - GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). - Params(map[string]interface{}{"offset": offset, "limit": limit}). - Set("base_token", runtime.Str("base-token")). - Set("table_id", baseTableID(runtime)) + dry := common.NewDryRunAPI() + for _, tableIDValue := range fieldListTableRefs(runtime) { + dry.GET(baseV3Path("bases", runtime.Str("base-token"), "tables", tableIDValue, "fields")). + Params(map[string]interface{}{"offset": offset, "limit": limit}). + Set("base_token", runtime.Str("base-token")). + Set("table_id", tableIDValue) + } + return dry } func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -61,6 +64,7 @@ func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *commo } func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + fieldRef := fieldSearchOptionsRef(runtime) params := map[string]interface{}{ "offset": runtime.Int("offset"), "limit": runtime.Int("limit"), @@ -72,11 +76,11 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) params["query"] = keyword } return common.NewDryRunAPI(). - GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/options"). + GET(baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields", fieldRef, "options")). Params(params). Set("base_token", runtime.Str("base-token")). Set("table_id", baseTableID(runtime)). - Set("field_id", runtime.Str("field-id")) + Set("field_id", fieldRef) } func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { @@ -118,17 +122,88 @@ func executeFieldList(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) - fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) - if err != nil { - return err + tableRefs := fieldListTableRefs(runtime) + if len(tableRefs) == 1 { + fields, total, err := listAllFields(runtime, runtime.Str("base-token"), tableRefs[0], offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(fields) + } + if !runtime.Bool("full") { + fields = compactFields(fields) + } + runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil) + return nil } - if total == 0 { - total = len(fields) + + baseToken := runtime.Str("base-token") + results := make([]map[string]interface{}, 0, len(tableRefs)) + for _, tableRef := range tableRefs { + fields, total, err := listAllFields(runtime, baseToken, tableRef, offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(fields) + } + if !runtime.Bool("full") { + fields = compactFields(fields) + } + results = append(results, map[string]interface{}{ + "table_id": tableRef, + "fields": fields, + "total": total, + }) } - runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil) + runtime.Out(map[string]interface{}{"tables": results, "total": len(results)}, nil) return nil } +func fieldListTableRefs(runtime *common.RuntimeContext) []string { + refs := runtime.StrArray("table-id") + if len(refs) == 0 { + ref := baseTableID(runtime) + if ref != "" { + refs = []string{ref} + } + } + return refs +} + +// compactFields projects each field to the keys an agent needs for selection +// (id / name / type, plus select option names), dropping verbose display style, +// formula expressions and lookup internals that bloat agent context. Full detail +// stays available via `+field-list --full` or `+field-get`. +func compactFields(fields []map[string]interface{}) []map[string]interface{} { + keep := []string{"id", "name", "type", "is_primary", "ui_type", "description"} + out := make([]map[string]interface{}, 0, len(fields)) + for _, f := range fields { + c := map[string]interface{}{} + for _, k := range keep { + if v, ok := f[k]; ok { + c[k] = v + } + } + if opts, ok := f["options"].([]interface{}); ok && len(opts) > 0 { + names := make([]interface{}, 0, len(opts)) + for _, o := range opts { + if om, ok := o.(map[string]interface{}); ok { + if name, ok := om["name"]; ok { + names = append(names, name) + continue + } + } + names = append(names, o) + } + c["options"] = names + } + out = append(out, c) + } + return out +} + func executeFieldGet(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) @@ -184,10 +259,18 @@ func executeFieldDelete(runtime *common.RuntimeContext) error { return nil } +func fieldSearchOptionsRef(runtime *common.RuntimeContext) string { + fieldRef := runtime.Str("field-id") + if strings.TrimSpace(fieldRef) == "" { + fieldRef = runtime.Str("field-name") + } + return fieldRef +} + func executeFieldSearchOptions(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) - fieldRef := runtime.Str("field-id") + fieldRef := fieldSearchOptionsRef(runtime) params := map[string]interface{}{ "offset": runtime.Int("offset"), "limit": runtime.Int("limit"), diff --git a/shortcuts/base/field_search_options.go b/shortcuts/base/field_search_options.go index 1783d3681..865c56cf5 100644 --- a/shortcuts/base/field_search_options.go +++ b/shortcuts/base/field_search_options.go @@ -5,6 +5,7 @@ package base import ( "context" + "strings" "github.com/larksuite/cli/shortcuts/common" ) @@ -19,7 +20,8 @@ var BaseFieldSearchOptions = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - fieldRefFlag(true), + fieldRefFlag(false), + {Name: "field-name", Hidden: true}, {Name: "keyword", Desc: "keyword for option query"}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"}, @@ -29,6 +31,12 @@ var BaseFieldSearchOptions = common.Shortcut{ "Use only for fields with options, such as select or multi-select fields.", }, DryRun: dryRunFieldSearchOptions, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(fieldSearchOptionsRef(runtime)) == "" { + return baseFlagErrorf("--field-id is required") + } + return nil + }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeFieldSearchOptions(runtime) }, diff --git a/shortcuts/base/record_delete.go b/shortcuts/base/record_delete.go index 281376d5e..de29e6021 100644 --- a/shortcuts/base/record_delete.go +++ b/shortcuts/base/record_delete.go @@ -20,6 +20,7 @@ var BaseRecordDelete = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, + {Name: "record-ids", Type: "string_array", Hidden: true}, {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, }, Tips: []string{ diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go index f8d0b720a..1a7b0fb72 100644 --- a/shortcuts/base/record_get.go +++ b/shortcuts/base/record_get.go @@ -21,7 +21,9 @@ var BaseRecordGet = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, + {Name: "record-ids", Type: "string_array", Hidden: true}, {Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"}, + {Name: "field-names", Type: "string_array", Hidden: true}, {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, recordReadFormatFlag(), }, diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index b6489a5c8..56905c3ca 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -21,11 +21,15 @@ var BaseRecordList = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), recordListFieldRefFlag(), + {Name: "field-names", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), + {Name: recordFilterAliasFlag, Hidden: true, Input: []string{common.File}}, recordSortFlag(), + {Name: recordSortAliasFlag, Hidden: true, Input: []string{common.File}}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, + {Name: "page-size", Type: "int", Desc: "deprecated alias for --limit", Hidden: true}, recordReadFormatFlag(), }, Tips: []string{ @@ -45,6 +49,9 @@ var BaseRecordList = common.Shortcut{ if err := validateRecordReadFormat(runtime); err != nil { return err } + if runtime.Changed("limit") && runtime.Changed("page-size") { + return baseFlagErrorf("--page-size is a deprecated alias for --limit; use only one") + } return validateRecordQueryOptions(runtime) }, DryRun: dryRunRecordList, diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 1f5264097..8976b42ea 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -45,8 +45,8 @@ func validateRecordSelection(runtime *common.RuntimeContext) error { } func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) { - recordIDs := runtime.StrArray("record-id") - fieldIDs := runtime.StrArray("field-id") + recordIDs := recordIDFlags(runtime) + fieldIDs := recordListFields(runtime) jsonRaw := strings.TrimSpace(runtime.Str("json")) if len(recordIDs) > 0 && jsonRaw != "" { return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive") @@ -93,6 +93,14 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er }, nil } +func recordIDFlags(runtime *common.RuntimeContext) []string { + recordIDs := runtime.StrArray("record-id") + if len(recordIDs) == 0 { + recordIDs = runtime.StrArray("record-ids") + } + return recordIDs +} + func normalizeRecordIDs(values interface{}) ([]string, error) { return normalizeStringList(values, stringListNormalizeOptions{ typeError: "record selection must be a string array", @@ -207,7 +215,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common if offset < 0 { offset = 0 } - limit := common.ParseIntBounded(runtime, "limit", 1, 200) + limit := recordListLimit(runtime) params := url.Values{} params.Set("offset", strconv.Itoa(offset)) params.Set("limit", strconv.Itoa(limit)) @@ -341,7 +349,8 @@ func validateRecordShareBatch(runtime *common.RuntimeContext) error { } func deduplicateRecordIDs(runtime *common.RuntimeContext) []string { - raw := runtime.StrSlice("record-ids") + raw := append([]string{}, runtime.StrSlice("record-ids")...) + raw = append(raw, runtime.StrArray("record-id")...) seen := make(map[string]bool, len(raw)) result := make([]string, 0, len(raw)) for _, id := range raw { @@ -375,7 +384,15 @@ func validateRecordJSON(runtime *common.RuntimeContext) error { } func recordListFields(runtime *common.RuntimeContext) []string { - return runtime.StrArray("field-id") + fields := runtime.StrArray("field-id") + if len(fields) == 0 { + fields = recordListFieldAliases(runtime) + } + return fields +} + +func recordListFieldAliases(runtime *common.RuntimeContext) []string { + return runtime.StrArray("field-names") } func executeRecordList(runtime *common.RuntimeContext) error { @@ -386,7 +403,7 @@ func executeRecordList(runtime *common.RuntimeContext) error { if offset < 0 { offset = 0 } - limit := common.ParseIntBounded(runtime, "limit", 1, 200) + limit := recordListLimit(runtime) params := map[string]interface{}{"offset": offset, "limit": limit} fields := recordListFields(runtime) if len(fields) > 0 { @@ -409,6 +426,13 @@ func executeRecordList(runtime *common.RuntimeContext) error { return nil } +func recordListLimit(runtime *common.RuntimeContext) int { + if runtime.Changed("page-size") { + return common.ParseIntBounded(runtime, "page-size", 1, 200) + } + return common.ParseIntBounded(runtime, "limit", 1, 200) +} + func executeRecordGet(runtime *common.RuntimeContext) error { if err := validateRecordReadFormat(runtime); err != nil { return err diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go index 3341d66b0..d5e485af8 100644 --- a/shortcuts/base/record_query.go +++ b/shortcuts/base/record_query.go @@ -13,9 +13,11 @@ import ( ) const ( - recordFilterJSONFlag = "filter-json" - recordSortJSONFlag = "sort-json" - recordSortMaxCount = 10 + recordFilterJSONFlag = "filter-json" + recordSortJSONFlag = "sort-json" + recordFilterAliasFlag = "filter" + recordSortAliasFlag = "sort" + recordSortMaxCount = 10 ) func recordFilterFlag() common.Flag { @@ -43,7 +45,7 @@ func validateRecordQueryOptions(runtime *common.RuntimeContext) error { } func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) { - filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag)) + filterRaw := recordQueryFlagValue(runtime, recordFilterJSONFlag, recordFilterAliasFlag) if filterRaw == "" { return nil, nil } @@ -52,7 +54,7 @@ func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) } func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) { - sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag)) + sortRaw := recordQueryFlagValue(runtime, recordSortJSONFlag, recordSortAliasFlag) if sortRaw == "" { return nil, nil } @@ -64,6 +66,14 @@ func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) return normalizeRecordSortValue(value, "--"+recordSortJSONFlag) } +func recordQueryFlagValue(runtime *common.RuntimeContext, canonical string, alias string) string { + canonicalValue := strings.TrimSpace(runtime.Str(canonical)) + if canonicalValue != "" { + return canonicalValue + } + return strings.TrimSpace(runtime.Str(alias)) +} + func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) { var sortConfig []interface{} if parsed, ok := value.([]interface{}); ok { @@ -167,7 +177,7 @@ func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]inte func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { body := map[string]interface{}{} - if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + if keyword := recordSearchKeyword(runtime); keyword != "" { body["keyword"] = keyword } searchFields := runtime.StrArray("search-field") @@ -217,6 +227,9 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error { if err := validateRecordReadFormat(runtime); err != nil { return err } + if strings.TrimSpace(runtime.Str("keyword")) != "" && strings.TrimSpace(runtime.Str("query")) != "" { + return baseFlagErrorf("--query is a deprecated alias for --keyword; use only one") + } jsonRaw := strings.TrimSpace(runtime.Str("json")) if jsonRaw != "" { if recordSearchHasJSONExclusiveFlagInputs(runtime) { @@ -225,7 +238,7 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error { _, err := recordSearchJSONBody(runtime) return err } - if strings.TrimSpace(runtime.Str("keyword")) == "" { + if recordSearchKeyword(runtime) == "" { return baseFlagErrorf("--keyword is required unless --json is used") } if len(runtime.StrArray("search-field")) == 0 { @@ -235,14 +248,22 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error { } func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool { - return strings.TrimSpace(runtime.Str("keyword")) != "" || + return recordSearchKeyword(runtime) != "" || len(runtime.StrArray("search-field")) > 0 || len(recordListFields(runtime)) > 0 || + len(recordListFieldAliases(runtime)) > 0 || runtime.Str("view-id") != "" || runtime.Changed("offset") || runtime.Changed("limit") } +func recordSearchKeyword(runtime *common.RuntimeContext) string { + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + return keyword + } + return strings.TrimSpace(runtime.Str("query")) +} + func formatRecordQueryPriorityTip() string { return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag) } diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index 586e5071a..fe9de834b 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -22,11 +22,15 @@ var BaseRecordSearch = common.Shortcut{ tableRefFlag(true), {Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`}, {Name: "keyword", Desc: "keyword for record search; required unless --json is used"}, + {Name: "query", Desc: "deprecated alias for --keyword", Hidden: true}, {Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"}, recordListFieldRefFlag(), + {Name: "field-names", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), + {Name: recordFilterAliasFlag, Hidden: true, Input: []string{common.File}}, recordSortFlag(), + {Name: recordSortAliasFlag, Hidden: true, Input: []string{common.File}}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), diff --git a/shortcuts/base/record_share_link_create.go b/shortcuts/base/record_share_link_create.go index 522369fcb..28214dda6 100644 --- a/shortcuts/base/record_share_link_create.go +++ b/shortcuts/base/record_share_link_create.go @@ -19,7 +19,8 @@ var BaseRecordShareLinkCreate = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true}, + {Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)"}, + {Name: "record-id", Type: "string_array", Desc: "hidden alias for --record-ids", Hidden: true}, }, Tips: []string{ `Example: lark-cli base +record-share-link-create --base-token --table-id --record-ids `, diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index 909709122..e3b87154b 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -60,7 +60,8 @@ func executeTableList(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 100) - tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit) + baseToken := runtime.Str("base-token") + tables, total, err := listAllTables(runtime, baseToken, offset, limit) if err != nil { return err } diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index 187ac3e73..146b6ca30 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -45,6 +45,11 @@ func driveExportTaskResultCommand(ticket, docToken string) string { // driveExportDownloadCommand prints a copy-pasteable follow-up command for // downloading an already-generated export artifact by file token. func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite bool) string { + cdPrefix := "" + if strings.TrimSpace(outputDir) != "" && filepath.IsAbs(outputDir) { + cdPrefix = "cd " + strconv.Quote(outputDir) + " && " + outputDir = "." + } parts := []string{ "lark-cli", "drive", "+export-download", "--file-token", strconv.Quote(fileToken), @@ -52,13 +57,13 @@ func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite if strings.TrimSpace(fileName) != "" { parts = append(parts, "--file-name", strconv.Quote(fileName)) } - if strings.TrimSpace(outputDir) != "" && outputDir != "." { + if strings.TrimSpace(outputDir) != "" && (outputDir != "." || cdPrefix != "") { parts = append(parts, "--output-dir", strconv.Quote(outputDir)) } if overwrite { parts = append(parts, "--overwrite") } - return strings.Join(parts, " ") + return cdPrefix + strings.Join(parts, " ") } // driveExportStatus captures the fields needed to decide whether the export is diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 619c9d43d..aefde911f 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -723,6 +723,14 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) { } } +func TestDriveExportDownloadCommandUsesCdForAbsoluteOutputDir(t *testing.T) { + got := driveExportDownloadCommand("box_ready", "report.pdf", "/tmp", true) + want := `cd "/tmp" && lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf" --output-dir "." --overwrite` + if got != want { + t.Fatalf("command=%q want %q", got, want) + } +} + func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index 8c5c9a2c8..17282bebd 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -31,7 +31,10 @@ var DriveImport = common.Shortcut{ {Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true}, {Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"}, {Name: "name", Desc: "imported file name (default: local file name without extension)"}, - {Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"}, + {Name: "target-token", Desc: "existing token to import data into (only for type=bitable); verify the returned verification_token, not the import task token"}, + }, + Tips: []string{ + "When --target-token is set, data is mounted into that existing Base; verify output.verification_token with lark-cli base +base-get.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveImportSpec(driveImportSpec{ @@ -139,6 +142,14 @@ var DriveImport = common.Shortcut{ if status.Extra != nil { out["extra"] = status.Extra } + if spec.TargetToken != "" { + out["target_token"] = spec.TargetToken + out["verification_token"] = spec.TargetToken + if u := common.BuildResourceURL(runtime.Config.Brand, "bitable", spec.TargetToken); u != "" { + out["verification_url"] = u + } + out["verify_hint"] = fmt.Sprintf("because --target-token was used, verify the existing target Base with: lark-cli base +base-get --base-token %s", spec.TargetToken) + } if !ready { nextCommand := driveImportTaskResultCommand(ticket) fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand) diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go index ba5407531..f9b65da09 100644 --- a/shortcuts/drive/drive_import_test.go +++ b/shortcuts/drive/drive_import_test.go @@ -762,3 +762,43 @@ func TestDriveImportFallbackURLForSlides(t *testing.T) { t.Fatalf("data.url = %#v, want %q (slides fallback)", got, want) } } + +func TestDriveImportTargetTokenOutputsVerificationToken(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("target-token")) + driveImportMockEnv(t, reg, "ticket_target", map[string]interface{}{ + "token": "bascn_backend_result", + "type": "bitable", + "job_status": float64(0), + }) + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir: %v", err) + } + defer os.Chdir(origDir) + if err := os.WriteFile("snapshot.base", []byte("fake-base"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := mountAndRunDrive(t, DriveImport, []string{ + "+import", "--file", "snapshot.base", "--type", "bitable", "--target-token", "bascn_target", "--as", "user", + }, f, stdout); err != nil { + t.Fatalf("import should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["token"], "bascn_backend_result"; got != want { + t.Fatalf("data.token = %#v, want backend result token %q", got, want) + } + if got, want := data["verification_token"], "bascn_target"; got != want { + t.Fatalf("data.verification_token = %#v, want target token %q", got, want) + } + if got, want := data["target_token"], "bascn_target"; got != want { + t.Fatalf("data.target_token = %#v, want target token %q", got, want) + } + hint, _ := data["verify_hint"].(string) + if !strings.Contains(hint, "lark-cli base +base-get --base-token bascn_target") { + t.Fatalf("verify_hint = %q, want target-token verification command", hint) + } +} diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 1bfab7198..8064a1125 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -35,6 +35,15 @@ metadata: - Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。 - 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。 +## 执行节奏 + +- 目标明确、边界清晰的简单读写任务(查表/字段/视图/少量记录、单字段新增/更新/删除、单视图配置、明确记录写入/删除)不要先建 Todo 或长计划,直接按最少 `lark-cli base +...` 命令执行并必要时验证。 +- 仅在多阶段批量写入、跨多个对象且有依赖、workflow/dashboard/role 等复杂配置时使用 Todo/分步计划。 + +## 导出/导入快路径 + +- Base 导出再导入:source 用用户给的 `/base/{token}`;target 用 `drive +search --doc-types bitable --only-title --format json` 定位;`cd` 到工作目录后用 `drive +export --output-dir .` 和 `drive +import --file ./name.base --target-token `;异步导入续查用 `drive +task_result --scenario import --ticket `;导入返回新 token 时仍先验证原 `target`。 + ## 快速路由 | 用户目标 | 优先命令 | 何时读 reference | @@ -43,7 +52,7 @@ metadata: | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | | 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | -| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | +| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除;需要多表结构时先 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` 批量拿字段,避免逐表多次调用 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | @@ -129,8 +138,12 @@ metadata: ## Dashboard / Workflow / Role -- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 +- Dashboard 创建/改图前先用 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` 批量拿字段(不要逐表多次 `+field-list`,多余调用会显著抬高 token)。 +- Dashboard 快路径:布局/重排/撑满/排列美观直接用 `+dashboard-arrange`,不要尝试用 `+dashboard-block-update` 修改 layout,layout 不是 `data_config`。 +- Dashboard block 换图表类型或换数据源表(`table_name`)时,删除旧 block 后用 `+dashboard-block-create` 新建;`+dashboard-block-update` 只适合同一数据源内改 `series/filter/group_by/name`。 +- 删除具名图表:`+dashboard-list` → `+dashboard-block-list` 精确匹配名称 → `+dashboard-block-delete`;长 `block_id` 用变量传参,避免手抄截断。 +- 创建或更新 block 数据配置前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;完整 dashboard 用例按需读 [lark-base-dashboard-usecase.md](references/lark-base-dashboard-usecase.md),复杂/完整 data_config 模板按需读 [dashboard-block-data-config-full.md](references/dashboard-block-data-config-full.md)。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 +- Workflow 查询优先走上方快速查询,不读完整 reference;创建、更新或复用复杂 `steps` 时才读 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)。 - Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 ## 常见恢复 diff --git a/skills/lark-base/references/dashboard-block-data-config.md b/skills/lark-base/references/dashboard-block-data-config.md index 92e27478a..806e03f35 100644 --- a/skills/lark-base/references/dashboard-block-data-config.md +++ b/skills/lark-base/references/dashboard-block-data-config.md @@ -2,6 +2,8 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档是 dashboard block `data_config` 的单一事实来源(SSOT),包含组件类型、字段结构、筛选格式、约束和可复制模板。 +`data_config` 是 dashboard block 的数据源配置。先用 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` **一次批量**拿到相关表字段(不要逐表多次 `+field-list`,每次多余调用都拉高 token);表用 **name**,不是 table_id;字段用 **field_name**。 + ## 支持的组件类型(`type` 枚举) | type 值 | 说明 | diff --git a/skills/lark-base/references/lark-base-dashboard-usecase.md b/skills/lark-base/references/lark-base-dashboard-usecase.md new file mode 100644 index 000000000..3a6e5c235 --- /dev/null +++ b/skills/lark-base/references/lark-base-dashboard-usecase.md @@ -0,0 +1,238 @@ +# Dashboard(仪表盘/数据看板)模块指引 + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**组件**(图表、指标卡等)进行展示。 + +## 核心概念 + +- **Dashboard(仪表盘)**:容器,包含多个组件 +- **Block(组件)**:仪表盘中的单个可视化元素(柱状图、折线图、饼图、指标卡等) +- **data_config**:组件的数据源配置(表名、字段、分组等) + +## 能力速览 + +| 你想做什么 | 用这些命令 | 关键文档 | +|------|-----------|---------| +| 创建/删除/改名称 | `+dashboard-create/delete/update` | 本页下方「仪表盘管理」 | +| 在仪表盘里添加组件 | `+dashboard-block-create` | 先用 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` 批量拿字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` | +| 修改组件 | `+dashboard-block-update` | 先读 block 现状,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 决定替换哪些顶层 key | +| 查看仪表盘有哪些组件 | `+dashboard-get` 或 `+dashboard-block-list` | 本页下方「查看仪表盘」 | +| 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` | +| 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 | + +## 典型场景工作流 + +### 场景 1:从 0 到 1 创建仪表盘 + +示例:搭建一个销售数据分析仪表盘 + +```bash +# 第 1 步:创建空白仪表盘 +lark-cli base +dashboard-create --base-token xxx --name "销售数据分析" +# 记录返回的 dashboard_id + +# 第 2 步:获取数据源信息 +lark-cli base +table-list --base-token xxx # 先拿表名/table_id +lark-cli base +field-list --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 + +# 第 3 步:规划应该创建哪些组件(根据用户需求确定组件类型和数量) +# 例如:总销售额(指标卡)、月度趋势(折线图)、品类占比(饼图) + +# 第 4 步:顺序创建每个组件(必须串行执行,不能并发) +# 重要:创建组件前,先确定 dashboard_id、组件 name/type 和真实表字段 +# 再阅读 dashboard-block-data-config.md 了解 data_config 结构、组件类型和 filter 规则 + +# 第 1 个组件 +lark-cli base +dashboard-block-create \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --name "总销售额" \ + --type statistics \ + --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}]}' + +# 第 2 个组件(等上一个完成后再执行) +lark-cli base +dashboard-block-create \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --name "月度趋势" \ + --type line \ + --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated"}]}' + +# 继续创建其他组件... + +# 第 5 步:组件创建完成后,使用 arrange 命令智能重排布局(可选但推荐) +# 默认布局可能不够美观,arrange 会根据组件数量和类型自动优化布局 +lark-cli base +dashboard-arrange \ + --base-token xxx \ + --dashboard-id blk_xxx +``` + +### 场景 2:在已有仪表盘上添加新组件 + +```bash +# 第 1 步:列出仪表盘,定位到当前仪表盘 +lark-cli base +dashboard-list --base-token xxx +# 获取目标 dashboard_id + +# 第 2 步:根据用户诉求规划组件类型和数据源 +# 建议先查看当前仪表盘已有组件,避免重复创建,或作为参考 +lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx + +# 第 3 步:获取数据源信息 +lark-cli base +table-list --base-token xxx # 先拿表名/table_id +lark-cli base +field-list --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 + +# 第 4 步:顺序创建每个新组件(必须串行执行,不能并发) +# 重要:先确定 dashboard_id、组件 name/type 和真实表字段 +# 再阅读 dashboard-block-data-config.md 了解 data_config 结构 +lark-cli base +dashboard-block-create \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --name "新组件名" \ + --type column \ + --data-config '{...}' +``` + +### 场景 3:编辑已有组件 + +> [!IMPORTANT] +> `+dashboard-block-update` **不能修改组件的 `type`**(图表类型),只能更新 `name` 和 `data_config`。 +> 如需更换组件类型,必须先删除再重新创建。 + +```bash +# 第 1 步:列出仪表盘,定位到当前仪表盘 +lark-cli base +dashboard-list --base-token xxx + +# 第 2 步:列出组件,获取到目标组件 +lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx +# 获取目标 block_id +# 提示:查看已有组件可作为参考,或检查是否重复创建相似组件 + +# 第 3 步:获取组件当前详情 +lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx + +# 第 4 步:根据用户编辑诉求准备更新 +# 如果编辑诉求涉及数据源变更,需要先获取数据源信息 +lark-cli base +table-list --base-token xxx # 先拿表名/table_id +lark-cli base +field-list --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 + +# 第 5 步:执行更新 +# 重要:先读取当前 block 的 name/type/data_config +# 再阅读 dashboard-block-data-config.md 了解 data_config 更新规则 +lark-cli base +dashboard-block-update \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --block-id chtxxxxxxxx \ + --data-config '{...}' +``` + +### 场景 4:重排仪表盘布局 + +当用户明确要求对已有仪表盘进行布局重排或美化时使用。 + +> [!CAUTION] +> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期 +> - 无法指定具体位置(如"第一排放 A,第二排放 B"),排列逻辑是**自适应**的 +> - **不建议**在已有仪表盘上自动调用,除非用户明确要求 + +```bash +# 第 1 步:列出仪表盘,定位到目标仪表盘 +lark-cli base +dashboard-list --base-token xxx + +# 第 2 步:执行智能重排 +lark-cli base +dashboard-arrange \ + --base-token xxx \ + --dashboard-id blk_xxx +``` + +### 场景 5:读取仪表盘或组件现状 + +**选择查询方式:** +- 想看仪表盘整体结构(含主题、所有组件名称和类型)→ 用 **方式 A** +- 只想快速查看有哪些组件 → 用 **方式 B** +- 想看某个组件的详细 data_config 配置 → 用 **方式 C** +- 想看某个图表/指标卡实际算出来的数据 → 用 **方式 D** + +```bash +# 第 1 步:列出仪表盘,定位到当前仪表盘 +lark-cli base +dashboard-list --base-token xxx + +# 第 2 步:根据用户诉求查看详情 + +# 方式 A:查看仪表盘整体情况(包含所有组件列表) +lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx + +# 方式 B:列出所有组件 +lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx + +# 方式 C:查看某个组件的详细配置 +lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx + +# 方式 D:查看某个图表组件的计算结果(AI 友好的 chart protocol) +lark-cli base +dashboard-block-get-data --base-token xxx --block-id chtxxxxxxxx + +# 最后:把获取到的现状信息整理好告诉用户 +``` + +## 组件类型选择 + +组件 `type` 决定展示形式: + +| 用户想看什么 | 选什么 type | 说明 | +|-------------|------------|------| +| 数据趋势(时间变化) | line | 折线图组件 | +| 类别比较(谁高谁低) | column | 柱状图组件 | +| 占比分布(各部分比例) | pie | 饼图组件 | +| 单个关键指标 | statistics | 指标卡组件 | +| 富文本说明/标题/注释 | text | 文本组件(支持 Markdown) | + +详细组件类型和 data_config 完整规则:[dashboard-block-data-config.md](dashboard-block-data-config.md) + +## 常见问题 + +**Q: 创建组件的命令和 data_config 怎么写?** +A: +1. 先确定 `dashboard_id`、组件 `name`、组件 `type` 和真实表字段 +2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解: + - 全部组件类型的可复制模板 + - filter 筛选条件格式 + - 字段类型与操作符对应表 + +**Q: 为什么组件创建失败了?** +A: 常见原因: +- `table_name` 用了 table_id 而不是表名(必须用表名称,如「订单表」) +- `series` 和 `count_all` 同时存在(必须二选一,互斥) +- 字段名拼写错误(必须用 `+field-list` 获取的真实字段名,禁止猜测) +- 组件创建并发执行(必须串行,等上一个完成再执行下一个) + +**Q: 可以一次创建多个组件吗?** +A: 不可以,必须串行执行。等上一个 `+dashboard-block-create` 完成后再执行下一个。 + +**Q: 组件的 `type` 创建后能改吗?** +A: 不能。`+dashboard-block-update` 只能修改 `name` 和 `data_config`,不能修改 `type`。 + +**Q: 更新组件的命令和 data_config 怎么写?** +A: +1. 先读取当前 block,确认 `block_id`、当前 `type` 和已有 `data_config` +2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解 data_config 结构 + +**data_config 更新策略(顶层 key merge)**: +- 只传入需要修改的顶层字段(如 `series`、`filter`) +- 未传的顶层字段(如 `group_by`)自动保留原值 +- 但每个传入的字段内部是**全量替换**(如传新 `filter` 会完整覆盖旧 `filter`) + +**Q: 查看已有组件有什么用?** +A: 在「添加新组件」或「编辑组件」前查看已有组件可以: +- 了解当前仪表盘已有哪些可视化 +- 避免重复创建相似的组件 +- 参考已有组件的 data_config 结构作为模板 + +**Q: 我想直接拿图表算好的结果给 AI 分析,应该用什么?** +A: 用 `+dashboard-block-get-data`。它返回图表协议 JSON(常见字段包括 `dimensions`、`measures`、`main_data`,指标卡可能还有 `comparison_data`、`trend_data`),不返回 block 名称、类型、布局或 `data_config`;需要这些元数据时先用 `+dashboard-block-get`。 + +## 写入前检查 + +- 创建 block 前必须知道 `base_token`、`dashboard_id`、组件 `name/type` 和 `data_config`。 +- 更新 block 前必须知道 `base_token`、`dashboard_id`、`block_id`,并读过当前 block。 +- `data_config` 中使用表名和字段名,不使用 table_id / field_id;名称必须来自 `+table-list` / `+field-list` 的真实返回。 diff --git a/skills/lark-base/references/lark-base-dashboard.md b/skills/lark-base/references/lark-base-dashboard.md index 87aaaff90..d5cc35488 100644 --- a/skills/lark-base/references/lark-base-dashboard.md +++ b/skills/lark-base/references/lark-base-dashboard.md @@ -21,6 +21,11 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成** | 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` | | 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 | +硬规则: + +- 删除并用 `+dashboard-get` 复核 `not_found` 后,用户只回复“确认/好的/收到”视为结束,不要再次创建或保留同名/正式版仪表盘。 +- 用户只说“更新标题”但未给新标题时,可基于原名生成一次新标题;先用 `+dashboard-list` 避开已存在名称,遇同名冲突换名更新,不要创建新仪表盘。 + ## 典型场景工作流 ### 场景 1:从 0 到 1 创建仪表盘 diff --git a/skills/lark-drive/references/lark-drive-import.md b/skills/lark-drive/references/lark-drive-import.md index 9b5d35aa0..139689768 100644 --- a/skills/lark-drive/references/lark-drive-import.md +++ b/skills/lark-drive/references/lark-drive-import.md @@ -48,6 +48,7 @@ lark-cli drive +import --file ./data.csv --type bitable --folder-token +# 成功后验证 ;不要拿返回中的导入任务 token 当作 Base token 复核 # 预览底层调用链(上传 -> 创建任务 -> 轮询) lark-cli drive +import --file ./README.md --type docx --dry-run @@ -72,7 +73,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run 2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数 3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令 - **默认根目录行为**:不传 `--folder-token` 时,shortcut 会保留空的 `point.mount_key`,Lark Import API 会将其视为"导入到调用者根目录"。 -- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格的 token,point 挂载点逻辑不变。数据会挂载到该已有多维表格中,而非创建新文档。 +- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格。数据会挂载到该已有多维表格中,而非创建新文档;完成后用输出的 `verification_token`(即传入的 `--target-token`)复核,不要改用返回的导入任务 `token` 做 Base 查询。 ### 支持的文件类型转换 From 62fd7270ba47a82d1a11120daf566d4fbdbb769a Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Wed, 10 Jun 2026 16:53:46 +0800 Subject: [PATCH 02/17] fix: address eval feedback on base token optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - revert field-list multi-table change; add dedicated +field-list-batch shortcut - make compact field output opt-in via --compact, default stays full (avoid breaking change) - compact output now keeps style - SKILL.md: drop 执行节奏/导出导入快路径 sections, restore Dashboard/Workflow/Role wording, sink new guidance into dashboard/workflow references - SKILL.md: add glossary (base-block vs dashboard-block disambiguation), batch-execution and help-first rules - field-json: note auto_number rule updates apply to existing records by default --- shortcuts/base/base_dryrun_ops_test.go | 9 ++- shortcuts/base/base_execute_test.go | 27 +++++++- shortcuts/base/base_shortcuts_test.go | 2 +- shortcuts/base/field_list.go | 4 +- shortcuts/base/field_list_batch.go | 30 ++++++++ shortcuts/base/field_ops.go | 69 ++++++++++--------- shortcuts/base/shortcuts.go | 1 + skills/lark-base/SKILL.md | 55 +++++++++++---- .../references/dashboard-block-data-config.md | 2 +- .../references/lark-base-dashboard-usecase.md | 2 +- .../references/lark-base-dashboard.md | 8 +++ .../references/lark-base-field-json.md | 1 + .../references/lark-base-workflow-guide.md | 2 + 13 files changed, 154 insertions(+), 58 deletions(-) create mode 100644 shortcuts/base/field_list_batch.go diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index fef92dc6f..bf74c61b5 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -58,21 +58,20 @@ func TestDryRunBaseBlockOps(t *testing.T) { func TestDryRunFieldOps(t *testing.T) { ctx := context.Background() - listRT := newBaseTestRuntimeWithArrays( - map[string]string{"base-token": "app_x"}, - map[string][]string{"table-id": {"tbl_1"}}, + listRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, nil, map[string]int{"offset": -2, "limit": 999}, ) assertDryRunContains(t, dryRunFieldList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "offset=0", "limit=200") - multiListRT := newBaseTestRuntimeWithArrays( + batchListRT := newBaseTestRuntimeWithArrays( map[string]string{"base-token": "app_x"}, map[string][]string{"table-id": {"tbl_1", "tbl_2"}}, nil, map[string]int{"offset": 0, "limit": 50}, ) - assertDryRunContains(t, dryRunFieldList(ctx, multiListRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "GET /open-apis/base/v3/bases/app_x/tables/tbl_2/fields", "limit=50") + assertDryRunContains(t, dryRunFieldListBatch(ctx, batchListRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "GET /open-apis/base/v3/bases/app_x/tables/tbl_2/fields", "limit=50") rt := newBaseTestRuntime( map[string]string{ diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 03a634eb9..a13dee2dc 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -855,7 +855,7 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) - t.Run("list multiple tables", func(t *testing.T) { + t.Run("list batch multiple tables", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "GET", @@ -877,11 +877,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { }, "total": 1}, }, }) - if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "tbl_b"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "tbl_b", "--compact"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"options": [`) || !strings.Contains(got, `"Todo"`) || strings.Contains(got, `"style"`) || strings.Contains(got, `"color"`) { + if !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"options": [`) || !strings.Contains(got, `"Todo"`) || !strings.Contains(got, `"style"`) || strings.Contains(got, `"color"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("list batch default keeps full fields", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}}, + }, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_b"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"color": "red"`) { t.Fatalf("stdout=%s", got) } }) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index d8a29194c..e0e495fde 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -199,7 +199,7 @@ func TestShortcutsCatalog(t *testing.T) { want := []string{ "+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete", "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", - "+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", + "+field-list", "+field-list-batch", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", "+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename", "+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete", "+record-history-list", diff --git a/shortcuts/base/field_list.go b/shortcuts/base/field_list.go index 21b3d0586..5b135d4f1 100644 --- a/shortcuts/base/field_list.go +++ b/shortcuts/base/field_list.go @@ -18,10 +18,10 @@ var BaseFieldList = common.Shortcut{ AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), - {Name: "table-id", Type: "string_array", Desc: "table ID (must start with tbl if ID) or name; repeat to list fields for multiple tables", Required: true}, + tableRefFlag(true), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, - {Name: "full", Type: "bool", Desc: "return full field objects (style/property/formula/lookup internals); default returns compact id/name/type/options for lower context cost"}, + {Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"}, }, DryRun: dryRunFieldList, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_list_batch.go b/shortcuts/base/field_list_batch.go new file mode 100644 index 000000000..544ad41be --- /dev/null +++ b/shortcuts/base/field_list_batch.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldListBatch = common.Shortcut{ + Service: "base", + Command: "+field-list-batch", + Description: "List fields for multiple tables in one call", + Risk: "read", + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "table-id", Type: "string_array", Desc: "table ID (must start with tbl if ID) or name; repeat to list fields for multiple tables", Required: true}, + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, + {Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"}, + }, + DryRun: dryRunFieldListBatch, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldListBatch(runtime) + }, +} diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index c7de980e4..129dfbaba 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -11,13 +11,26 @@ import ( ) func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). + Params(map[string]interface{}{"offset": offset, "limit": limit}). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunFieldListBatch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") if offset < 0 { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) dry := common.NewDryRunAPI() - for _, tableIDValue := range fieldListTableRefs(runtime) { + for _, tableIDValue := range runtime.StrArray("table-id") { dry.GET(baseV3Path("bases", runtime.Str("base-token"), "tables", tableIDValue, "fields")). Params(map[string]interface{}{"offset": offset, "limit": limit}). Set("base_token", runtime.Str("base-token")). @@ -122,23 +135,28 @@ func executeFieldList(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) - tableRefs := fieldListTableRefs(runtime) - if len(tableRefs) == 1 { - fields, total, err := listAllFields(runtime, runtime.Str("base-token"), tableRefs[0], offset, limit) - if err != nil { - return err - } - if total == 0 { - total = len(fields) - } - if !runtime.Bool("full") { - fields = compactFields(fields) - } - runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil) - return nil + fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(fields) } + if runtime.Bool("compact") { + fields = compactFields(fields) + } + runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil) + return nil +} +func executeFieldListBatch(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) baseToken := runtime.Str("base-token") + tableRefs := runtime.StrArray("table-id") results := make([]map[string]interface{}, 0, len(tableRefs)) for _, tableRef := range tableRefs { fields, total, err := listAllFields(runtime, baseToken, tableRef, offset, limit) @@ -148,7 +166,7 @@ func executeFieldList(runtime *common.RuntimeContext) error { if total == 0 { total = len(fields) } - if !runtime.Bool("full") { + if runtime.Bool("compact") { fields = compactFields(fields) } results = append(results, map[string]interface{}{ @@ -161,23 +179,12 @@ func executeFieldList(runtime *common.RuntimeContext) error { return nil } -func fieldListTableRefs(runtime *common.RuntimeContext) []string { - refs := runtime.StrArray("table-id") - if len(refs) == 0 { - ref := baseTableID(runtime) - if ref != "" { - refs = []string{ref} - } - } - return refs -} - // compactFields projects each field to the keys an agent needs for selection -// (id / name / type, plus select option names), dropping verbose display style, -// formula expressions and lookup internals that bloat agent context. Full detail -// stays available via `+field-list --full` or `+field-get`. +// (id / name / type / style, plus select option names), dropping formula +// expressions and lookup internals that bloat agent context. Opt-in via +// `--compact`; the default output keeps full field objects. func compactFields(fields []map[string]interface{}) []map[string]interface{} { - keep := []string{"id", "name", "type", "is_primary", "ui_type", "description"} + keep := []string{"id", "name", "type", "is_primary", "ui_type", "description", "style"} out := make([]map[string]interface{}, 0, len(fields)) for _, f := range fields { c := map[string]interface{}{} diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index 42fa883aa..c1d135b63 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -19,6 +19,7 @@ func Shortcuts() []common.Shortcut { BaseTableUpdate, BaseTableDelete, BaseFieldList, + BaseFieldListBatch, BaseFieldGet, BaseFieldCreate, BaseFieldUpdate, diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 8064a1125..6e486dc0e 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -35,14 +35,24 @@ metadata: - Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。 - 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。 -## 执行节奏 +## 名词与概念 -- 目标明确、边界清晰的简单读写任务(查表/字段/视图/少量记录、单字段新增/更新/删除、单视图配置、明确记录写入/删除)不要先建 Todo 或长计划,直接按最少 `lark-cli base +...` 命令执行并必要时验证。 -- 仅在多阶段批量写入、跨多个对象且有依赖、workflow/dashboard/role 等复杂配置时使用 Todo/分步计划。 +| 名词 | 含义 | +|---|---| +| Base / 多维表格 / Bitable | 同一个东西:`/base/{token}` 链接对应的整个文档容器,token 即 `--base-token`;Bitable 是曾用名,只出现在历史 API 和返回字段里 | +| Table(数据表) | Base 内的一张数据表,ID `tbl` 开头;列是 field,行是 record | +| Field(字段)/ Record(记录) | 表的列与行;字段 ID `fld` 开头,记录 ID `rec` 开头 | +| View(视图) | 同一张 table 的一种展示配置(筛选/排序/分组等),ID `viw` 开头 | +| Form(表单) | 收集数据的入口,提交结果写入对应 table 的记录 | +| Workflow(工作流) | Base 内的自动化流程,ID `wkf` 开头,由 steps(trigger + action)组成 | +| Dashboard(仪表盘) | 数据可视化容器,ID `blk` 开头——因为它本身是 Base 资源目录里的一个 block,见下方歧义说明 | +| Chart(图表/组件) | dashboard 内的单个可视化组件(柱状图/饼图/指标卡等),其 `block_id` 是 `cht` 开头 | -## 导出/导入快路径 +**`block` 是易混淆词,同名不同义,按命令域区分:** -- Base 导出再导入:source 用用户给的 `/base/{token}`;target 用 `drive +search --doc-types bitable --only-title --format json` 定位;`cd` 到工作目录后用 `drive +export --output-dir .` 和 `drive +import --file ./name.base --target-token `;异步导入续查用 `drive +task_result --scenario import --ticket `;导入返回新 token 时仍先验证原 `target`。 +- **Base block**(`+base-block-*`):Base 资源目录里的节点,table/docx/dashboard/workflow/folder 在目录层面统称 block——所以 dashboard 的 ID 是 `blk` 开头。“这个 Base 里有哪些东西” → `+base-block-list`。 +- **Dashboard block**(`+dashboard-block-*`):仪表盘内部的图表组件(即 chart),`block_id` 为 `cht` 开头。“仪表盘里的图表/卡片” → `+dashboard-block-*`。 +- 判别:操作需要传 `--dashboard-id`(`blk` 开头)的 block 是图表组件;没有 dashboard 上下文的 block 是 Base 目录资源。 ## 快速路由 @@ -52,8 +62,8 @@ metadata: | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | | 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | -| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除;需要多表结构时先 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` 批量拿字段,避免逐表多次调用 | -| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | +| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | +| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段;需要多表结构时先 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,避免逐表多次调用 | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | @@ -70,6 +80,27 @@ metadata: | Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 | | 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 | +## 批量执行 + +- 执行不熟悉的命令前先看 `--help`,不要猜参数名或 JSON 结构;本轮任务会用到多个命令时,把它们的 `--help` 合并在一条 Bash 命令里一次看完,不要一轮对话只看一个 help: + +```bash +lark-cli base +table-list --help; lark-cli base +field-list --help; lark-cli base +field-update --help +``` + +- 优先用原生批量能力:多表字段 `+field-list-batch`;批量写记录 `+record-batch-create` / `+record-batch-update`;部分命令参数本身支持多值(如 `+record-delete --record-id` 可重复传、`+record-share-link-create --record-ids`),先看 `--help`。 +- 没有原生批量命令时,对多个对象做同类操作要在**一条 Bash 命令**里用 shell 循环完成,不要一轮对话只执行一个命令、看完结果再发下一个。 +- 循环内先 `echo` 对象标识再执行,失败可定位到具体对象;写同一张表保持串行;只读命令可用 `--jq` 收窄输出,避免无关字段灌入上下文。 + +示例——一次取多个视图的配置: + +```bash +for v in vewAAA vewBBB vewCCC; do + echo "== $v" + lark-cli base +view-get --base-token --table-id --view-id "$v" --as user +done +``` + ## Base 心智模型 - Base 曾用名 Bitable;返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。 @@ -138,12 +169,8 @@ metadata: ## Dashboard / Workflow / Role -- Dashboard 创建/改图前先用 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` 批量拿字段(不要逐表多次 `+field-list`,多余调用会显著抬高 token)。 -- Dashboard 快路径:布局/重排/撑满/排列美观直接用 `+dashboard-arrange`,不要尝试用 `+dashboard-block-update` 修改 layout,layout 不是 `data_config`。 -- Dashboard block 换图表类型或换数据源表(`table_name`)时,删除旧 block 后用 `+dashboard-block-create` 新建;`+dashboard-block-update` 只适合同一数据源内改 `series/filter/group_by/name`。 -- 删除具名图表:`+dashboard-list` → `+dashboard-block-list` 精确匹配名称 → `+dashboard-block-delete`;长 `block_id` 用变量传参,避免手抄截断。 -- 创建或更新 block 数据配置前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;完整 dashboard 用例按需读 [lark-base-dashboard-usecase.md](references/lark-base-dashboard-usecase.md),复杂/完整 data_config 模板按需读 [dashboard-block-data-config-full.md](references/dashboard-block-data-config-full.md)。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 查询优先走上方快速查询,不读完整 reference;创建、更新或复用复杂 `steps` 时才读 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)。 +- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 +- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 - Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 ## 常见恢复 @@ -172,6 +199,6 @@ metadata: - [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释 - [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON - [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON -- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议 +- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md) / [lark-base-dashboard-usecase.md](references/lark-base-dashboard-usecase.md):仪表盘、组件配置、图表结果协议与完整用例 - [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT - [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT diff --git a/skills/lark-base/references/dashboard-block-data-config.md b/skills/lark-base/references/dashboard-block-data-config.md index 806e03f35..ece680358 100644 --- a/skills/lark-base/references/dashboard-block-data-config.md +++ b/skills/lark-base/references/dashboard-block-data-config.md @@ -2,7 +2,7 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档是 dashboard block `data_config` 的单一事实来源(SSOT),包含组件类型、字段结构、筛选格式、约束和可复制模板。 -`data_config` 是 dashboard block 的数据源配置。先用 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` **一次批量**拿到相关表字段(不要逐表多次 `+field-list`,每次多余调用都拉高 token);表用 **name**,不是 table_id;字段用 **field_name**。 +`data_config` 是 dashboard block 的数据源配置。先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` **一次批量**拿到相关表字段(不要逐表多次 `+field-list`,每次多余调用都拉高 token);表用 **name**,不是 table_id;字段用 **field_name**。 ## 支持的组件类型(`type` 枚举) diff --git a/skills/lark-base/references/lark-base-dashboard-usecase.md b/skills/lark-base/references/lark-base-dashboard-usecase.md index 3a6e5c235..0d8bf8ff4 100644 --- a/skills/lark-base/references/lark-base-dashboard-usecase.md +++ b/skills/lark-base/references/lark-base-dashboard-usecase.md @@ -15,7 +15,7 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成** | 你想做什么 | 用这些命令 | 关键文档 | |------|-----------|---------| | 创建/删除/改名称 | `+dashboard-create/delete/update` | 本页下方「仪表盘管理」 | -| 在仪表盘里添加组件 | `+dashboard-block-create` | 先用 `+table-list` 拿表,再用 `+field-list --table-id <表1> --table-id <表2>` 批量拿字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` | +| 在仪表盘里添加组件 | `+dashboard-block-create` | 先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 批量拿字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` | | 修改组件 | `+dashboard-block-update` | 先读 block 现状,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 决定替换哪些顶层 key | | 查看仪表盘有哪些组件 | `+dashboard-get` 或 `+dashboard-block-list` | 本页下方「查看仪表盘」 | | 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` | diff --git a/skills/lark-base/references/lark-base-dashboard.md b/skills/lark-base/references/lark-base-dashboard.md index d5cc35488..44aa72796 100644 --- a/skills/lark-base/references/lark-base-dashboard.md +++ b/skills/lark-base/references/lark-base-dashboard.md @@ -26,6 +26,14 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成** - 删除并用 `+dashboard-get` 复核 `not_found` 后,用户只回复“确认/好的/收到”视为结束,不要再次创建或保留同名/正式版仪表盘。 - 用户只说“更新标题”但未给新标题时,可基于原名生成一次新标题;先用 `+dashboard-list` 避开已存在名称,遇同名冲突换名更新,不要创建新仪表盘。 +## 执行要点 + +- 创建/改图前先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,不要逐表多次 `+field-list`,多余调用会显著抬高 token。 +- 布局/重排/撑满/排列美观直接用 `+dashboard-arrange`,不要尝试用 `+dashboard-block-update` 修改 layout,layout 不是 `data_config`。 +- block 换图表类型或换数据源表(`table_name`)时,删除旧 block 后用 `+dashboard-block-create` 新建;`+dashboard-block-update` 只适合同一数据源内改 `series/filter/group_by/name`。 +- 删除具名图表:`+dashboard-list` → `+dashboard-block-list` 精确匹配名称 → `+dashboard-block-delete`;长 `block_id` 用变量传参,避免手抄截断。 +- 完整 dashboard 用例(从需求到逐组件落地)按需读 [lark-base-dashboard-usecase.md](lark-base-dashboard-usecase.md)。 + ## 典型场景工作流 ### 场景 1:从 0 到 1 创建仪表盘 diff --git a/skills/lark-base/references/lark-base-field-json.md b/skills/lark-base/references/lark-base-field-json.md index bfe77c3a3..8be5e84da 100644 --- a/skills/lark-base/references/lark-base-field-json.md +++ b/skills/lark-base/references/lark-base-field-json.md @@ -397,6 +397,7 @@ 默认值 / 约束: - `style.rules` 是规则数组,数量 `1..9` +- `+field-update` 修改编号规则时,**默认会把新规则应用到已有记录** - 默认规则: ```json diff --git a/skills/lark-base/references/lark-base-workflow-guide.md b/skills/lark-base/references/lark-base-workflow-guide.md index fb7aa363c..f71b8f63f 100644 --- a/skills/lark-base/references/lark-base-workflow-guide.md +++ b/skills/lark-base/references/lark-base-workflow-guide.md @@ -6,6 +6,8 @@ > - Workflow 的数据结构参考:[lark-base-workflow-schema.md](lark-base-workflow-schema.md) > - 创建/更新时重点构造 `title`、`status` 和 `steps`;复杂度集中在 `steps[].type/data/next` +> **阅读节奏**:list/get/enable/disable 等查询启停场景不需要读本文档和 schema。创建/更新时也不要整篇读入 schema——先确定要用的 trigger/action 类型,再按类型名分段检索 [lark-base-workflow-schema.md](lark-base-workflow-schema.md) 中对应小节。 + --- ## 快速开始 From 3a8e86d74a2f718690ce5660a2945b193befbea9 Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Wed, 10 Jun 2026 17:17:58 +0800 Subject: [PATCH 03/17] =?UTF-8?q?docs(lark-base):=20consolidate=20caveat?= =?UTF-8?q?=20sections=20under=20=E6=B3=A8=E6=84=8F=E4=BA=8B=E9=A1=B9=20ch?= =?UTF-8?q?apter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/lark-base/SKILL.md | 59 +++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 6e486dc0e..bfc23cff1 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -45,14 +45,21 @@ metadata: | View(视图) | 同一张 table 的一种展示配置(筛选/排序/分组等),ID `viw` 开头 | | Form(表单) | 收集数据的入口,提交结果写入对应 table 的记录 | | Workflow(工作流) | Base 内的自动化流程,ID `wkf` 开头,由 steps(trigger + action)组成 | -| Dashboard(仪表盘) | 数据可视化容器,ID `blk` 开头——因为它本身是 Base 资源目录里的一个 block,见下方歧义说明 | -| Chart(图表/组件) | dashboard 内的单个可视化组件(柱状图/饼图/指标卡等),其 `block_id` 是 `cht` 开头 | +| Dashboard(仪表盘) | 数据可视化容器,ID `blk` 开头(因为它本身是 Base 资源目录里的一个 block,见下方歧义说明) | +| Chart(图表/组件) | 又叫Dashboard block, 是 dashboard 内的单个可视化组件(柱状图/饼图/指标卡等), ID `cht` 开头 | +| Base block (`+base-block-*`)| Base 资源目录里的节点,table/docx/dashboard/workflow/folder 在目录层面统称 block。 “这个 Base 里有哪些东西” → `+base-block-list`| -**`block` 是易混淆词,同名不同义,按命令域区分:** +**`block` 是易混淆词,同名不同义,按命令域区分:base-block 和 dashboard-block** -- **Base block**(`+base-block-*`):Base 资源目录里的节点,table/docx/dashboard/workflow/folder 在目录层面统称 block——所以 dashboard 的 ID 是 `blk` 开头。“这个 Base 里有哪些东西” → `+base-block-list`。 -- **Dashboard block**(`+dashboard-block-*`):仪表盘内部的图表组件(即 chart),`block_id` 为 `cht` 开头。“仪表盘里的图表/卡片” → `+dashboard-block-*`。 -- 判别:操作需要传 `--dashboard-id`(`blk` 开头)的 block 是图表组件;没有 dashboard 上下文的 block 是 Base 目录资源。 +### Base 心智模型 + +- `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除;具体资源内容仍走 table/dashboard/workflow 命令。 +- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 +- 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。 +- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 +- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。 +- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。 +- 跨表场景必须读取目标表结构;link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。 ## 快速路由 @@ -60,7 +67,7 @@ metadata: |---|---|---| | 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token | | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | -| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` | +| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系, 适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令。 fewshot 看 `--help` | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段;需要多表结构时先 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,避免逐表多次调用 | @@ -80,7 +87,9 @@ metadata: | Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 | | 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 | -## 批量执行 +## 注意事项 + +### Help 先行 - 执行不熟悉的命令前先看 `--help`,不要猜参数名或 JSON 结构;本轮任务会用到多个命令时,把它们的 `--help` 合并在一条 Bash 命令里一次看完,不要一轮对话只看一个 help: @@ -88,6 +97,8 @@ metadata: lark-cli base +table-list --help; lark-cli base +field-list --help; lark-cli base +field-update --help ``` +### 批量执行 + - 优先用原生批量能力:多表字段 `+field-list-batch`;批量写记录 `+record-batch-create` / `+record-batch-update`;部分命令参数本身支持多值(如 `+record-delete --record-id` 可重复传、`+record-share-link-create --record-ids`),先看 `--help`。 - 没有原生批量命令时,对多个对象做同类操作要在**一条 Bash 命令**里用 shell 循环完成,不要一轮对话只执行一个命令、看完结果再发下一个。 - 循环内先 `echo` 对象标识再执行,失败可定位到具体对象;写同一张表保持串行;只读命令可用 `--jq` 收窄输出,避免无关字段灌入上下文。 @@ -101,19 +112,7 @@ for v in vewAAA vewBBB vewCCC; do done ``` -## Base 心智模型 - -- Base 曾用名 Bitable;返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。 -- `+base-block-list` 是查看一个 Base 内资源目录的新入口:它列出这个 Base 直接管理的 `folder/table/docx/dashboard/workflow`,适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令。 -- `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除;具体资源内容仍走 table/dashboard/workflow 命令。 -- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 -- 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。 -- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 -- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。 -- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。 -- 跨表场景必须读取目标表结构;link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。 - -## 身份与权限降级 +### 身份与权限降级 - 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`。 - user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。 @@ -121,7 +120,7 @@ done - `91403` 或明确不可访问错误不要循环换身份重试。 - `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。 -## 查询与统计规则 +### 查询与统计规则 涉及查询、统计或判断结论时,先阅读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守: @@ -133,7 +132,7 @@ done 6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。 7. `+data-query` 可返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。 -## 写入前置规则 +### 写入前置规则 - 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。 - 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 @@ -144,13 +143,19 @@ done - `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。 - select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。 -## 表单与视图细节 +### 表单与视图细节 - `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。 - 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。 - `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。 - 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。 +### Dashboard / Workflow / Role + +- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 +- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 +- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 + ## Token 与链接 | 输入类型 | 含义 / 正确处理方式 | @@ -167,12 +172,6 @@ done `wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。 -## Dashboard / Workflow / Role - -- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 -- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 - ## 常见恢复 | 错误 / 现象 | 恢复动作 | From 64e9b008d8bfe913513d3af450921a1e6b56c160 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Wed, 10 Jun 2026 17:23:34 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20=E5=9B=9E=E6=BB=9A=20record=20alia?= =?UTF-8?q?s(=E4=BB=85=E4=BF=9D=E7=95=99=20record=20search=20=E7=9A=84=20q?= =?UTF-8?q?uery=20alias)=20&=20=E5=8E=BB=E9=99=A4=20drive=20export=20?= =?UTF-8?q?=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ie7fc9ff22509ec4b69b04f3c3b6c58cfbeb2ca88 --- shortcuts/base/base_execute_test.go | 80 ---------------------- shortcuts/base/base_shortcuts_test.go | 54 --------------- shortcuts/base/record_delete.go | 1 - shortcuts/base/record_get.go | 2 - shortcuts/base/record_list.go | 7 -- shortcuts/base/record_ops.go | 36 ++-------- shortcuts/base/record_query.go | 21 ++---- shortcuts/base/record_search.go | 3 - shortcuts/base/record_share_link_create.go | 3 +- shortcuts/drive/drive_export_common.go | 9 +-- shortcuts/drive/drive_export_test.go | 8 --- 11 files changed, 14 insertions(+), 210 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index a13dee2dc..9b10a68ed 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1317,50 +1317,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) - t.Run("list accepts page size alias", func(t *testing.T) { - factory, stdout, reg := newExecuteFactory(t) - var gotLimit string - listStub := &httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records", - OnMatch: func(req *http.Request) { - gotLimit = req.URL.Query().Get("limit") - }, - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "fields": []interface{}{"Title"}, - "field_id_list": []interface{}{"fld_title"}, - "record_id_list": []interface{}{"rec_1"}, - "data": []interface{}{[]interface{}{"Created by AI"}}, - "has_more": false, - }, - }, - } - reg.Register(listStub) - if err := runShortcut( - t, - BaseRecordList, - []string{ - "+record-list", - "--base-token", "app_x", - "--table-id", "tbl_x", - "--page-size", "20", - "--format", "json", - }, - factory, - stdout, - ); err != nil { - t.Fatalf("err=%v", err) - } - if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) { - t.Fatalf("stdout=%s", got) - } - if gotLimit != "20" { - t.Fatalf("limit query=%q, want 20", gotLimit) - } - }) - t.Run("search with filter json file", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) tmp := t.TempDir() @@ -2002,42 +1958,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) - t.Run("share link accepts record-id alias", func(t *testing.T) { - factory, stdout, reg := newExecuteFactory(t) - shareStub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/share_links/batch", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "record_share_links": map[string]interface{}{ - "rec_1": "https://example.test/rec_1", - "rec_2": "https://example.test/rec_2", - }, - }, - }, - } - reg.Register(shareStub) - err := runShortcut(t, BaseRecordShareLinkCreate, []string{ - "+record-share-link-create", - "--base-token", "app_x", - "--table-id", "tbl_x", - "--record-id", "rec_1", - "--record-id", "rec_2", - "--record-id", "rec_1", - }, factory, stdout) - if err != nil { - t.Fatalf("err=%v", err) - } - body := string(shareStub.CapturedBody) - if !strings.Contains(body, `"record_ids":["rec_1","rec_2"]`) { - t.Fatalf("request body=%s", body) - } - if got := stdout.String(); !strings.Contains(got, "rec_1") || !strings.Contains(got, "rec_2") { - t.Fatalf("stdout=%s", got) - } - }) - t.Run("upload attachment", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index e0e495fde..5b2dcb6cb 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -59,53 +59,6 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}} } -func TestRecordQueryAliases(t *testing.T) { - runtime := newBaseTestRuntime( - map[string]string{ - "filter": `{"logic":"and","conditions":[["Status","==","Todo"]]}`, - "sort": `[{"field":"Updated","desc":true}]`, - }, - nil, - nil, - ) - filter, err := parseRecordFilterFlag(runtime) - if err != nil { - t.Fatalf("filter err=%v", err) - } - if filter.(map[string]interface{})["logic"] != "and" { - t.Fatalf("filter=%#v", filter) - } - sortConfig, err := parseRecordSortFlag(runtime) - if err != nil { - t.Fatalf("sort err=%v", err) - } - if sortConfig[0].(map[string]interface{})["field"] != "Updated" { - t.Fatalf("sort=%#v", sortConfig) - } -} - -func TestRecordSelectionAliases(t *testing.T) { - runtime := newBaseTestRuntimeWithArrays( - map[string]string{}, - map[string][]string{ - "record-ids": {"rec_1", "rec_2"}, - "field-names": {"Name"}, - }, - nil, - nil, - ) - selection, err := resolveRecordSelection(runtime) - if err != nil { - t.Fatalf("err=%v", err) - } - if len(selection.recordIDs) != 2 || selection.recordIDs[1] != "rec_2" { - t.Fatalf("recordIDs=%#v", selection.recordIDs) - } - if len(selection.selectFields) != 1 || selection.selectFields[0] != "Name" { - t.Fatalf("selectFields=%#v", selection.selectFields) - } -} - func TestFieldSearchOptionsAlias(t *testing.T) { runtime := newBaseTestRuntime(map[string]string{"field-name": "Status"}, nil, nil) if got := fieldSearchOptionsRef(runtime); got != "Status" { @@ -1089,13 +1042,6 @@ func TestBaseRecordValidate(t *testing.T) { )); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") { t.Fatalf("err=%v", err) } - if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( - map[string]string{"base-token": "b", "table-id": "tbl_1"}, - nil, - map[string]int{"limit": 20, "page-size": 20}, - )); err == nil || !strings.Contains(err.Error(), "use only one") { - t.Fatalf("err=%v", err) - } if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") { t.Fatalf("err=%v", err) } diff --git a/shortcuts/base/record_delete.go b/shortcuts/base/record_delete.go index de29e6021..281376d5e 100644 --- a/shortcuts/base/record_delete.go +++ b/shortcuts/base/record_delete.go @@ -20,7 +20,6 @@ var BaseRecordDelete = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, - {Name: "record-ids", Type: "string_array", Hidden: true}, {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, }, Tips: []string{ diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go index 1a7b0fb72..f8d0b720a 100644 --- a/shortcuts/base/record_get.go +++ b/shortcuts/base/record_get.go @@ -21,9 +21,7 @@ var BaseRecordGet = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, - {Name: "record-ids", Type: "string_array", Hidden: true}, {Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"}, - {Name: "field-names", Type: "string_array", Hidden: true}, {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, recordReadFormatFlag(), }, diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index 56905c3ca..b6489a5c8 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -21,15 +21,11 @@ var BaseRecordList = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), recordListFieldRefFlag(), - {Name: "field-names", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), - {Name: recordFilterAliasFlag, Hidden: true, Input: []string{common.File}}, recordSortFlag(), - {Name: recordSortAliasFlag, Hidden: true, Input: []string{common.File}}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, - {Name: "page-size", Type: "int", Desc: "deprecated alias for --limit", Hidden: true}, recordReadFormatFlag(), }, Tips: []string{ @@ -49,9 +45,6 @@ var BaseRecordList = common.Shortcut{ if err := validateRecordReadFormat(runtime); err != nil { return err } - if runtime.Changed("limit") && runtime.Changed("page-size") { - return baseFlagErrorf("--page-size is a deprecated alias for --limit; use only one") - } return validateRecordQueryOptions(runtime) }, DryRun: dryRunRecordList, diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 8976b42ea..1f5264097 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -45,8 +45,8 @@ func validateRecordSelection(runtime *common.RuntimeContext) error { } func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) { - recordIDs := recordIDFlags(runtime) - fieldIDs := recordListFields(runtime) + recordIDs := runtime.StrArray("record-id") + fieldIDs := runtime.StrArray("field-id") jsonRaw := strings.TrimSpace(runtime.Str("json")) if len(recordIDs) > 0 && jsonRaw != "" { return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive") @@ -93,14 +93,6 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er }, nil } -func recordIDFlags(runtime *common.RuntimeContext) []string { - recordIDs := runtime.StrArray("record-id") - if len(recordIDs) == 0 { - recordIDs = runtime.StrArray("record-ids") - } - return recordIDs -} - func normalizeRecordIDs(values interface{}) ([]string, error) { return normalizeStringList(values, stringListNormalizeOptions{ typeError: "record selection must be a string array", @@ -215,7 +207,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common if offset < 0 { offset = 0 } - limit := recordListLimit(runtime) + limit := common.ParseIntBounded(runtime, "limit", 1, 200) params := url.Values{} params.Set("offset", strconv.Itoa(offset)) params.Set("limit", strconv.Itoa(limit)) @@ -349,8 +341,7 @@ func validateRecordShareBatch(runtime *common.RuntimeContext) error { } func deduplicateRecordIDs(runtime *common.RuntimeContext) []string { - raw := append([]string{}, runtime.StrSlice("record-ids")...) - raw = append(raw, runtime.StrArray("record-id")...) + raw := runtime.StrSlice("record-ids") seen := make(map[string]bool, len(raw)) result := make([]string, 0, len(raw)) for _, id := range raw { @@ -384,15 +375,7 @@ func validateRecordJSON(runtime *common.RuntimeContext) error { } func recordListFields(runtime *common.RuntimeContext) []string { - fields := runtime.StrArray("field-id") - if len(fields) == 0 { - fields = recordListFieldAliases(runtime) - } - return fields -} - -func recordListFieldAliases(runtime *common.RuntimeContext) []string { - return runtime.StrArray("field-names") + return runtime.StrArray("field-id") } func executeRecordList(runtime *common.RuntimeContext) error { @@ -403,7 +386,7 @@ func executeRecordList(runtime *common.RuntimeContext) error { if offset < 0 { offset = 0 } - limit := recordListLimit(runtime) + limit := common.ParseIntBounded(runtime, "limit", 1, 200) params := map[string]interface{}{"offset": offset, "limit": limit} fields := recordListFields(runtime) if len(fields) > 0 { @@ -426,13 +409,6 @@ func executeRecordList(runtime *common.RuntimeContext) error { return nil } -func recordListLimit(runtime *common.RuntimeContext) int { - if runtime.Changed("page-size") { - return common.ParseIntBounded(runtime, "page-size", 1, 200) - } - return common.ParseIntBounded(runtime, "limit", 1, 200) -} - func executeRecordGet(runtime *common.RuntimeContext) error { if err := validateRecordReadFormat(runtime); err != nil { return err diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go index d5e485af8..75b324d00 100644 --- a/shortcuts/base/record_query.go +++ b/shortcuts/base/record_query.go @@ -13,11 +13,9 @@ import ( ) const ( - recordFilterJSONFlag = "filter-json" - recordSortJSONFlag = "sort-json" - recordFilterAliasFlag = "filter" - recordSortAliasFlag = "sort" - recordSortMaxCount = 10 + recordFilterJSONFlag = "filter-json" + recordSortJSONFlag = "sort-json" + recordSortMaxCount = 10 ) func recordFilterFlag() common.Flag { @@ -45,7 +43,7 @@ func validateRecordQueryOptions(runtime *common.RuntimeContext) error { } func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) { - filterRaw := recordQueryFlagValue(runtime, recordFilterJSONFlag, recordFilterAliasFlag) + filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag)) if filterRaw == "" { return nil, nil } @@ -54,7 +52,7 @@ func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) } func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) { - sortRaw := recordQueryFlagValue(runtime, recordSortJSONFlag, recordSortAliasFlag) + sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag)) if sortRaw == "" { return nil, nil } @@ -66,14 +64,6 @@ func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) return normalizeRecordSortValue(value, "--"+recordSortJSONFlag) } -func recordQueryFlagValue(runtime *common.RuntimeContext, canonical string, alias string) string { - canonicalValue := strings.TrimSpace(runtime.Str(canonical)) - if canonicalValue != "" { - return canonicalValue - } - return strings.TrimSpace(runtime.Str(alias)) -} - func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) { var sortConfig []interface{} if parsed, ok := value.([]interface{}); ok { @@ -251,7 +241,6 @@ func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool return recordSearchKeyword(runtime) != "" || len(runtime.StrArray("search-field")) > 0 || len(recordListFields(runtime)) > 0 || - len(recordListFieldAliases(runtime)) > 0 || runtime.Str("view-id") != "" || runtime.Changed("offset") || runtime.Changed("limit") diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index fe9de834b..27a30f501 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -25,12 +25,9 @@ var BaseRecordSearch = common.Shortcut{ {Name: "query", Desc: "deprecated alias for --keyword", Hidden: true}, {Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"}, recordListFieldRefFlag(), - {Name: "field-names", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), - {Name: recordFilterAliasFlag, Hidden: true, Input: []string{common.File}}, recordSortFlag(), - {Name: recordSortAliasFlag, Hidden: true, Input: []string{common.File}}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), diff --git a/shortcuts/base/record_share_link_create.go b/shortcuts/base/record_share_link_create.go index 28214dda6..522369fcb 100644 --- a/shortcuts/base/record_share_link_create.go +++ b/shortcuts/base/record_share_link_create.go @@ -19,8 +19,7 @@ var BaseRecordShareLinkCreate = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)"}, - {Name: "record-id", Type: "string_array", Desc: "hidden alias for --record-ids", Hidden: true}, + {Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true}, }, Tips: []string{ `Example: lark-cli base +record-share-link-create --base-token --table-id --record-ids `, diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index 146b6ca30..187ac3e73 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -45,11 +45,6 @@ func driveExportTaskResultCommand(ticket, docToken string) string { // driveExportDownloadCommand prints a copy-pasteable follow-up command for // downloading an already-generated export artifact by file token. func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite bool) string { - cdPrefix := "" - if strings.TrimSpace(outputDir) != "" && filepath.IsAbs(outputDir) { - cdPrefix = "cd " + strconv.Quote(outputDir) + " && " - outputDir = "." - } parts := []string{ "lark-cli", "drive", "+export-download", "--file-token", strconv.Quote(fileToken), @@ -57,13 +52,13 @@ func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite if strings.TrimSpace(fileName) != "" { parts = append(parts, "--file-name", strconv.Quote(fileName)) } - if strings.TrimSpace(outputDir) != "" && (outputDir != "." || cdPrefix != "") { + if strings.TrimSpace(outputDir) != "" && outputDir != "." { parts = append(parts, "--output-dir", strconv.Quote(outputDir)) } if overwrite { parts = append(parts, "--overwrite") } - return cdPrefix + strings.Join(parts, " ") + return strings.Join(parts, " ") } // driveExportStatus captures the fields needed to decide whether the export is diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index aefde911f..619c9d43d 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -723,14 +723,6 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) { } } -func TestDriveExportDownloadCommandUsesCdForAbsoluteOutputDir(t *testing.T) { - got := driveExportDownloadCommand("box_ready", "report.pdf", "/tmp", true) - want := `cd "/tmp" && lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf" --output-dir "." --overwrite` - if got != want { - t.Fatalf("command=%q want %q", got, want) - } -} - func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ From 54b59a672f3e40a3b55a4bfd9b19e4c914dda407 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Wed, 10 Jun 2026 18:11:35 +0800 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=E5=88=86=E6=8B=86=20guide=20?= =?UTF-8?q?=E5=92=8C=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Iaacc2ff5211e34f121aed46f01b0646ea7ed71b1 --- skills/lark-base/SKILL.md | 6 +- .../references/lark-base-workflow-guide.md | 853 +------------ .../references/lark-base-workflow-schema.md | 1074 +---------------- .../workflow-steps/action-add-record.md | 24 + .../references/workflow-steps/action-delay.md | 16 + .../workflow-steps/action-find-record.md | 25 + .../workflow-steps/action-generate-ai-text.md | 21 + .../workflow-steps/action-http-client.md | 48 + .../workflow-steps/action-lark-message.md | 42 + .../workflow-steps/action-set-record.md | 28 + .../workflow-steps/branch-if-else.md | 36 + .../workflow-steps/branch-switch.md | 52 + .../workflow-steps/common-types-and-refs.md | 401 ++++++ .../references/workflow-steps/system-loop.md | 26 + .../workflow-steps/trigger-add-record.md | 24 + .../workflow-steps/trigger-button.md | 22 + .../workflow-steps/trigger-change-record.md | 22 + .../workflow-steps/trigger-lark-message.md | 40 + .../workflow-steps/trigger-reminder.md | 30 + .../workflow-steps/trigger-set-record.md | 38 + .../workflow-steps/trigger-timer.md | 27 + 21 files changed, 1019 insertions(+), 1836 deletions(-) create mode 100644 skills/lark-base/references/workflow-steps/action-add-record.md create mode 100644 skills/lark-base/references/workflow-steps/action-delay.md create mode 100644 skills/lark-base/references/workflow-steps/action-find-record.md create mode 100644 skills/lark-base/references/workflow-steps/action-generate-ai-text.md create mode 100644 skills/lark-base/references/workflow-steps/action-http-client.md create mode 100644 skills/lark-base/references/workflow-steps/action-lark-message.md create mode 100644 skills/lark-base/references/workflow-steps/action-set-record.md create mode 100644 skills/lark-base/references/workflow-steps/branch-if-else.md create mode 100644 skills/lark-base/references/workflow-steps/branch-switch.md create mode 100644 skills/lark-base/references/workflow-steps/common-types-and-refs.md create mode 100644 skills/lark-base/references/workflow-steps/system-loop.md create mode 100644 skills/lark-base/references/workflow-steps/trigger-add-record.md create mode 100644 skills/lark-base/references/workflow-steps/trigger-button.md create mode 100644 skills/lark-base/references/workflow-steps/trigger-change-record.md create mode 100644 skills/lark-base/references/workflow-steps/trigger-lark-message.md create mode 100644 skills/lark-base/references/workflow-steps/trigger-reminder.md create mode 100644 skills/lark-base/references/workflow-steps/trigger-set-record.md create mode 100644 skills/lark-base/references/workflow-steps/trigger-timer.md diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index bfc23cff1..a0b32ac66 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -84,7 +84,7 @@ metadata: | 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) | | 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 | | 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` | -| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 | +| Workflow | `+workflow-*` | 创建/更新或理解 steps 时先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),再读 schema 路由 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);只打开涉及的 `workflow-steps/*.md` 小文件,公共 `value/ref/condition` 才读 [common-types-and-refs.md](references/workflow-steps/common-types-and-refs.md);list/get/enable/disable 只处理 workflow ID 与启停状态 | | 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 | ## 注意事项 @@ -153,7 +153,7 @@ done ### Dashboard / Workflow / Role - Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 +- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),再读 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) 的 step 路由表;只打开涉及的 `workflow-steps/*.md`,需要 `ValueInfo/ref/Condition` 时再读 [common-types-and-refs.md](references/workflow-steps/common-types-and-refs.md)。enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 - Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 ## Token 与链接 @@ -199,5 +199,5 @@ done - [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON - [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON - [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md) / [lark-base-dashboard-usecase.md](references/lark-base-dashboard-usecase.md):仪表盘、组件配置、图表结果协议与完整用例 -- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT +- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) / [workflow-steps/](references/workflow-steps/):workflow 入口、steps 路由和按 step type 拆分的 schema 小文件 - [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT diff --git a/skills/lark-base/references/lark-base-workflow-guide.md b/skills/lark-base/references/lark-base-workflow-guide.md index f71b8f63f..8be33b18f 100644 --- a/skills/lark-base/references/lark-base-workflow-guide.md +++ b/skills/lark-base/references/lark-base-workflow-guide.md @@ -1,832 +1,67 @@ # Workflow guide -本文档是 Workflow 的入口指南,帮助选择步骤组合、理解创建/更新边界,并引导到 steps JSON SSOT。 +本文档只做 Workflow 创建/更新入口路由,避免默认读入完整 steps schema。查询、启停 workflow 不需要读本文档。 -> **配套文档**: -> - Workflow 的数据结构参考:[lark-base-workflow-schema.md](lark-base-workflow-schema.md) -> - 创建/更新时重点构造 `title`、`status` 和 `steps`;复杂度集中在 `steps[].type/data/next` +## 什么时候读 -> **阅读节奏**:list/get/enable/disable 等查询启停场景不需要读本文档和 schema。创建/更新时也不要整篇读入 schema——先确定要用的 trigger/action 类型,再按类型名分段检索 [lark-base-workflow-schema.md](lark-base-workflow-schema.md) 中对应小节。 +| 目标 | 处理方式 | +|---|---| +| 列出或查看 workflow | 先看 `lark-cli base +workflow-list --help` / `+workflow-get --help`,按返回摘要回答 | +| 启用或停用 workflow | 先确认 workflow ID 和当前状态,再用 `+workflow-enable` / `+workflow-disable` | +| 创建或更新简单 workflow | 读本文件,再按 step type 打开 schema 小文件 | +| 复用或解释复杂 `steps` | 读 [lark-base-workflow-schema.md](lark-base-workflow-schema.md) 的路由表,只打开涉及的 step 文件 | ---- +## 创建/更新最小流程 -## 快速开始 +1. 调用命令前先看 `--help`,不要猜参数名或 JSON 结构。 +2. 先确认真实 Base、表、字段、视图和用户/群 ID;不要凭口述猜字段名或 field ID。 +3. 选择一个 trigger;新增记录用 `AddRecordTrigger`,只监听修改用 `SetRecordTrigger`,新增或修改都触发/拿不准用 `ChangeRecordTrigger`。 +4. 选择 action/branch/system step,打开对应 schema 文件。 +5. 需要 `value_type`、`ref`、条件、字段值或输出引用时,再读 [common-types-and-refs.md](workflow-steps/common-types-and-refs.md)。 +6. 组装 `title/status/steps` 后用 `+workflow-create` 或 `+workflow-update`。 -### 最简单的 Workflow +## Step 文件路由 -新增记录时发送消息通知: +入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md) -```json -{ - "client_token": "1704067200", - "title": "新订单自动通知", - "steps": [ - { - "id": "trigger_1", - "type": "AddRecordTrigger", - "title": "监控新订单", - "next": "action_1", - "data": { - "table_name": "订单表", - "watched_field_name": "订单号" - } - }, - { - "id": "action_1", - "type": "LarkMessageAction", - "title": "发送通知", - "next": null, - "data": { - "receiver": [{ "value_type": "user", "value": {"id": "ou_xxxx", "name": "张三"} }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "新订单提醒" }], - "content": [ - { "value_type": "text", "value": "收到新订单" } - ], - "btn_list": [] - } - } - ] -} -``` - ---- - -## 场景速查表 - -| 场景 | 步骤组合 | 示例 | -|------|---------|------| -| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) | -| 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) | -| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) | -| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) | -| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支-switchbranch) | -| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例5-组合场景-定时查找循环分支消息) | - ---- - -## 完整示例 - -### 示例 1: 新增记录触发 + 发送消息 - -**场景**: 当订单表新增记录时,发送飞书消息通知负责人。 - -```json -{ - "client_token": "1704067201", - "title": "新订单自动通知", - "steps": [ - { - "id": "step_trigger", - "type": "AddRecordTrigger", - "title": "新增订单时触发", - "next": "step_notify", - "data": { - "table_name": "订单表", - "watched_field_name": "订单号", - "condition_list": null - } - }, - { - "id": "step_notify", - "type": "LarkMessageAction", - "title": "发送订单通知", - "next": null, - "data": { - "receiver": [{ "value_type": "ref", "value": "$.step_trigger.fldManager" }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "新订单提醒" }], - "content": [ - { "value_type": "text", "value": "客户 " }, - { "value_type": "ref", "value": "$.step_trigger.fldCustomer" }, - { "value_type": "text", "value": " 创建了新订单,金额:¥" }, - { "value_type": "ref", "value": "$.step_trigger.fldAmount" } - ], - "btn_list": [ - { - "text": "查看订单", - "btn_action": "openLink", - "link": [{ "value_type": "ref", "value": "$.step_trigger.recordLink" }] - } - ] - } - } - ] -} -``` - -**关键点**: -- `AddRecordTrigger` 监控 `table_name` 表的 `watched_field_name` 字段 -- 使用 `ref` 引用触发器输出的字段值(注意是 fieldId,不是字段名) -- `recordLink` 是触发器内置输出,表示记录链接 - ---- - -### 示例 2: 定时触发 + 查找记录 + 循环遍历 + 发送消息 +| 场景 | 常见步骤 | 只读这些文件 | +|---|---|---| +| 新增记录后通知 | `AddRecordTrigger -> LarkMessageAction` | [trigger-add-record.md](workflow-steps/trigger-add-record.md), [action-lark-message.md](workflow-steps/action-lark-message.md), common refs | +| 定时查找并循环处理 | `TimerTrigger -> FindRecordAction -> Loop -> ...` | [trigger-timer.md](workflow-steps/trigger-timer.md), [action-find-record.md](workflow-steps/action-find-record.md), [system-loop.md](workflow-steps/system-loop.md), common refs | +| 条件分支 | `... -> IfElseBranch -> ...` | [branch-if-else.md](workflow-steps/branch-if-else.md), common conditions | +| 多路分类 | `... -> SwitchBranch -> ...` | [branch-switch.md](workflow-steps/branch-switch.md), common conditions | +| 按钮触发外部系统 | `ButtonTrigger -> HTTPClientAction -> AddRecordAction` | [trigger-button.md](workflow-steps/trigger-button.md), [action-http-client.md](workflow-steps/action-http-client.md), [action-add-record.md](workflow-steps/action-add-record.md), common refs | +| AI 生成内容 | `... -> GenerateAiTextAction` | [action-generate-ai-text.md](workflow-steps/action-generate-ai-text.md), common refs | -**场景**: 每天早上 9 点,查找所有待处理订单,给每个客户发送提醒。 +## 结构速记 ```json { - "client_token": "1704067202", - "title": "每日待处理订单提醒", + "title": "工作流标题", + "status": "enable", "steps": [ - { - "id": "step_timer", - "type": "TimerTrigger", - "title": "每天早上9点触发", - "next": "step_find_orders", - "data": { - "rule": "DAILY", - "start_time": "2025-01-01 09:00", - "is_never_end": true - } - }, - { - "id": "step_find_orders", - "type": "FindRecordAction", - "title": "查找所有待处理订单", - "next": "step_loop_customers", - "data": { - "table_name": "订单表", - "field_names": ["客户名称", "订单金额", "客户联系方式"], - "should_proceed_when_no_results": false, - "filter_info": { - "conjunction": "and", - "conditions": [ - { - "field_name": "状态", - "operator": "is", - "value": [{ "value_type": "option", "value": { "name": "待处理" } }] - } - ] - } - } - }, - { - "id": "step_loop_customers", - "type": "Loop", - "title": "遍历每个订单", - "children": { - "links": [ - { "kind": "loop_start", "to": "step_send_reminder" } - ] - }, - "next": null, - "data": { - "loop_mode": "continue", - "max_loop_times": 100, - "data": [{ - "value_type": "ref", - "value": "$.step_find_orders.fieldRecords" - }] - } - }, - { - "id": "step_send_reminder", - "type": "LarkMessageAction", - "title": "发送催办消息", - "next": null, - "data": { - "receiver": [{ - "value_type": "ref", - "value": "$.step_loop_customers.item.fldContact" - }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "订单处理提醒" }], - "content": [ - { "value_type": "text", "value": "您好,您的订单 " }, - { "value_type": "ref", "value": "$.step_loop_customers.item.fldName" }, - { "value_type": "text", "value": " 金额 ¥" }, - { "value_type": "ref", "value": "$.step_loop_customers.item.fldAmount" }, - { "value_type": "text", "value": " 正在处理中。" } - ], - "btn_list": [] - } - } + {"id":"step_trigger","type":"AddRecordTrigger","title":"触发器","next":"step_action","data":{}}, + {"id":"step_action","type":"LarkMessageAction","title":"动作","next":null,"data":{}} ] } ``` -**关键点**: -- `Loop.data` 必须传入 `ref` 类型的数据源(通常是 FindRecordAction 的 `fieldRecords`) -- `Loop.children.links` 必须包含 `kind: "loop_start"` 的链接指向循环体 -- 循环体内用 `$.{loopStepId}.item.{fieldId}` 引用当前遍历记录的字段 -- `$.{loopStepId}.index` 获取当前索引(从 0 开始) - ---- - -### 示例 3: 条件分支(IfElseBranch) - -**场景**: 根据订单金额判断,大额订单通知主管审批,小额订单自动通过。 - -```json -{ - "client_token": "1704067203", - "title": "订单金额自动判断", - "steps": [ - { - "id": "step_trigger", - "type": "AddRecordTrigger", - "title": "新增订单时触发", - "next": "step_check_amount", - "data": { - "table_name": "订单表", - "watched_field_name": "订单金额" - } - }, - { - "id": "step_check_amount", - "type": "IfElseBranch", - "title": "判断是否为大额订单", - "children": { - "links": [ - { "kind": "if_true", "to": "step_notify_manager", "label": "high", "desc": "金额>=10000" }, - { "kind": "if_false", "to": "step_auto_approve", "label": "normal", "desc": "金额<10000" } - ] - }, - "next": "step_log", - "data": { - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_trigger.fldAmount" }, - "operator": "isGreaterEqual", - "right_value": [{ "value_type": "number", "value": 10000 }] - } - ] - } - ] - } - } - }, - { - "id": "step_notify_manager", - "type": "LarkMessageAction", - "title": "通知主管审批大额订单", - "next": "step_log", - "data": { - "receiver": [{ "value_type": "user", "value": {"id": "ou_manager", "name": "主管"} }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "大额订单待审批" }], - "content": [ - { "value_type": "text", "value": "有大额订单 ¥" }, - { "value_type": "ref", "value": "$.step_trigger.fldAmount" }, - { "value_type": "text", "value": " 需要您审批" } - ], - "btn_list": [] - } - }, - { - "id": "step_auto_approve", - "type": "SetRecordAction", - "title": "自动标记小额订单为已审核", - "next": "step_log", - "data": { - "table_name": "订单表", - "ref_info": { "step_id": "step_trigger" }, - "field_values": [ - { - "field_name": "审批状态", - "value": [{ "value_type": "option", "value": { "name": "已自动审核" } }] - } - ] - } - }, - { - "id": "step_log", - "type": "GenerateAiTextAction", - "title": "生成订单处理日志", - "next": null, - "data": { - "prompt": [ - { "value_type": "text", "value": "请生成订单处理日志,金额:" }, - { "value_type": "ref", "value": "$.step_trigger.fldAmount" } - ] - } - } - ] -} -``` - -**关键点**: -- `IfElseBranch.children.links` 必须包含 `if_true` 和 `if_false` 两个分支 -- `next` 指向两个分支汇合后的步骤(可选,为 null 则分支结束) -- `condition` 使用 OrGroup 结构,支持 `(A and B) or (C and D)` 的复杂条件 -- 分支内可以用 `ref_info` 引用触发记录,用 `filter_info` 批量筛选记录 - ---- - -### 示例 4: 多路分支(SwitchBranch) - -**场景**: 根据订单优先级(P0/P1/P2)执行不同的处理流程。 - -```json -{ - "client_token": "1704067204", - "title": "按优先级分类处理订单", - "steps": [ - { - "id": "step_trigger", - "type": "AddRecordTrigger", - "title": "新增订单时触发", - "next": "step_classify", - "data": { - "table_name": "订单表", - "watched_field_name": "优先级" - } - }, - { - "id": "step_classify", - "type": "SwitchBranch", - "title": "按优先级分类", - "children": { - "links": [ - { "kind": "case", "to": "step_p0_handler", "label": "p0", "desc": "P0-紧急" }, - { "kind": "case", "to": "step_p1_handler", "label": "p1", "desc": "P1-高优先级" }, - { "kind": "case", "to": "step_p2_handler", "label": "p2", "desc": "P2-普通" }, - { "kind": "case", "to": "step_other_handler", "label": "other", "desc": "其他" } - ] - }, - "next": null, - "data": { - "mode": "exclusive", - "no_match_action": "classifyToOther", - "child_branch_list": [ - { - "name": "P0-紧急", - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_trigger.fldPriority" }, - "operator": "is", - "right_value": [{ "value_type": "option", "value": { "name": "P0" } }] - } - ] - } - ] - } - }, - { - "name": "P1-高优先级", - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_trigger.fldPriority" }, - "operator": "is", - "right_value": [{ "value_type": "option", "value": { "name": "P1" } }] - } - ] - } - ] - } - }, - { - "name": "P2-普通", - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_trigger.fldPriority" }, - "operator": "is", - "right_value": [{ "value_type": "option", "value": { "name": "P2" } }] - } - ] - } - ] - } - } - ] - } - }, - { - "id": "step_p0_handler", - "type": "LarkMessageAction", - "title": "P0紧急处理", - "next": null, - "data": { - "receiver": [{ "value_type": "user", "value": {"id": "ou_director", "name": "总监"} }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "🚨 P0 紧急订单" }], - "content": [{ "value_type": "text", "value": "有新的 P0 紧急订单需要立即处理" }], - "btn_list": [] - } - }, - { - "id": "step_p1_handler", - "type": "SetRecordAction", - "title": "标记高优先级", - "next": null, - "data": { - "table_name": "订单表", - "ref_info": { "step_id": "step_trigger" }, - "field_values": [ - { "field_name": "处理状态", "value": [{ "value_type": "text", "value": "高优先级待处理" }] } - ] - } - }, - { - "id": "step_p2_handler", - "type": "Delay", - "title": "普通订单延迟处理", - "next": null, - "data": { "duration": 60 } - }, - { - "id": "step_other_handler", - "type": "SetRecordAction", - "title": "标记其他订单", - "next": null, - "data": { - "table_name": "订单表", - "ref_info": { "step_id": "step_trigger" }, - "field_values": [ - { "field_name": "处理状态", "value": [{ "value_type": "text", "value": "待分类" }] } - ] - } - } - ] -} -``` - -**关键点**: -- `SwitchBranch` 适合 3 路及以上的分支场景(少于 3 路用 `IfElseBranch` 更简洁) -- `children.links` 中 `kind: "case"` 的 `label` 对应 `child_branch_list` 中的条件 -- `mode: "exclusive"` 表示排他执行(第一个匹配的分支执行后停止) -- `no_match_action: "classifyToOther"` 表示无匹配时走最后一个 `case`(兜底分支) - ---- +- 普通 trigger/action 用 `next` 串联。 +- `IfElseBranch` / `SwitchBranch` / `Loop` 用 `children.links` 表达分支或循环入口。 +- `ref` 路径用前置 step 的 `id`,字段下钻通常是 `$.{stepId}.{fieldId}` 或 `$.{loopStepId}.item.{fieldId}`。 +- Action 节点不要设置 `children`。 -### 示例 5: 组合场景(定时+查找+循环+分支+消息) - -**场景**: 每天早上 9 点,查找昨天的订单,按金额分级,给不同级别的销售发送不同的通知。 - -```json -{ - "client_token": "1704067205", - "title": "每日订单分级通知", - "steps": [ - { - "id": "step_timer", - "type": "TimerTrigger", - "title": "每天早上9点触发", - "next": "step_find_orders", - "data": { - "rule": "DAILY", - "start_time": "2025-01-01 09:00", - "is_never_end": true - } - }, - { - "id": "step_find_orders", - "type": "FindRecordAction", - "title": "查找昨天所有订单", - "next": "step_loop", - "data": { - "table_name": "订单表", - "field_names": ["订单号", "客户名称", "金额", "销售负责人"], - "should_proceed_when_no_results": false, - "filter_info": { - "conjunction": "and", - "conditions": [ - { "field_name": "创建时间", "operator": "isGreaterEqual", "value": [{ "value_type": "date", "value": "yesterday" }] } - ] - } - } - }, - { - "id": "step_loop", - "type": "Loop", - "title": "遍历每个订单", - "children": { - "links": [ - { "kind": "loop_start", "to": "step_classify" } - ] - }, - "next": "step_summary", - "data": { - "loop_mode": "continue", - "max_loop_times": 500, - "data": [{ "value_type": "ref", "value": "$.step_find_orders.fieldRecords" }] - } - }, - { - "id": "step_classify", - "type": "SwitchBranch", - "title": "按金额分类", - "children": { - "links": [ - { "kind": "case", "to": "step_vip_notify", "label": "vip", "desc": "VIP >= 10万" }, - { "kind": "case", "to": "step_normal_notify", "label": "normal", "desc": "普通 < 10万" } - ] - }, - "next": null, - "data": { - "mode": "exclusive", - "no_match_action": "fail", - "child_branch_list": [ - { - "name": "VIP订单", - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_loop.item.fldAmount" }, - "operator": "isGreaterEqual", - "right_value": [{ "value_type": "number", "value": 100000 }] - } - ] - } - ] - } - }, - { - "name": "普通订单", - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_loop.item.fldAmount" }, - "operator": "isLess", - "right_value": [{ "value_type": "number", "value": 100000 }] - } - ] - } - ] - } - } - ] - } - }, - { - "id": "step_vip_notify", - "type": "LarkMessageAction", - "title": "VIP订单通知", - "next": null, - "data": { - "receiver": [{ "value_type": "ref", "value": "$.step_loop.item.fldSales" }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "🌟 VIP大额订单" }], - "content": [ - { "value_type": "text", "value": "恭喜!您有一笔 VIP 订单 ¥" }, - { "value_type": "ref", "value": "$.step_loop.item.fldAmount" }, - { "value_type": "text", "value": ",客户:" }, - { "value_type": "ref", "value": "$.step_loop.item.fldCustomer" } - ], - "btn_list": [] - } - }, - { - "id": "step_normal_notify", - "type": "LarkMessageAction", - "title": "普通订单通知", - "next": null, - "data": { - "receiver": [{ "value_type": "ref", "value": "$.step_loop.item.fldSales" }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "新订单通知" }], - "content": [ - { "value_type": "text", "value": "您有一笔新订单 ¥" }, - { "value_type": "ref", "value": "$.step_loop.item.fldAmount" } - ], - "btn_list": [] - } - }, - { - "id": "step_summary", - "type": "GenerateAiTextAction", - "title": "生成日报", - "next": null, - "data": { - "prompt": [ - { "value_type": "text", "value": "请生成昨日订单处理日报" } - ] - } - } - ] -} -``` - ---- - -### 示例 6: 按钮触发 + 调用外部接口 + 写入同步日志 - -**场景**: 在「客户线索表」里给每条记录配置一个“同步到 CRM”按钮。销售点击按钮后,Workflow 调用外部 CRM 接口同步当前线索,再在「同步日志表」新增一条记录,方便后续审计和排查。 - -```json -{ - "client_token": "1704067206", - "title": "线索一键同步到 CRM", - "steps": [ - { - "id": "step_button_trigger", - "type": "ButtonTrigger", - "title": "点击同步到 CRM 按钮时触发", - "next": "step_call_crm_api", - "data": { - "button_type": "buttonField", - "table_name": "客户线索表" - } - }, - { - "id": "step_call_crm_api", - "type": "HTTPClientAction", - "title": "调用 CRM 同步接口", - "next": "step_add_sync_log", - "data": { - "method": "POST", - "url": [ - { "value_type": "text", "value": "https://api.example-crm.com/v1/leads/sync" } - ], - "headers": [ - { "key": "Content-Type", "value": [{ "value_type": "text", "value": "application/json" }] }, - { "key": "X-System", "value": [{ "value_type": "text", "value": "lark_base_workflow" }] } - ], - "body_type": "raw", - "raw_body": [ - { "value_type": "text", "value": "{\"lead_name\":\"" }, - { "value_type": "ref", "value": "$.step_button_trigger.fldLeadName" }, - { "value_type": "text", "value": "\",\"mobile\":\"" }, - { "value_type": "ref", "value": "$.step_button_trigger.fldMobile" }, - { "value_type": "text", "value": "\",\"company\":\"" }, - { "value_type": "ref", "value": "$.step_button_trigger.fldCompany" }, - { "value_type": "text", "value": "\",\"owner\":\"" }, - { "value_type": "ref", "value": "$.step_button_trigger.fldOwner" }, - { "value_type": "text", "value": "\",\"source_record_id\":\"" }, - { "value_type": "ref", "value": "$.step_button_trigger.recordId" }, - { "value_type": "text", "value": "\"}" } - ], - "response_type": "json", - "response_value": "{\"success\":true,\"message\":\"lead synced successfully\"}" - } - }, - { - "id": "step_add_sync_log", - "type": "AddRecordAction", - "title": "写入同步日志", - "next": null, - "data": { - "table_name": "同步日志表", - "field_values": [ - { - "field_name": "线索名称", - "value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldLeadName" }] - }, - { - "field_name": "手机号", - "value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldMobile" }] - }, - { - "field_name": "公司名称", - "value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldCompany" }] - }, - { - "field_name": "负责人", - "value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldOwner" }] - }, - { - "field_name": "来源记录ID", - "value": [{ "value_type": "ref", "value": "$.step_button_trigger.recordId" }] - }, - { - "field_name": "同步状态", - "value": [{ "value_type": "text", "value": "已提交 CRM 同步" }] - }, - { - "field_name": "同步是否成功", - "value": [{ "value_type": "ref", "value": "$.step_call_crm_api.body.success" }] - }, - { - "field_name": "同步结果说明", - "value": [{ "value_type": "ref", "value": "$.step_call_crm_api.body.message" }] - }, - { - "field_name": "备注", - "value": [{ "value_type": "text", "value": "由按钮触发自动发起同步请求" }] - } - ] - } - } - ] -} -``` - -**关键点**: -- `ButtonTrigger` 适合“人工确认后再执行”的场景,比如同步 CRM、推送 ERP、发起审批等 -- `button_type: "buttonField"` 表示按钮挂在记录上,因此可以直接引用当前记录的字段和值 -- `HTTPClientAction.raw_body` 可以通过 `text + ref + text` 的方式动态拼接 JSON 请求体 -- `HTTPClientAction` 的输出引用规则是:`response_type=none` 时不可引用;`response_type=text` 时只能用 `$.stepId` 引整个文本;`response_type=json` 时用 `$.stepId.body` 引整个 body、用 `$.stepId.body.字段名` 引 body 中字段,同时 `$.stepId.status_code` 表示 HTTP 返回状态码 -- `HTTPClientAction.response_value` 中声明了哪些字段,后续节点就只能引用这些字段;例如 `$.step_call_crm_api.body.success`、`$.step_call_crm_api.body.message` -- `AddRecordAction` 常用于写日志表、操作审计表、同步结果表,便于追踪谁在什么时候触发了外部调用 -- 示例里的 `fldLeadName` / `fldMobile` / `fldCompany` / `fldOwner` 只是占位的 fieldId,请以实际表字段 ID 为准 - ---- - -## 构造技巧 - -### Loop 构造要点 - -1. **数据源**: `Loop.data` 必须传入 `ref` 类型,通常是 `FindRecordAction` 的 `fieldRecords` -2. **循环体**: `children.links` 必须包含 `kind: "loop_start"` 指向循环体入口 -3. **引用**: 循环体内用 `$.{loopStepId}.item.{fieldId}` 引用当前元素 -4. **索引**: 用 `$.{loopStepId}.index` 获取当前索引(从 0 开始) - -### 分支构造要点 - -1. **IfElseBranch**: - - 适合二元判断(是/否、大于/小于) - - `children.links` 必须包含 `if_true` 和 `if_false` - - 可以用 `next` 指向汇合点 - -2. **SwitchBranch**: - - 适合多路分类(3路及以上) - - `label` 对应 `child_branch_list` 中的条件顺序 - - 建议加一个兜底分支(其他) - -### 字段值构造 - -| 字段类型 | value_type | 示例 | -|---------|------------|------| -| 文本 | `text` | `{"value_type": "text", "value": "张三"}` | -| 数字 | `number` | `{"value_type": "number", "value": 100}` | -| 单选 | `option` | `{"value_type": "option", "value": {"name": "已完成"}}` | -| 人员 | `user` | `{"value_type": "user", "value": {"id": "ou_xxxx"}}` | -| 引用 | `ref` | `{"value_type": "ref", "value": "$.step_1.fldxxx"}` | - ---- - -## 常见错误避免 - -### Top 10 高频错误 - -| # | 错误信息 | 原因 | 解决方案 | -|---|---------|------|---------| -| 1 | `path "xxx" does not exist in the output path tree` | ref 引用路径错误或 stepId 不存在 | 检查 stepId 是否在 steps 数组中;使用 fieldId 而非字段名;确保路径以 `$.` 开头 | -| 2 | `recordInfo.conditions must be non-empty` | `condition_list` 为空数组 `[]` | 改用 `null` 或省略该字段 | -| 3 | `At least one of filter info and ref info is required` | SetRecordAction/FindRecordAction 缺少定位条件 | 必须提供 `filter_info` 或 `ref_info` 之一 | -| 4 | `client token is empty` | 缺少 `client_token` | 每次请求传入唯一值(时间戳或随机字符串) | -| 5 | `valueType 'text' not allowed for fieldType '3'` | select 类型字段值格式错误 | 改用 `option` 类型 | -| 6 | `Undefined Step Type` | 使用了不支持的 StepType | 使用 `AddRecordTrigger` 而非 `CreateRecordTrigger` | -| 7 | `prompt references an unknown reference from step` | 引用的 stepId 不存在 | 确保引用的 step 在同一 workflow 的 steps 数组中 | -| 8 | `[2200] Internal Error` | 1. steps[].id 重复 2. next/children.links 引用了不存在的 step | 确保所有 step id 唯一;检查引用关系 | -| 9 | 工作流结构不完整 | Branch/Loop 节点缺少 `children` | 仅 Branch(IfElseBranch/SwitchBranch)和 Loop 节点需要 `children`,Trigger/Action 节点无需设置 | -| 10 | 嵌套分支过于复杂 | 多层 IfElseBranch 嵌套 | 3+ 路分支用 SwitchBranch 替代嵌套 IfElseBranch | - -### 其他常见错误 - -**1. condition_list 为空数组** -```json -// ❌ 错误 -{ "condition_list": [] } - -// ✅ 正确 -{ "condition_list": null } -// 或省略该字段 -``` - -**2. filter_info 和 ref_info 同时提供** -```json -// ❌ 错误 -{ "filter_info": {...}, "ref_info": {...} } - -// ✅ 正确(二选一) -{ "filter_info": {...}, "ref_info": null } -{ "filter_info": null, "ref_info": {...} } -``` - -**3. 使用字段名而非 fieldId** -```json -// ❌ 错误 -{ "value": "$.step_1.客户名称" } - -// ✅ 正确 -{ "value": "$.step_1.fldXXXXXXXX" } -``` +## 常见错误 ---- +| 错误 | 处理 | +|---|---| +| 把字段名当 field ID 写入 ref | 先读真实字段结构;ref 下钻通常使用 field ID | +| 分支/循环没有 `children.links` | 按 branch/loop schema 补 `if_true/if_false/case/loop_start` | +| SetRecordAction/FindRecordAction 缺定位条件 | 提供 `filter_info` 或 `ref_info` | +| HTTPClientAction 后续节点引用不到字段 | `response_type=json` 时填写 `response_value` 声明输出字段 | +| Loop 内引用错路径 | 用 `$.{loopStepId}.item.{fieldId}` 和 `$.{loopStepId}.index` | ## 参考 -- [lark-base-workflow-schema.md](lark-base-workflow-schema.md) — 字段定义参考 -- 创建/更新前先确认真实表名、字段名和目标 workflow ID;`steps` 结构按 schema 构造,不凭自然语言猜 `type` +- [lark-base-workflow-schema.md](lark-base-workflow-schema.md):steps 基础结构和按需路由 +- [workflow-steps/common-types-and-refs.md](workflow-steps/common-types-and-refs.md):ValueInfo、ref、Condition、节点输出 diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 916f53f64..e36abdde8 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -1,28 +1,15 @@ # Workflow steps JSON SSOT -本文档是 Workflow `steps` JSON 的单一事实来源(SSOT),定义完整数据结构,适用于: -- **查询场景**:理解 `+workflow-get` 返回的 `steps` 结构 -- **创建/修改场景**:构造 `+workflow-create` / `+workflow-update` 的 `--json` body -> 💡 **本文档是纯字段参考**。如需**创建/修改**工作流的完整示例,请阅读 [workflow-guide.md](lark-base-workflow-guide.md)。 ---- -## 📖 快速导航 +本文档是 Workflow steps 的按需读取入口。不要整篇读旧 schema;先确认要处理的 step type,再打开对应小文件。 -根据你的需求跳转到对应章节: +## 读取顺序 -| 需求 | 章节 | -|------|------| -| 了解 Step 基础结构 | [WorkflowStep 基础结构](#workflowstep-基础结构) | -| 查询 Trigger 类型及 data 字段 | [Trigger data](#trigger-data-详细结构) | -| 查询 Action 类型及 data 字段 | [Action data](#action-data-详细结构) | -| 查询 Branch/Loop 结构 | [Branch data](#branch-data-详细结构) / [System data](#system-data-详细结构) | -| 查询 ValueInfo/Condition 等公共类型 | [公共类型](#公共类型) | - ---- +1. 查询、启停 workflow:只用 `+workflow-list/get/enable/disable --help` 和命令返回,不读本目录。 +2. 创建或更新 workflow:先读本文件的基础结构和 step 路由表。 +3. 只打开会用到的 step type 文件;需要 value/ref/filter 条件时再读 [common-types-and-refs.md](workflow-steps/common-types-and-refs.md)。 ## WorkflowStep 基础结构 -每个步骤(Trigger / Action / Branch / System)共享以下字段: - ```json { "id": "step_xxx", @@ -34,1038 +21,77 @@ ``` | 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `id` | string | 是 | 步骤唯一 ID(用户自定义,被 `next` 和 `children.links[].to` 引用) | -| `type` | string | 是 | 步骤类型,见下方枚举 | +|---|---|---|---| +| `id` | string | 是 | 步骤唯一 ID,被 `next` 和 `children.links[].to` 引用 | +| `type` | string | 是 | 步骤类型,按下方路由打开对应文件 | | `title` | string | 否 | 步骤标题 | -| `children` | StepChildren | 否 | 子关系边,承担所有分支/循环 | -| `next` | string | null | 否 | 线性后继节点 ID;`null` 表示流程结束 | -| `data` | object | 是 | 步骤详细配置,按 `type` 区分,见后续各节 | - -> **总原则**:连线写 `children`,扩展标识写 `meta`,输入参数写 `data`。 +| `children` | StepChildren | 否 | 分支/循环关系边;普通 trigger/action 不设置 | +| `next` | string/null | 否 | 线性后继节点 ID;`null` 表示流程结束 | +| `data` | object | 是 | 按 `type` 区分的配置对象 | ---- +总原则:连线写 `children`,扩展标识写 `meta`,输入参数写 `data`。 ## StepChildren 与 ChildLink -### StepChildren - ```json { - "links": [ /* ChildLink[] */ ] + "links": [ + { "kind": "if_true", "to": "step_4", "label": "branch_1", "desc": "金额大于1000" } + ] } ``` -| 字段 | 类型 | 说明 | -|------|------|------| -| `links` | ChildLink[] | 子关系边列表;无子关系时为空数组 `[]` | - -### ChildLink - -每条关系边描述从当前节点到目标节点的有向连线: - -```json -{ "kind": "if_true", "to": "step_4", "label": "branch_1", "desc": "金额大于1000" } -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `kind` | string | 是 | 关系类型:`if_true` / `if_false` / `case` / `loop_start` / `slot` | -| `to` | string | 是 | 目标节点 ID | -| `label` | string | 否 | 可选标签(如 `branch_1`、`tool`、`llm`、`memory`) | -| `desc` | string | 否 | 可选语义说明(如"销售部门"、"积极情绪") | - -`kind` 使用场景: - | kind | 使用节点 | 说明 | -|------|---------|------| +|---|---|---| | `if_true` | IfElseBranch | 条件为真时跳转 | | `if_false` | IfElseBranch | 条件为假时跳转 | -| `case` | SwitchBranch / AIClassificationBranch | 多路分支,`label` 建议用 `branch_1` 等中性标签,`desc` 写语义 | +| `case` | SwitchBranch | 多路分支,`label` 建议用 `branch_1` 等中性标签,`desc` 写语义 | | `loop_start` | Loop | 循环体入口 | | `slot` | AIAgentAction | 挂载 LLM / 工具 / 记忆子节点,`label` 为 `llm` / `tool` / `memory` | ---- - -## StepType 枚举 - -### Trigger 类型 - -| type | 说明 | -|------|------| -| `AddRecordTrigger` | 新增记录时触发 | -| `SetRecordTrigger` | 记录被修改时触发 | -| `ChangeRecordTrigger` | 记录满足条件时触发 | -| `TimerTrigger` | 定时触发 | -| `ReminderTrigger` | 日期提醒触发 | -| `ButtonTrigger` | 按钮点击触发 | -| `LarkMessageTrigger` | 接收飞书消息触发 | - -> 所有 Trigger 节点**请勿设置** `children` ,通过 `next` 串联后继。 - -### 触发器选型指南 - -| 需求描述 | 触发器 | -|---------|--------| -| 新增记录时 | `AddRecordTrigger` | -| 字段变为特定值时(**仅修改**) | `SetRecordTrigger` | -| **新增或修改**都触发 | `ChangeRecordTrigger` | -| 拿不准用哪个 | `ChangeRecordTrigger` | - -> ⚠️ `SetRecordTrigger` 仅监听修改,`ChangeRecordTrigger` 同时监听新增 + 修改。 - -### Action 类型 - -| type | 说明 | -|------|------| -| `AddRecordAction` | 新增记录 | -| `SetRecordAction` | 更新记录 | -| `FindRecordAction` | 查找记录 | -| `HTTPClientAction` | HTTP 请求 | -| `Delay` | 延迟 | -| `LarkMessageAction` | 发送飞书消息 | -| `GenerateAiTextAction` | AI 生成文本 | - -> 所有 Action 节点**请勿设置** `children` ,通过 `next` 串联后继。 - -### Branch 类型 - -| type | 说明 | -|------|------| -| `IfElseBranch` | 条件分支,`children.links` 含 `if_true` 和 `if_false` | -| `SwitchBranch` | 多路分支,`children.links` 含多个 `case` | - -### System 类型 - -| type | 说明 | -|------|------| -| `Loop` | 循环,`children.links` 含 `loop_start` 指向循环体入口 | - ---- - -## Trigger data 详细结构 - - -### AddRecordTrigger - -```json -{ - "table_name": "订单表", - "watched_field_name": "状态", - "trigger_control_list": ["pasteUpdate", "automationBatchUpdate"], - "condition_list": [] /* AndCondition 数组 */ -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `table_name` | 是 | 监控的数据表名 | -| `watched_field_name` | 是 | 监控的字段名 | -| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` / `openAPIBatchUpdate` | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | - -### ChangeRecordTrigger - -```json -{ - "table_name": "任务表", - "trigger_control_list": [], - "condition": null -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `table_name` | 是 | 监控的数据表名 | -| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | - -### SetRecordTrigger - -```json -{ - "table_name": "订单表", - "record_watch_conjunction": "and", - "record_watch_info": [ /* FieldCondition[] */ ], - "field_watch_info": [ - { "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "已发货" }] } - ], - "trigger_control_list": [], - "condition_list": null -} -``` - -| 字段 | 必填 | 说明 | -|------|----|------| -| `table_name` | 是 | 监控的数据表名 | -| `record_watch_conjunction` | 否 | 记录筛选组合方式:`and` / `or`,默认 `and` | -| `record_watch_info` | 否 | 记录级过滤条件(修改前值匹配),为空则监听全部 | -| `field_watch_info` | 是 | 字段级监控条件列表,至少一个 | -| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | - -`FieldWatchItem`: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `field_name` | string | 监听字段名称 | -| `operator` | string | 操作符(仅明确要求字段满足条件时填) | -| `value` | ValueInfo[] | 触发值 | - -### TimerTrigger - -```json -{ - "rule": "WEEKLY", - "start_time": "2025-01-01 09:00", - "sub_unit": [1, 3, 5], - "is_never_end": true -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `rule` | 是 | `NO_REPEAT` / `DAILY` / `WEEKLY` / `MONTHLY` / `YEARLY` / `WORKDAY` / `CUSTOM` | -| `start_time` | 否 | 开始时间,格式 `yyyy-MM-dd HH:mm` | -| `interval` | 否 | 自定义间隔 [1,30](仅 CUSTOM) | -| `unit` | 否 | 自定义单位:`SECOND` / `MINUTE` / `HOUR` / `DAY` / `WEEK` / `MONTH` / `YEAR` | -| `sub_unit` | 否 | 子单位(`WEEKLY` 时为星期几数组 0-6,`MONTHLY` 时为几号数组 1-31) | -| `end_time` | 否 | 结束时间 | -| `is_never_end` | 否 | 是否永不结束 | - -### ReminderTrigger - -```json -{ - "table_name": "项目表", - "field_name": "截止日期", - "offset": 1, - "unit": "DAY", - "hour": 9, - "minute": 0, - "condition_list": null -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `table_name` | 是 | 数据表名 | -| `field_name` | 是 | 日期字段名(必须为 `datetime` / `created_at` / `formula` / `lookup` 类型) | -| `unit` | 是 | 偏移单位:`MINUTE` / `HOUR` / `DAY` / `WEEK` / `MONTH` | -| `offset` | 是 | 提前/延后的偏移量(正数=提前,负数=延后;范围由 `unit` 决定):`MINUTE` ∈ {0, 5, 15, 30, -5, -15, -30};`HOUR` ∈ [-6, -1] ∪ [1, 6];`DAY` ∈ [-7, 7];`WEEK` ∈ [-7, -1] ∪ [1, 7];`MONTH` ∈ [-7, -1] ∪ [1, 7] | -| `hour` | 是 | 触发小时 (0-23),默认 9 | -| `minute` | 是 | 触发分钟 (0-59),默认 0 | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | - - -### ButtonTrigger - -```json -{ - "button_type": "buttonField", - "table_name": "审批表" -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `button_type` | 是 | 按钮类型:`buttonField`(表格里的按钮,可操作当前记录数据)/ `buttonElement`(仪表盘、应用页面上的按钮,可执行整体操作) | -| `table_name` | 否 | 绑定的数据表名,仅 `button_type=buttonField` 时填写 | - -> `buttonField` 和 `buttonElement` 的输出能力不同,详见下方「ButtonTrigger(按钮触发器)」输出说明。 - - -### LarkMessageTrigger - -```json -{ - "receive_scene": "group", - "receiver": [{ "value_type": "group", "value": {"id": "oc_xxxx", "name": "测试群"} }], - "scope": "all", - "filter": { - "conjunction": "and", - "content_contains": ["关键词"], - "sender_contains": [{ "value_type": "user", "value": {"id": "ou_xxxx", "name": ""} }], - "is_new_message": true, - "is_message_contain_attachment": false - } -} -``` - -| 字段 | 必填 | 说明| -|------|------|---| -| `receive_scene` | 是 | 接收场景:`group`(群聊)/ `chat`(单聊)| -| `receiver` | 是 | 触发来源,支持 `user` / `group` / `ref`。在单聊场景下,该字段指“可以和机器人单聊的用户”;在群聊场景下,该字段指“接收信息的群组”| -| `scope` | 是 | 触发范围:`at`(@提及)/ `all`(所有消息)。该参数仅在群聊场景有效,单聊场景请勿指定该参数| -| `filter` | 是 | MessageFilter 消息过滤条件| - -`MessageFilter`: - -| 字段 | 类型 | 说明 | -|------|------|----| -| `conjunction` | string | `and` 满足所有条件 / `or` 任一条件| -| `content_contains` | string[] | 关键词列表| -| `sender_contains` | ValueInfo[] | 筛选发送人(仅群聊+群组来源时生效,单聊场景请勿指定该参数)| -| `is_new_message` | boolean | 仅新话题消息(仅群聊时有效,单聊场景请勿指定该参数)| -| `is_message_contain_attachment` | boolean | 是否仅附件消息触发| - -## Action data 详细结构 - -### AddRecordAction - -```json -{ - "table_name": "订单表", - "field_values": [ - { "field_name": "客户名称", "value": [{ "value_type": "text", "value": "张三" }] }, - { "field_name": "金额", "value": [{ "value_type": "number", "value": 100 }] }, - { "field_name": "创建人", "value": [{ "value_type": "ref", "value": "$.trigger_1.fieldIdxxx" }] } - ] -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `table_name` | 是 | 目标数据表名 | -| `field_values` | 是 | RecordFieldValue[] | - -### SetRecordAction - -```json -{ - "table_name": "订单表", - "max_set_record_num": 10, - "field_values": [ - { "field_name": "状态", "value": [{ "value_type": "option", "value": { "id": "opt1", "name": "已完成" } }] } - ], - "filter_info": { /* RecordFilterInfo */ }, - "ref_info": { "step_id": "step_trigger" } -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `table_name` | 是 | 目标数据表名 | -| `max_set_record_num` | 否 | 最大更新记录数,默认 100,范围 1-15000 | -| `field_values` | 是 | RecordFieldValue[] | -| `filter_info` | 否* | RecordFilterInfo 过滤条件(与 `ref_info` 互斥) | -| `ref_info` | 否* | RefInfo 引用前置步骤的记录(与 `filter_info` 互斥) | - -### FindRecordAction - -```json -{ - "table_name": "客户表", - "field_names": ["客户名称", "联系方式", "等级"], - "should_proceed_when_no_results": true, - "filter_info": { /* RecordFilterInfo */ } -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `table_name` | 是 | 目标数据表名 | -| `field_names` | 是 | 要检索的字段名列表,至少一个 | -| `should_proceed_when_no_results` | 否 | 无结果时是否继续后续步骤,默认 `true` | -| `filter_info` | 否* | RecordFilterInfo(与 `ref_info` 互斥) | -| `ref_info` | 否* | RefInfo(与 `filter_info` 互斥) | - -### HTTPClientAction - -```json -{ - "method": "POST", - "url": [{ "value_type": "text", "value": "https://api.example.com/webhook" }], - "queries": [ - { "key": "source", "value": [{ "value_type": "text", "value": "workflow" }] } - ], - "headers": [ - { "key": "Content-Type", "value": [{ "value_type": "text", "value": "application/json" }] } - ], - "body_type": "raw", - "raw_body": [ - { "value_type": "text", "value": "{\"record_id\":\"" }, - { "value_type": "ref", "value": "$.step_1.recordId" }, - { "value_type": "text", "value": "\"}" } - ], - "response_type": "json", - "response_value": "{\"success\":true,\"message\":\"data fetched successfully\"}" -} -``` - -| 字段 | 必填 | 说明 | -|------|-----|------| -| `method` | 否 | 请求方法:`GET` / `POST` / `PUT` / `PATCH` / `DELETE`,默认 `POST` | -| `url` | 是 | ValueInfo[],请求 URL,支持 `text` / `ref` 拼接 | -| `queries` | 否 | KeyValue[],查询参数 | -| `headers` | 否 | KeyValue[],请求头 | -| `body_type` | 否 | 请求体类型:`none` / `raw` / `form-data` / `form-urlencoded`,默认 `raw` | -| `raw_body` | 否 | ValueInfo[],原始请求体,仅 `body_type=raw` 时使用 | -| `form_body` | 否 | KeyValue[],表单数据,仅 `body_type=form-data` 或 `body_type=form-urlencoded` 时使用 | -| `response_type` | 否 | 响应类型:`none` / `text` / `json`,默认 `json` | -| `response_value` | 否 | string,JSON 字符串形式的响应结果示例;仅当 `response_type=json` 时必填 | - -`KeyValue`: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `key` | string | 参数名 / 请求头名 | -| `value` | ValueInfo[] | 参数值 / 请求头值,支持 `text` / `ref` | - -### Delay - -```json -{ "duration": 30 } -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `duration` | 是 | 延迟时长(分钟),范围 [1, 120] | - -### LarkMessageAction - -```json -{ - "receiver": [{ "value_type": "user", "value": {"id": "ou_xxxx"} }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "新订单通知" }], - "content": [ - { "value_type": "text", "value": "客户 " }, - { "value_type": "ref", "value": "$.trigger_1.fldCustomerName" }, - { "value_type": "text", "value": " 创建了新订单" } - ], - "btn_list": [ - { "text": "查看详情", "btn_action": "openLink", "link": [{ "value_type": "text", "value": "https://example.com" }] } - ] -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `receiver` | 是 | ValueInfo[] | -| `send_to_everyone` | 是 | 是否发送给所有人 | -| `title` | 否 | TextRefItem[] 消息标题 | -| `content` | 是 | TextRefItem[] 消息内容 | -| `btn_list` | 是 | 按钮列表,不需要时为空数组 | - -`ButtonConfig`: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `text` | string | 按钮文字 | -| `btn_action` | string | `addRecord` / `setRecord` / `openLink` | -| `link` | ValueInfo[] | 跳转链接(`openLink` 时使用) | -| `table_name` | string | 操作表名(`addRecord` 时使用) | -| `record_values` | RecordFieldValue[] | 记录赋值(`addRecord` / `setRecord` 时使用) | - -### GenerateAiTextAction - -```json -{ - "prompt": [ - { "value_type": "text", "value": "请总结以下内容:" }, - { "value_type": "ref", "value": "$.step_1.fieldxxx" } - ] -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `prompt` | 是 | TextRefItem[] 提示词,支持 `text` / `ref` | - - -## Branch data 详细结构 - -### IfElseBranch - -`children.links` 包含 `if_true` 和 `if_false` 两条边,`next` 指向两个分支汇合后的后继节点。 - -**如果涉及到复杂的多分支场景(分支数目 >= 3时),你应该采用 SwitchBranch,而不是嵌套的 IfElseBranch** - -```json -{ - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" }, - "operator": "isGreater", - "right_value": [{ "value_type": "number", "value": 1000 }] - } - ] - } - ] - } -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `condition` | 是 | OrGroup 判断条件,结构为 `(A and B) or (C and D)` | - -### SwitchBranch - -`children.links` 包含多个 `case` 边(`label` 建议用 `branch_1`、`branch_2`,语义写在 `desc`)。 - -```json -{ - "mode": "exclusive", - "no_match_action": "classifyToOther", - "child_branch_list": [ - { - "name": "高优先级", - "condition": { - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" }, - "operator": "is", - "right_value": [{ "value_type": "text", "value": "P0" }] - } - ] - } - ] - } - } - ] -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `mode` | 否 | 分支模式。`exclusive`:排他模式,仅执行一个满足条件的子分支;`parallel`:并行模式,执行所有满足条件的子分支。默认 `exclusive` | -| `no_match_action` | 否 | `mode=exclusive` 时使用,无匹配时的处理策略。`classifyToOther`:归类到其他分支;`fail`:报错终止。默认 `classifyToOther` | -| `fail_mode` | 否 | `mode=parallel` 时使用,部分分支出错时策略。`partialSuccess`:部分成功即继续;`fail`:任一失败即终止。默认 `partialSuccess` | -| `match_mode` | 否 | `mode=parallel` 时使用,所有分支不满足时策略。`noneMatchSkip`:跳过继续;`noneMatchFail`:报错终止。默认 `noneMatchSkip` | -| `child_branch_list` | 是 | BranchItem[],1-10 个条件分支 | - -`BranchItem`: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `name` | string | 分支名称 | -| `condition` | OrGroup | 分支条件 | - - -## System data 详细结构 - -### Loop - -`children.links` 包含 `loop_start` 边指向循环体入口,`next` 指向循环结束后的后继节点。 - -```json -{ - "loop_mode": "continue", - "max_loop_times": 100, - "data": [{ "value_type": "ref", "value": "$.find_record_stepIdxxx.fieldRecords" }] -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `data` | 是 | ValueInfo[](仅支持 `ref` 类型),循环数据源,只能填一个 | -| `loop_mode` | 否 | 单次错误时是否继续:`end`(终止)/ `continue`(继续) | -| `max_loop_times` | 否 | 最大循环次数 | - ---- - - -## 公共类型 - -### ValueInfo - -所有值的基础类型,通过 `value_type` 区分: - -| value_type | value 类型 | 说明 | 示例 | -|------------|-----------|------|------| -| `text` | string | 文本 | `"张三"` | -| `number` | number | 数字 | `100` | -| `boolean` | boolean | 布尔值 | `true` | -| `date` | string | 日期,可以是具体时间字符串,或者相对时间值 | `"2025/01/01"`、`"2025/01/01 11:00"`、`"now"`、`"now 11:00"`、`"today"`、`"today 11:00"`、`"yesterday"`、`"yesterday 11:00"`、`"lastWeek"`、`"currentMonth"`、`"lastMonth"`、`"theLastWeek"`、`"theNextWeek"`、`"theLastMonth"`、`"theNextMonth"` | -| `option` | `{ id, name }` | 选项 | `{ "id": "opt1", "name": "已完成" }` | -| `link` | `{ text, link }` | 链接(含文字和 URL), 文字和 URL 的格式可以是 ValueInfo 中的 text/ref 类型 | `{ "text": [{ "value_type": "text", "value": "查看详情" }], "link": [{ "value_type": "text", "value": "https://example.com" }] }`、`{ "text": [{ "value_type": "text", "value": "查看详情" }], "link": [{ "value_type": "ref", "value": "$.step_1.fldXXX" }] }` | -| `user` | `{ id, name }` | 用户 OpenID、名字 | `{ "id": "ou_xxxx", "name": "张三" }` | -| `group` | `{ id, name }` | 群 Chat ID、名字 | `{ "id": "oc_xxx", "name": "测试群" }` | -| `ref` | `string` | 引用前置节点输出的路径 | 参考 ref 引用变量详解 章节 | - -> ⚠️ **所有涉及用户的 value 中的 id 统一使用 OpenID(`ou_xxxx` 格式)**,由 CLI 层来完成转换 -> ⚠️ **所有涉及群的 value 中的 id 统一使用 ChatID(`oc_xxxx` 格式)**,由 CLI 层来完成转换 - -### ref 引用变量详解 - -`ref` 类型是工作流中节点间数据传递的核心机制。当 `value_type` 为 `ref` 时,`value` 指向前置节点的某个输出变量。本节详细描述每个节点可供引用的输出变量定义。 - -#### 引用路径格式 - -``` -$.{stepId} -$.{stepId}.{pathId} -$.{stepId}.{pathId}.{childPathId} -$.{stepId}.{pathId}.{childPathId}.{grandChildPathId} -``` - -- `{stepId}`:前置节点的 `id`(即 WorkflowStep 中的 `id` 字段) -- `{pathId}`:节点输出的路径标识符 -- 支持多层下钻,如引用字段的属性:`$.step_1.fldXXX.name` - ---- - -#### 触发器节点输出 - -##### 记录触发器(AddRecordTrigger / ChangeRecordTrigger / SetRecordTrigger / ReminderTrigger) - -这 4 个触发器的输出结构完全一致: - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `{fieldId}` | 字段id,从配置表的所有字段或者指定字段id生成,可下钻字段属性 | `$.{stepId}.{fieldId}` | -| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` | -| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` | -| `startTime` | 触发时间戳 | `$.{stepId}.startTime` | -| `recordId` | 记录 ID | `$.{stepId}.recordId` | -| `recordLink` | 记录链接 | `$.{stepId}.recordLink` | -| `recordCreatedUser` | 记录创建者 | `$.{stepId}.recordCreatedUser` | -| `recordCreatedTime` | 记录创建时间 | `$.{stepId}.recordCreatedTime` | -| `recordModifiedUser` | 最后修改者 | `$.{stepId}.recordModifiedUser` | -| `recordModifiedTime` | 最后修改时间 | `$.{stepId}.recordModifiedTime` | - -**动态字段输出规则**: - -- 读取触发器所配置的数据表的所有字段 -- 每个字段生成一条输出:`pathId` = fieldId -- 若字段为关联字段,children 为关联表所有字段(单层下钻,不再递归) -- 每个字段可下钻特定的字段属性(见「字段属性下钻」) - -**recordLink 的 children**:如果配置了数据表,则为该表所有视图的列表,每个视图 `{ pathId: viewId, pathName: viewName, pathType: 'string' }`。引用示例:`$.{stepId}.recordLink.{viewId}`。 - -##### ButtonTrigger(按钮触发器) - -`ButtonTrigger` 的输出取决于 `button_type`: - -#### `button_type = buttonField` - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `{fieldId}` | 字段id,从配置表的所有字段或者指定字段id生成,可下钻字段属性 | `$.{stepId}.{fieldId}` | -| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` | -| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` | -| `recordId` | 记录 ID | `$.{stepId}.recordId` | -| `recordLink` | 记录链接 | `$.{stepId}.recordLink` | -| `recordCreatedUser` | 记录创建者 | `$.{stepId}.recordCreatedUser` | -| `recordModifiedUser` | 最后修改者 | `$.{stepId}.recordModifiedUser` | -| `recordModifiedTime` | 最后修改时间 | `$.{stepId}.recordModifiedTime` | -| `time` | 触发时间 | `$.{stepId}.time` | -| `user` | 触发人 | `$.{stepId}.user` | -| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` | - -#### `button_type = buttonElement` - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `time` | 触发时间 | `$.{stepId}.time` | -| `user` | 触发人 | `$.{stepId}.user` | -| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` | - -##### TimerTrigger(定时触发器) - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `scheduleTime` | 定时触发时间 | `$.{stepId}.scheduleTime` | - -##### LarkMessageTrigger(飞书消息触发器) - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `Sender` | 消息发送者 | `$.{stepId}.Sender` | -| `AtUser` | 消息中被@的用户 | `$.{stepId}.AtUser` | -| `SenderGroup` | 消息所在群(仅群聊场景) | `$.{stepId}.SenderGroup` | -| `MessageSendTime` | 消息发送时间 | `$.{stepId}.MessageSendTime` | -| `MessageContent` | 消息正文 | `$.{stepId}.MessageContent` | -| `MessageType` | 消息类型标识 | `$.{stepId}.MessageType` | -| `MessageID` | 消息唯一标识 | `$.{stepId}.MessageID` | -| `MessageLink` | 消息链接(仅群聊场景) | `$.{stepId}.MessageLink` | -| `ParentID` | 回复的消息 ID | `$.{stepId}.ParentID` | -| `ThreadID` | 所在话题消息 ID | `$.{stepId}.ThreadID` | -| `Attachments` | 消息中的附件 | `$.{stepId}.Attachments` | - -条件限制: - -- 若场景为单聊(`receive_scene = "Chat"`),则 `SenderGroup` 和 `MessageLink` 不可用 - ---- - -#### 操作节点输出 - -##### FindRecordAction(查找记录) - -| pathId | 说明 | 引用示例| -|--------|------|-------| -| `fieldRecords` | 所有找到的记录的引用(可用于 Loop 遍历) | `$.{stepId}.fieldRecords`| -| `firstfieldsRecord` | 第一条匹配记录 | `$.{stepId}.firstfieldsRecord`| -| `firstfieldsRecord.{fieldId}` | 首条记录的字段值,可下钻字段属性 | `$.{stepId}.firstfieldsRecord.{fieldId}`| -| `firstfieldsRecord.recordId` | 记录 ID 数组 | `$.{stepId}.firstfieldsRecord.recordId`| -| `fields` | 查找到的所有记录某列值 | 不支持引用| -| `fields.{fieldId}` | 用户选择的字段 | `$.{stepId}.fields.{fieldId}`| -| `fields.{fieldId}.fieldId` | 用户选择的字段id数组 | `$.{stepId}.fields.{fieldId}.fieldId`| -| `fields.{fieldId}.fieldName` | 用户选择的字段名数组 | `$.{stepId}.fields.{fieldId}.fieldName`| -| `fields.recordId` | 记录 ID 数组 | `$.{stepId}.fields.recordId`| -| `recordNum` | 找到记录总数 | `$.{stepId}.recordNum`| - -##### AddRecordAction(新增记录) - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` | -| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` | -| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` | -| `recordId` | 新增的记录 ID | `$.{stepId}.recordId` | -| `recordLink` | 新增的记录 URL | `$.{stepId}.recordLink` | - -##### SetRecordAction(更新记录) - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` | -| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` | -| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` | -| `recordId` | 记录 ID 数组(因可能更新多条记录) | `$.{stepId}.recordId` | - -##### HTTPClientAction(HTTP 请求) - -HTTPClientAction 的输出取决于 `response_type`: - -| response_type | 是否可引用 | 输出说明 | 引用示例 | -|--------------|-----------|----------|----------| -| `none` | 否 | 无任何可引用输出 | 不支持引用 | -| `text` | 是 | 整个响应文本作为节点整体输出 | `$.{stepId}` | -| `json` | 是 | 响应体整体挂在 `body` 下,同时返回 `status_code`;仅可引用 `response_value` 中声明的字段 | `$.{stepId}.body`、`$.{stepId}.body.success`、`$.{stepId}.body.message`、`$.{stepId}.status_code` | - -**补充说明**: - -- 当 `response_type = none` 时,后续节点无法引用 HTTPClientAction 的任何输出 -- 当 `response_type = text` 时,`$.{stepId}` 表示整个响应文本 -- 当 `response_type = json` 时,`$.{stepId}.body` 表示整个 JSON body,`$.{stepId}.body.字段名` 表示 body 中某个字段 -- 仅当 `response_type = json` 时,`$.{stepId}.status_code` 表示请求该 HTTP URL 后返回的 HTTP 状态码 -- 仅当 `response_type = json` 时,`response_value` 必填 -- 当 `response_type = json` 时,后续节点只能引用 `response_value` 中声明过的字段 - -**案例**: - -假设某个 `HTTPClientAction` 的配置如下: - -```json -{ - "id": "step_http_1", - "type": "HTTPClientAction", - "data": { - "response_type": "json", - "response_value": "{\"success\":true,\"message\":\"ok\"}" - } -} -``` - -则后续节点仅可以引用: - -- `$.step_http_1.body` -- `$.step_http_1.body.success` -- `$.step_http_1.body.message` -- `$.step_http_1.status_code` - -但**不能**引用未在 `response_value` 中声明的字段,例如: - -- `$.step_http_1.body.data` -- `$.step_http_1.body.request_id` - -##### GenerateAiTextAction(AI 生成文本) - -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| (整体出参) | AI 生成的文本内容(不支持下钻,只能引用 `$.{stepId}`) | `$.{stepId}` | - -##### 无输出的操作节点 - -以下节点不产生任何可引用的输出数据: - -- **Delay**(延时等待) -- **LarkMessageAction**(发送飞书消息) - ---- - -#### 分支节点输出 - -以下分支节点均不产生任何可引用的输出数据: - -- **IfElseBranch**(条件分支) -- **SwitchBranch**(多条件分支) - ---- - -#### 系统节点输出 - -##### Loop(循环) +## Step type 路由 -| pathId | 说明 | 引用示例 | -|--------|------|----------| -| `item` | 当前循环元素 | `$.{stepId}.item` | -| `index` | 从 0 开始的循环索引 | `$.{stepId}.index` | +### Trigger -**`item` 的类型推断规则**(由循环数据源决定): +| type | 说明 | 按需读取 | +|---|---|---| +| `AddRecordTrigger` | 新增记录时触发 | [trigger-add-record.md](workflow-steps/trigger-add-record.md) | +| `SetRecordTrigger` | 记录被修改时触发 | [trigger-set-record.md](workflow-steps/trigger-set-record.md) | +| `ChangeRecordTrigger` | 记录满足条件时触发;新增或修改都触发 | [trigger-change-record.md](workflow-steps/trigger-change-record.md) | +| `TimerTrigger` | 定时触发 | [trigger-timer.md](workflow-steps/trigger-timer.md) | +| `ReminderTrigger` | 日期提醒触发 | [trigger-reminder.md](workflow-steps/trigger-reminder.md) | +| `ButtonTrigger` | 按钮点击触发 | [trigger-button.md](workflow-steps/trigger-button.md) | +| `LarkMessageTrigger` | 接收飞书消息触发 | [trigger-lark-message.md](workflow-steps/trigger-lark-message.md) | -**场景一:遍历组合记录** — 数据源为 `record` 类型时(如 FindRecordAction 的 `fieldRecords`),`item` 类型为 `record`,可向下选择具体字段: +触发器选型:新增记录用 `AddRecordTrigger`;只监听修改用 `SetRecordTrigger`;新增或修改都触发、或拿不准时用 `ChangeRecordTrigger`。 -| 说明 | 引用示例 | -|------|----------| -| 当前遍历的记录(record) | `$.{loopStepId}.item` | -| 记录的具体字段 | `$.{loopStepId}.item.{fieldId}` | -| 从 0 开始的索引(number) | `$.{loopStepId}.index` | +### Action -**场景二:遍历字段** — 数据源为某个多值类型字段时,比如附件字段、人员字段,`item` 继承该字段的类型并可继续下钻字段属性: +| type | 说明 | 按需读取 | +|---|---|---| +| `AddRecordAction` | 新增记录 | [action-add-record.md](workflow-steps/action-add-record.md) | +| `SetRecordAction` | 更新记录 | [action-set-record.md](workflow-steps/action-set-record.md) | +| `FindRecordAction` | 查找记录 | [action-find-record.md](workflow-steps/action-find-record.md) | +| `HTTPClientAction` | HTTP 请求 | [action-http-client.md](workflow-steps/action-http-client.md) | +| `Delay` | 延迟 | [action-delay.md](workflow-steps/action-delay.md) | +| `LarkMessageAction` | 发送飞书消息 | [action-lark-message.md](workflow-steps/action-lark-message.md) | +| `GenerateAiTextAction` | AI 生成文本 | [action-generate-ai-text.md](workflow-steps/action-generate-ai-text.md) | -| 说明 | 引用示例 | -|------|----------| -| 当前遍历的元素(类型继承数据源字段类型,例如人员字段) | `$.{loopStepId}.item` | -| 用户姓名 | `$.{loopStepId}.item.name` | -| 从 0 开始的索引(number) | `$.{loopStepId}.index` | +所有 Action 节点不要设置 `children`,通过 `next` 串联后继。 ---- +### Branch / System -#### 字段属性下钻 +| type | 说明 | 按需读取 | +|---|---|---| +| `IfElseBranch` | 条件分支,`children.links` 含 `if_true` 和 `if_false` | [branch-if-else.md](workflow-steps/branch-if-else.md) | +| `SwitchBranch` | 多路分支,`children.links` 含多个 `case` | [branch-switch.md](workflow-steps/branch-switch.md) | +| `Loop` | 循环,`children.links` 含 `loop_start` 指向循环体入口 | [system-loop.md](workflow-steps/system-loop.md) | -每个字段变量都可以进一步下钻选择字段的属性。所有字段至少支持 `fieldId` 和 `fieldName` 两个基础属性,部分字段还支持额外属性: - -| 字段类型 | 属性名称 | 属性 pathId | 属性 pathType | 说明 | -|----------|---------|-------------|--------------|------| -| **所有字段(基础)** | 字段 ID | `fieldId` | `string` | 字段的唯一标识 | -| | 字段名称 | `fieldName` | `string` | 字段的显示名称 | -| **人员字段**(`user` / `created_by` / `updated_by`) | 姓名 | `name` | `string` | 用户姓名 | -| **日期字段**(`datetime` / `created_at` / `updated_at`) | 时间戳 | `timestamp` | `number` | 时间戳数值 | -| **附件字段**(`attachment`) | 文件名 | `fileName` | `string` | 附件文件名 | -| | 文件类型 | `fileType` | `string` | MIME 类型 | -| | 文件大小 | `size` | `number` | 文件字节数 | -| | 文件 Token | `fileToken` | `string` | 附件 token | -| **超链接文本字段**(`text` 且 `style.type=url`) | 文本 | `text` | `string` | 链接文本部分 | -| | 链接 | `link` | `string` | 链接 URL 部分 | -| **自动编号字段**(`auto_number`) | 序号 | `sequence` | `number` | 编号的纯数字序号 | -| **关联字段**(`link`) | 字段下钻 | `{fieldId}` | - | 可下钻到关联表的字段 | - -> 其他字段类型(如 `text`、`number`、`checkbox`、`select`、`location`、`formula`、`lookup` 等)仅支持 `fieldId` 和 `fieldName` 两个基础属性。 - -下钻引用示例: - -``` -$.{stepId}.{fieldId} → 字段值本身 -$.{stepId}.{fieldId}.fieldId → 字段 ID(string) -$.{stepId}.{fieldId}.fieldName → 字段名称(string) -$.{stepId}.{fieldId}.name → 人员姓名列表(array,仅人员字段) -$.{stepId}.{fieldId}.unionId → 人员 unionId 列表(array,仅人员字段) -$.{stepId}.{fieldId}.timestamp → 时间戳(array,仅日期字段) -$.{stepId}.{fieldId}.fileName → 文件名列表(array,仅附件字段) -$.{stepId}.{fieldId}.fileToken → 文件 Token 列表(array,仅附件字段) -``` - ---- - -#### 节点输出能力总览 - -| 节点 | 类型 | 有输出 | 输出特性 | -|------|------|--------|---------| -| AddRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | -| ChangeRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | -| SetRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | -| ReminderTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | -| ButtonTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性;buttonElement 仅基础触发属性) | -| TimerTrigger | 触发器 | ✅ | 静态(仅 scheduleTime) | -| LarkMessageTrigger | 触发器 | ✅ | 静态(消息属性列表) | -| FindRecordAction | 动作 | ✅ | 动态(用户选择的字段) | -| AddRecordAction | 动作 | ✅ | 动态(用户配置的字段) | -| SetRecordAction | 动作 | ✅ | 动态(用户配置的字段) | -| HTTPClientAction | 动作 | ✅ | 动态(取决于用户配置的 HTTP 响应输出) | -| GenerateAiTextAction | 动作 | ✅ | 静态(单 string) | -| Delay | 动作 | ❌ | 无输出 | -| LarkMessageAction | 动作 | ❌ | 无输出 | -| IfElseBranch | 分支 | ❌ | 无输出 | -| SwitchBranch | 分支 | ❌ | 无输出 | -| Loop | 系统 | ✅ | 动态(取决于数据源) | - ---- - -### TextRefItem - -文本与引用混排,用于消息内容等动态拼接场景: - -```json -[ - { "value_type": "text", "value": "客户 " }, - { "value_type": "ref", "value": "$.step_1.fieldxxx" }, - { "value_type": "text", "value": " 创建了新订单" } -] -``` - -### RecordFieldValue - -```json -{ "field_name": "客户名称", "value": [{ "value_type": "text", "value": "张三" }] } -``` - -### AndCondition(Trigger 过滤条件) - -```json -{ - "conjunction": "and", - "conditions": [ - { "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "进行中" }] } - ] -} -``` - -### OrGroup(Branch 分支条件) - -```json -{ - "conjunction": "or", - "conditions": [ - { - "conjunction": "and", - "conditions": [ - { - "left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" }, - "operator": "isGreater", - "right_value": [{ "value_type": "number", "value": 1000 }] - } - ] - } - ] -} -``` - -**operator 可选值:** `is` / `isNot` / `containsAny` / `doesNotContainAny` / /`containsAll`/ `isEmpty` / `isNotEmpty` / `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual` - -### RecordFilterInfo -** 由于 conjunction 只支持 and,若需要实现 字段X 等于 A 或 B,你可以使用 containsAny -```json -{ - "conjunction": "and", - "conditions": [ - { "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "进行中" }] } - ] -} -``` - -### `select` 字段多值匹配 - -| 操作 | operator | 正确写法 | -|------|---------|---------| -| 等于单个值 | `is` | `[{"value_type": "option", "value": {"name": "L2"}}]` | -| 匹配多个值(L2 或 L3) | `containsAny` | `[{"value_type": "option", "value": {"name": "L2"}}, {"value_type": "option", "value": {"name": "L3"}}]` | - -> ⚠️ 不要用多个 `is` 条件(会被当作 OR,无法实现 AND)。推荐使用 `containsAny` 操作符匹配多个值。 - -> ⚠️ **Select 字段条件**:`value_type` 必须为 `option`,`value` 对象可只传 `name`(如 `{"name": "L2"}`),无需提供选项 ID。 - -### RefInfo - -```json -{ "step_id": "step_trigger" } -``` - ---- - -## 完整示例:条件分支 + 发送消息 - -```json -{ - "title": "新订单自动通知", - "steps": [ - { - "id": "step_1", - "type": "AddRecordTrigger", - "title": "当「订单表」新增记录时触发", - "next": "step_2", - "data": { - "table_name": "订单表", - "watched_field_name": "订单编号" - } - }, - { - "id": "step_2", - "type": "IfElseBranch", - "title": "判断订单金额是否大于 1000", - "children": { - "links": [ - { "kind": "if_true", "to": "step_3" }, - { "kind": "if_false", "to": "step_4" } - ] - }, - "next": "step_5", - "data": { - "condition": { - "conjunction": "or", - "conditions": [{ - "conjunction": "and", - "conditions": [{ - "left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" }, - "operator": "isGreater", - "right_value": [{ "value_type": "number", "value": 1000 }] - }] - }] - } - } - }, - { - "id": "step_3", - "type": "LarkMessageAction", - "title": "通知主管审批大额订单", - "next": null, - "data": { - "receiver": [{ "value_type": "ref", "value": "$.step_1.fieldxxx" }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "大额订单提醒" }], - "content": [ - { "value_type": "text", "value": "新订单金额为:" }, - { "value_type": "ref", "value": "$.step_1.fieldxxx" }, - { "value_type": "text", "value": "元,请及时审批。" } - ], - "btn_list": [] - } - }, - { - "id": "step_4", - "type": "SetRecordAction", - "title": "自动标记小额订单为已通过", - "next": null, - "data": { - "table_name": "订单表", - "ref_info": { "step_id": "step_1" }, - "field_values": [ - { "field_name": "审批状态", "value": [{ "value_type": "text", "value": "已通过" }] } - ] - } - }, - { - "id": "step_5", - "type": "GenerateAiTextAction", - "title": "AI 生成订单处理日报", - "next": null, - "data": { - "prompt": [ - { "value_type": "text", "value": "请根据以下订单信息生成一份简要的处理日报:" }, - { "value_type": "ref", "value": "$.step_1.fieldxxx" } - ] - } - } - ] -} -``` +## 公共结构 ---- +只有在需要构造 `value_type`、`ref`、条件过滤、字段值、节点输出引用时,才读 [common-types-and-refs.md](workflow-steps/common-types-and-refs.md)。 ## 参考 -- [lark-base-workflow-guide.md](lark-base-workflow-guide.md) — 完整示例和构造技巧 -- 创建/更新时外层只承载 workflow 元信息,核心校验对象是 `steps`;列表只用于拿 workflow ID 和启停状态 +- Workflow 创建/更新入口路由:[lark-base-workflow-guide.md](lark-base-workflow-guide.md) +- 命令参数以 `lark-cli base +workflow-create --help` / `+workflow-update --help` 为准。 diff --git a/skills/lark-base/references/workflow-steps/action-add-record.md b/skills/lark-base/references/workflow-steps/action-add-record.md new file mode 100644 index 000000000..95eb7035e --- /dev/null +++ b/skills/lark-base/references/workflow-steps/action-add-record.md @@ -0,0 +1,24 @@ +# AddRecordAction + +```json +{ + "table_name": "订单表", + "field_values": [ + { "field_name": "客户名称", "value": [{ "value_type": "text", "value": "张三" }] }, + { "field_name": "金额", "value": [{ "value_type": "number", "value": 100 }] }, + { "field_name": "创建人", "value": [{ "value_type": "ref", "value": "$.trigger_1.fieldIdxxx" }] } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 目标数据表名 | +| `field_values` | 是 | RecordFieldValue[] | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/action-delay.md b/skills/lark-base/references/workflow-steps/action-delay.md new file mode 100644 index 000000000..4e3577862 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/action-delay.md @@ -0,0 +1,16 @@ +# Delay + +```json +{ "duration": 30 } +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `duration` | 是 | 延迟时长(分钟),范围 [1, 120] | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/action-find-record.md b/skills/lark-base/references/workflow-steps/action-find-record.md new file mode 100644 index 000000000..ecaaed8d6 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/action-find-record.md @@ -0,0 +1,25 @@ +# FindRecordAction + +```json +{ + "table_name": "客户表", + "field_names": ["客户名称", "联系方式", "等级"], + "should_proceed_when_no_results": true, + "filter_info": { /* RecordFilterInfo */ } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 目标数据表名 | +| `field_names` | 是 | 要检索的字段名列表,至少一个 | +| `should_proceed_when_no_results` | 否 | 无结果时是否继续后续步骤,默认 `true` | +| `filter_info` | 否* | RecordFilterInfo(与 `ref_info` 互斥) | +| `ref_info` | 否* | RefInfo(与 `filter_info` 互斥) | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/action-generate-ai-text.md b/skills/lark-base/references/workflow-steps/action-generate-ai-text.md new file mode 100644 index 000000000..556243b4e --- /dev/null +++ b/skills/lark-base/references/workflow-steps/action-generate-ai-text.md @@ -0,0 +1,21 @@ +# GenerateAiTextAction + +```json +{ + "prompt": [ + { "value_type": "text", "value": "请总结以下内容:" }, + { "value_type": "ref", "value": "$.step_1.fieldxxx" } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `prompt` | 是 | TextRefItem[] 提示词,支持 `text` / `ref` | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/action-http-client.md b/skills/lark-base/references/workflow-steps/action-http-client.md new file mode 100644 index 000000000..a7a4abc01 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/action-http-client.md @@ -0,0 +1,48 @@ +# HTTPClientAction + +```json +{ + "method": "POST", + "url": [{ "value_type": "text", "value": "https://api.example.com/webhook" }], + "queries": [ + { "key": "source", "value": [{ "value_type": "text", "value": "workflow" }] } + ], + "headers": [ + { "key": "Content-Type", "value": [{ "value_type": "text", "value": "application/json" }] } + ], + "body_type": "raw", + "raw_body": [ + { "value_type": "text", "value": "{\"record_id\":\"" }, + { "value_type": "ref", "value": "$.step_1.recordId" }, + { "value_type": "text", "value": "\"}" } + ], + "response_type": "json", + "response_value": "{\"success\":true,\"message\":\"data fetched successfully\"}" +} +``` + +| 字段 | 必填 | 说明 | +|------|-----|------| +| `method` | 否 | 请求方法:`GET` / `POST` / `PUT` / `PATCH` / `DELETE`,默认 `POST` | +| `url` | 是 | ValueInfo[],请求 URL,支持 `text` / `ref` 拼接 | +| `queries` | 否 | KeyValue[],查询参数 | +| `headers` | 否 | KeyValue[],请求头 | +| `body_type` | 否 | 请求体类型:`none` / `raw` / `form-data` / `form-urlencoded`,默认 `raw` | +| `raw_body` | 否 | ValueInfo[],原始请求体,仅 `body_type=raw` 时使用 | +| `form_body` | 否 | KeyValue[],表单数据,仅 `body_type=form-data` 或 `body_type=form-urlencoded` 时使用 | +| `response_type` | 否 | 响应类型:`none` / `text` / `json`,默认 `json` | +| `response_value` | 否 | string,JSON 字符串形式的响应结果示例;仅当 `response_type=json` 时必填 | + +`KeyValue`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `key` | string | 参数名 / 请求头名 | +| `value` | ValueInfo[] | 参数值 / 请求头值,支持 `text` / `ref` | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/action-lark-message.md b/skills/lark-base/references/workflow-steps/action-lark-message.md new file mode 100644 index 000000000..13e8357af --- /dev/null +++ b/skills/lark-base/references/workflow-steps/action-lark-message.md @@ -0,0 +1,42 @@ +# LarkMessageAction + +```json +{ + "receiver": [{ "value_type": "user", "value": {"id": "ou_xxxx"} }], + "send_to_everyone": false, + "title": [{ "value_type": "text", "value": "新订单通知" }], + "content": [ + { "value_type": "text", "value": "客户 " }, + { "value_type": "ref", "value": "$.trigger_1.fldCustomerName" }, + { "value_type": "text", "value": " 创建了新订单" } + ], + "btn_list": [ + { "text": "查看详情", "btn_action": "openLink", "link": [{ "value_type": "text", "value": "https://example.com" }] } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `receiver` | 是 | ValueInfo[] | +| `send_to_everyone` | 是 | 是否发送给所有人 | +| `title` | 否 | TextRefItem[] 消息标题 | +| `content` | 是 | TextRefItem[] 消息内容 | +| `btn_list` | 是 | 按钮列表,不需要时为空数组 | + +`ButtonConfig`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `text` | string | 按钮文字 | +| `btn_action` | string | `addRecord` / `setRecord` / `openLink` | +| `link` | ValueInfo[] | 跳转链接(`openLink` 时使用) | +| `table_name` | string | 操作表名(`addRecord` 时使用) | +| `record_values` | RecordFieldValue[] | 记录赋值(`addRecord` / `setRecord` 时使用) | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/action-set-record.md b/skills/lark-base/references/workflow-steps/action-set-record.md new file mode 100644 index 000000000..eeab3173b --- /dev/null +++ b/skills/lark-base/references/workflow-steps/action-set-record.md @@ -0,0 +1,28 @@ +# SetRecordAction + +```json +{ + "table_name": "订单表", + "max_set_record_num": 10, + "field_values": [ + { "field_name": "状态", "value": [{ "value_type": "option", "value": { "id": "opt1", "name": "已完成" } }] } + ], + "filter_info": { /* RecordFilterInfo */ }, + "ref_info": { "step_id": "step_trigger" } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 目标数据表名 | +| `max_set_record_num` | 否 | 最大更新记录数,默认 100,范围 1-15000 | +| `field_values` | 是 | RecordFieldValue[] | +| `filter_info` | 否* | RecordFilterInfo 过滤条件(与 `ref_info` 互斥) | +| `ref_info` | 否* | RefInfo 引用前置步骤的记录(与 `filter_info` 互斥) | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/branch-if-else.md b/skills/lark-base/references/workflow-steps/branch-if-else.md new file mode 100644 index 000000000..31bd43892 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/branch-if-else.md @@ -0,0 +1,36 @@ +# IfElseBranch + +`children.links` 包含 `if_true` 和 `if_false` 两条边,`next` 指向两个分支汇合后的后继节点。 + +**如果涉及到复杂的多分支场景(分支数目 >= 3时),你应该采用 SwitchBranch,而不是嵌套的 IfElseBranch** + +```json +{ + "condition": { + "conjunction": "or", + "conditions": [ + { + "conjunction": "and", + "conditions": [ + { + "left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" }, + "operator": "isGreater", + "right_value": [{ "value_type": "number", "value": 1000 }] + } + ] + } + ] + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `condition` | 是 | OrGroup 判断条件,结构为 `(A and B) or (C and D)` | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/branch-switch.md b/skills/lark-base/references/workflow-steps/branch-switch.md new file mode 100644 index 000000000..eda55ad7d --- /dev/null +++ b/skills/lark-base/references/workflow-steps/branch-switch.md @@ -0,0 +1,52 @@ +# SwitchBranch + +`children.links` 包含多个 `case` 边(`label` 建议用 `branch_1`、`branch_2`,语义写在 `desc`)。 + +```json +{ + "mode": "exclusive", + "no_match_action": "classifyToOther", + "child_branch_list": [ + { + "name": "高优先级", + "condition": { + "conjunction": "or", + "conditions": [ + { + "conjunction": "and", + "conditions": [ + { + "left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" }, + "operator": "is", + "right_value": [{ "value_type": "text", "value": "P0" }] + } + ] + } + ] + } + } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `mode` | 否 | 分支模式。`exclusive`:排他模式,仅执行一个满足条件的子分支;`parallel`:并行模式,执行所有满足条件的子分支。默认 `exclusive` | +| `no_match_action` | 否 | `mode=exclusive` 时使用,无匹配时的处理策略。`classifyToOther`:归类到其他分支;`fail`:报错终止。默认 `classifyToOther` | +| `fail_mode` | 否 | `mode=parallel` 时使用,部分分支出错时策略。`partialSuccess`:部分成功即继续;`fail`:任一失败即终止。默认 `partialSuccess` | +| `match_mode` | 否 | `mode=parallel` 时使用,所有分支不满足时策略。`noneMatchSkip`:跳过继续;`noneMatchFail`:报错终止。默认 `noneMatchSkip` | +| `child_branch_list` | 是 | BranchItem[],1-10 个条件分支 | + +`BranchItem`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | string | 分支名称 | +| `condition` | OrGroup | 分支条件 | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/common-types-and-refs.md b/skills/lark-base/references/workflow-steps/common-types-and-refs.md new file mode 100644 index 000000000..5b038bd15 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/common-types-and-refs.md @@ -0,0 +1,401 @@ +# Workflow common types and refs + +### ValueInfo + +所有值的基础类型,通过 `value_type` 区分: + +| value_type | value 类型 | 说明 | 示例 | +|------------|-----------|------|------| +| `text` | string | 文本 | `"张三"` | +| `number` | number | 数字 | `100` | +| `boolean` | boolean | 布尔值 | `true` | +| `date` | string | 日期,可以是具体时间字符串,或者相对时间值 | `"2025/01/01"`、`"2025/01/01 11:00"`、`"now"`、`"now 11:00"`、`"today"`、`"today 11:00"`、`"yesterday"`、`"yesterday 11:00"`、`"lastWeek"`、`"currentMonth"`、`"lastMonth"`、`"theLastWeek"`、`"theNextWeek"`、`"theLastMonth"`、`"theNextMonth"` | +| `option` | `{ id, name }` | 选项 | `{ "id": "opt1", "name": "已完成" }` | +| `link` | `{ text, link }` | 链接(含文字和 URL), 文字和 URL 的格式可以是 ValueInfo 中的 text/ref 类型 | `{ "text": [{ "value_type": "text", "value": "查看详情" }], "link": [{ "value_type": "text", "value": "https://example.com" }] }`、`{ "text": [{ "value_type": "text", "value": "查看详情" }], "link": [{ "value_type": "ref", "value": "$.step_1.fldXXX" }] }` | +| `user` | `{ id, name }` | 用户 OpenID、名字 | `{ "id": "ou_xxxx", "name": "张三" }` | +| `group` | `{ id, name }` | 群 Chat ID、名字 | `{ "id": "oc_xxx", "name": "测试群" }` | +| `ref` | `string` | 引用前置节点输出的路径 | 参考 ref 引用变量详解 章节 | + +> ⚠️ **所有涉及用户的 value 中的 id 统一使用 OpenID(`ou_xxxx` 格式)**,由 CLI 层来完成转换 +> ⚠️ **所有涉及群的 value 中的 id 统一使用 ChatID(`oc_xxxx` 格式)**,由 CLI 层来完成转换 + +### ref 引用变量详解 + +`ref` 类型是工作流中节点间数据传递的核心机制。当 `value_type` 为 `ref` 时,`value` 指向前置节点的某个输出变量。本节详细描述每个节点可供引用的输出变量定义。 + +#### 引用路径格式 + +``` +$.{stepId} +$.{stepId}.{pathId} +$.{stepId}.{pathId}.{childPathId} +$.{stepId}.{pathId}.{childPathId}.{grandChildPathId} +``` + +- `{stepId}`:前置节点的 `id`(即 WorkflowStep 中的 `id` 字段) +- `{pathId}`:节点输出的路径标识符 +- 支持多层下钻,如引用字段的属性:`$.step_1.fldXXX.name` + +--- + +#### 触发器节点输出 + +##### 记录触发器(AddRecordTrigger / ChangeRecordTrigger / SetRecordTrigger / ReminderTrigger) + +这 4 个触发器的输出结构完全一致: + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `{fieldId}` | 字段id,从配置表的所有字段或者指定字段id生成,可下钻字段属性 | `$.{stepId}.{fieldId}` | +| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` | +| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` | +| `startTime` | 触发时间戳 | `$.{stepId}.startTime` | +| `recordId` | 记录 ID | `$.{stepId}.recordId` | +| `recordLink` | 记录链接 | `$.{stepId}.recordLink` | +| `recordCreatedUser` | 记录创建者 | `$.{stepId}.recordCreatedUser` | +| `recordCreatedTime` | 记录创建时间 | `$.{stepId}.recordCreatedTime` | +| `recordModifiedUser` | 最后修改者 | `$.{stepId}.recordModifiedUser` | +| `recordModifiedTime` | 最后修改时间 | `$.{stepId}.recordModifiedTime` | + +**动态字段输出规则**: + +- 读取触发器所配置的数据表的所有字段 +- 每个字段生成一条输出:`pathId` = fieldId +- 若字段为关联字段,children 为关联表所有字段(单层下钻,不再递归) +- 每个字段可下钻特定的字段属性(见「字段属性下钻」) + +**recordLink 的 children**:如果配置了数据表,则为该表所有视图的列表,每个视图 `{ pathId: viewId, pathName: viewName, pathType: 'string' }`。引用示例:`$.{stepId}.recordLink.{viewId}`。 + +##### ButtonTrigger(按钮触发器) + +`ButtonTrigger` 的输出取决于 `button_type`: + +#### `button_type = buttonField` + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `{fieldId}` | 字段id,从配置表的所有字段或者指定字段id生成,可下钻字段属性 | `$.{stepId}.{fieldId}` | +| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` | +| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` | +| `recordId` | 记录 ID | `$.{stepId}.recordId` | +| `recordLink` | 记录链接 | `$.{stepId}.recordLink` | +| `recordCreatedUser` | 记录创建者 | `$.{stepId}.recordCreatedUser` | +| `recordModifiedUser` | 最后修改者 | `$.{stepId}.recordModifiedUser` | +| `recordModifiedTime` | 最后修改时间 | `$.{stepId}.recordModifiedTime` | +| `time` | 触发时间 | `$.{stepId}.time` | +| `user` | 触发人 | `$.{stepId}.user` | +| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` | + +#### `button_type = buttonElement` + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `time` | 触发时间 | `$.{stepId}.time` | +| `user` | 触发人 | `$.{stepId}.user` | +| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` | + +##### TimerTrigger(定时触发器) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `scheduleTime` | 定时触发时间 | `$.{stepId}.scheduleTime` | + +##### LarkMessageTrigger(飞书消息触发器) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `Sender` | 消息发送者 | `$.{stepId}.Sender` | +| `AtUser` | 消息中被@的用户 | `$.{stepId}.AtUser` | +| `SenderGroup` | 消息所在群(仅群聊场景) | `$.{stepId}.SenderGroup` | +| `MessageSendTime` | 消息发送时间 | `$.{stepId}.MessageSendTime` | +| `MessageContent` | 消息正文 | `$.{stepId}.MessageContent` | +| `MessageType` | 消息类型标识 | `$.{stepId}.MessageType` | +| `MessageID` | 消息唯一标识 | `$.{stepId}.MessageID` | +| `MessageLink` | 消息链接(仅群聊场景) | `$.{stepId}.MessageLink` | +| `ParentID` | 回复的消息 ID | `$.{stepId}.ParentID` | +| `ThreadID` | 所在话题消息 ID | `$.{stepId}.ThreadID` | +| `Attachments` | 消息中的附件 | `$.{stepId}.Attachments` | + +条件限制: + +- 若场景为单聊(`receive_scene = "Chat"`),则 `SenderGroup` 和 `MessageLink` 不可用 + +--- + +#### 操作节点输出 + +##### FindRecordAction(查找记录) + +| pathId | 说明 | 引用示例| +|--------|------|-------| +| `fieldRecords` | 所有找到的记录的引用(可用于 Loop 遍历) | `$.{stepId}.fieldRecords`| +| `firstfieldsRecord` | 第一条匹配记录 | `$.{stepId}.firstfieldsRecord`| +| `firstfieldsRecord.{fieldId}` | 首条记录的字段值,可下钻字段属性 | `$.{stepId}.firstfieldsRecord.{fieldId}`| +| `firstfieldsRecord.recordId` | 记录 ID 数组 | `$.{stepId}.firstfieldsRecord.recordId`| +| `fields` | 查找到的所有记录某列值 | 不支持引用| +| `fields.{fieldId}` | 用户选择的字段 | `$.{stepId}.fields.{fieldId}`| +| `fields.{fieldId}.fieldId` | 用户选择的字段id数组 | `$.{stepId}.fields.{fieldId}.fieldId`| +| `fields.{fieldId}.fieldName` | 用户选择的字段名数组 | `$.{stepId}.fields.{fieldId}.fieldName`| +| `fields.recordId` | 记录 ID 数组 | `$.{stepId}.fields.recordId`| +| `recordNum` | 找到记录总数 | `$.{stepId}.recordNum`| + +##### AddRecordAction(新增记录) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` | +| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` | +| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` | +| `recordId` | 新增的记录 ID | `$.{stepId}.recordId` | +| `recordLink` | 新增的记录 URL | `$.{stepId}.recordLink` | + +##### SetRecordAction(更新记录) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` | +| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` | +| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` | +| `recordId` | 记录 ID 数组(因可能更新多条记录) | `$.{stepId}.recordId` | + +##### HTTPClientAction(HTTP 请求) + +HTTPClientAction 的输出取决于 `response_type`: + +| response_type | 是否可引用 | 输出说明 | 引用示例 | +|--------------|-----------|----------|----------| +| `none` | 否 | 无任何可引用输出 | 不支持引用 | +| `text` | 是 | 整个响应文本作为节点整体输出 | `$.{stepId}` | +| `json` | 是 | 响应体整体挂在 `body` 下,同时返回 `status_code`;仅可引用 `response_value` 中声明的字段 | `$.{stepId}.body`、`$.{stepId}.body.success`、`$.{stepId}.body.message`、`$.{stepId}.status_code` | + +**补充说明**: + +- 当 `response_type = none` 时,后续节点无法引用 HTTPClientAction 的任何输出 +- 当 `response_type = text` 时,`$.{stepId}` 表示整个响应文本 +- 当 `response_type = json` 时,`$.{stepId}.body` 表示整个 JSON body,`$.{stepId}.body.字段名` 表示 body 中某个字段 +- 仅当 `response_type = json` 时,`$.{stepId}.status_code` 表示请求该 HTTP URL 后返回的 HTTP 状态码 +- 仅当 `response_type = json` 时,`response_value` 必填 +- 当 `response_type = json` 时,后续节点只能引用 `response_value` 中声明过的字段 + +**案例**: + +假设某个 `HTTPClientAction` 的配置如下: + +```json +{ + "id": "step_http_1", + "type": "HTTPClientAction", + "data": { + "response_type": "json", + "response_value": "{\"success\":true,\"message\":\"ok\"}" + } +} +``` + +则后续节点仅可以引用: + +- `$.step_http_1.body` +- `$.step_http_1.body.success` +- `$.step_http_1.body.message` +- `$.step_http_1.status_code` + +但**不能**引用未在 `response_value` 中声明的字段,例如: + +- `$.step_http_1.body.data` +- `$.step_http_1.body.request_id` + +##### GenerateAiTextAction(AI 生成文本) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| (整体出参) | AI 生成的文本内容(不支持下钻,只能引用 `$.{stepId}`) | `$.{stepId}` | + +##### 无输出的操作节点 + +以下节点不产生任何可引用的输出数据: + +- **Delay**(延时等待) +- **LarkMessageAction**(发送飞书消息) + +--- + +#### 分支节点输出 + +以下分支节点均不产生任何可引用的输出数据: + +- **IfElseBranch**(条件分支) +- **SwitchBranch**(多条件分支) + +--- + +#### 系统节点输出 + +##### Loop(循环) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `item` | 当前循环元素 | `$.{stepId}.item` | +| `index` | 从 0 开始的循环索引 | `$.{stepId}.index` | + +**`item` 的类型推断规则**(由循环数据源决定): + +**场景一:遍历组合记录** — 数据源为 `record` 类型时(如 FindRecordAction 的 `fieldRecords`),`item` 类型为 `record`,可向下选择具体字段: + +| 说明 | 引用示例 | +|------|----------| +| 当前遍历的记录(record) | `$.{loopStepId}.item` | +| 记录的具体字段 | `$.{loopStepId}.item.{fieldId}` | +| 从 0 开始的索引(number) | `$.{loopStepId}.index` | + +**场景二:遍历字段** — 数据源为某个多值类型字段时,比如附件字段、人员字段,`item` 继承该字段的类型并可继续下钻字段属性: + +| 说明 | 引用示例 | +|------|----------| +| 当前遍历的元素(类型继承数据源字段类型,例如人员字段) | `$.{loopStepId}.item` | +| 用户姓名 | `$.{loopStepId}.item.name` | +| 从 0 开始的索引(number) | `$.{loopStepId}.index` | + +--- + +#### 字段属性下钻 + +每个字段变量都可以进一步下钻选择字段的属性。所有字段至少支持 `fieldId` 和 `fieldName` 两个基础属性,部分字段还支持额外属性: + +| 字段类型 | 属性名称 | 属性 pathId | 属性 pathType | 说明 | +|----------|---------|-------------|--------------|------| +| **所有字段(基础)** | 字段 ID | `fieldId` | `string` | 字段的唯一标识 | +| | 字段名称 | `fieldName` | `string` | 字段的显示名称 | +| **人员字段**(`user` / `created_by` / `updated_by`) | 姓名 | `name` | `string` | 用户姓名 | +| **日期字段**(`datetime` / `created_at` / `updated_at`) | 时间戳 | `timestamp` | `number` | 时间戳数值 | +| **附件字段**(`attachment`) | 文件名 | `fileName` | `string` | 附件文件名 | +| | 文件类型 | `fileType` | `string` | MIME 类型 | +| | 文件大小 | `size` | `number` | 文件字节数 | +| | 文件 Token | `fileToken` | `string` | 附件 token | +| **超链接文本字段**(`text` 且 `style.type=url`) | 文本 | `text` | `string` | 链接文本部分 | +| | 链接 | `link` | `string` | 链接 URL 部分 | +| **自动编号字段**(`auto_number`) | 序号 | `sequence` | `number` | 编号的纯数字序号 | +| **关联字段**(`link`) | 字段下钻 | `{fieldId}` | - | 可下钻到关联表的字段 | + +> 其他字段类型(如 `text`、`number`、`checkbox`、`select`、`location`、`formula`、`lookup` 等)仅支持 `fieldId` 和 `fieldName` 两个基础属性。 + +下钻引用示例: + +``` +$.{stepId}.{fieldId} → 字段值本身 +$.{stepId}.{fieldId}.fieldId → 字段 ID(string) +$.{stepId}.{fieldId}.fieldName → 字段名称(string) +$.{stepId}.{fieldId}.name → 人员姓名列表(array,仅人员字段) +$.{stepId}.{fieldId}.unionId → 人员 unionId 列表(array,仅人员字段) +$.{stepId}.{fieldId}.timestamp → 时间戳(array,仅日期字段) +$.{stepId}.{fieldId}.fileName → 文件名列表(array,仅附件字段) +$.{stepId}.{fieldId}.fileToken → 文件 Token 列表(array,仅附件字段) +``` + +--- + +#### 节点输出能力总览 + +| 节点 | 类型 | 有输出 | 输出特性 | +|------|------|--------|---------| +| AddRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | +| ChangeRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | +| SetRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | +| ReminderTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) | +| ButtonTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性;buttonElement 仅基础触发属性) | +| TimerTrigger | 触发器 | ✅ | 静态(仅 scheduleTime) | +| LarkMessageTrigger | 触发器 | ✅ | 静态(消息属性列表) | +| FindRecordAction | 动作 | ✅ | 动态(用户选择的字段) | +| AddRecordAction | 动作 | ✅ | 动态(用户配置的字段) | +| SetRecordAction | 动作 | ✅ | 动态(用户配置的字段) | +| HTTPClientAction | 动作 | ✅ | 动态(取决于用户配置的 HTTP 响应输出) | +| GenerateAiTextAction | 动作 | ✅ | 静态(单 string) | +| Delay | 动作 | ❌ | 无输出 | +| LarkMessageAction | 动作 | ❌ | 无输出 | +| IfElseBranch | 分支 | ❌ | 无输出 | +| SwitchBranch | 分支 | ❌ | 无输出 | +| Loop | 系统 | ✅ | 动态(取决于数据源) | + +--- + +### TextRefItem + +文本与引用混排,用于消息内容等动态拼接场景: + +```json +[ + { "value_type": "text", "value": "客户 " }, + { "value_type": "ref", "value": "$.step_1.fieldxxx" }, + { "value_type": "text", "value": " 创建了新订单" } +] +``` + +### RecordFieldValue + +```json +{ "field_name": "客户名称", "value": [{ "value_type": "text", "value": "张三" }] } +``` + +### AndCondition(Trigger 过滤条件) + +```json +{ + "conjunction": "and", + "conditions": [ + { "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "进行中" }] } + ] +} +``` + +### OrGroup(Branch 分支条件) + +```json +{ + "conjunction": "or", + "conditions": [ + { + "conjunction": "and", + "conditions": [ + { + "left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" }, + "operator": "isGreater", + "right_value": [{ "value_type": "number", "value": 1000 }] + } + ] + } + ] +} +``` + +**operator 可选值:** `is` / `isNot` / `containsAny` / `doesNotContainAny` / /`containsAll`/ `isEmpty` / `isNotEmpty` / `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual` + +### RecordFilterInfo +** 由于 conjunction 只支持 and,若需要实现 字段X 等于 A 或 B,你可以使用 containsAny +```json +{ + "conjunction": "and", + "conditions": [ + { "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "进行中" }] } + ] +} +``` + +### `select` 字段多值匹配 + +| 操作 | operator | 正确写法 | +|------|---------|---------| +| 等于单个值 | `is` | `[{"value_type": "option", "value": {"name": "L2"}}]` | +| 匹配多个值(L2 或 L3) | `containsAny` | `[{"value_type": "option", "value": {"name": "L2"}}, {"value_type": "option", "value": {"name": "L3"}}]` | + +> ⚠️ 不要用多个 `is` 条件(会被当作 OR,无法实现 AND)。推荐使用 `containsAny` 操作符匹配多个值。 + +> ⚠️ **Select 字段条件**:`value_type` 必须为 `option`,`value` 对象可只传 `name`(如 `{"name": "L2"}`),无需提供选项 ID。 + +### RefInfo + +```json +{ "step_id": "step_trigger" } +``` + +--- + +返回 [Workflow schema index](../lark-base-workflow-schema.md)。 diff --git a/skills/lark-base/references/workflow-steps/system-loop.md b/skills/lark-base/references/workflow-steps/system-loop.md new file mode 100644 index 000000000..93ee75444 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/system-loop.md @@ -0,0 +1,26 @@ +# Loop + +`children.links` 包含 `loop_start` 边指向循环体入口,`next` 指向循环结束后的后继节点。 + +```json +{ + "loop_mode": "continue", + "max_loop_times": 100, + "data": [{ "value_type": "ref", "value": "$.find_record_stepIdxxx.fieldRecords" }] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `data` | 是 | ValueInfo[](仅支持 `ref` 类型),循环数据源,只能填一个 | +| `loop_mode` | 否 | 单次错误时是否继续:`end`(终止)/ `continue`(继续) | +| `max_loop_times` | 否 | 最大循环次数 | + +--- + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-add-record.md b/skills/lark-base/references/workflow-steps/trigger-add-record.md new file mode 100644 index 000000000..272e4b6c8 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/trigger-add-record.md @@ -0,0 +1,24 @@ +# AddRecordTrigger + +```json +{ + "table_name": "订单表", + "watched_field_name": "状态", + "trigger_control_list": ["pasteUpdate", "automationBatchUpdate"], + "condition_list": [] /* AndCondition 数组 */ +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 监控的数据表名 | +| `watched_field_name` | 是 | 监控的字段名 | +| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` / `openAPIBatchUpdate` | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-button.md b/skills/lark-base/references/workflow-steps/trigger-button.md new file mode 100644 index 000000000..f54f0d1a2 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/trigger-button.md @@ -0,0 +1,22 @@ +# ButtonTrigger + +```json +{ + "button_type": "buttonField", + "table_name": "审批表" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `button_type` | 是 | 按钮类型:`buttonField`(表格里的按钮,可操作当前记录数据)/ `buttonElement`(仪表盘、应用页面上的按钮,可执行整体操作) | +| `table_name` | 否 | 绑定的数据表名,仅 `button_type=buttonField` 时填写 | + +> `buttonField` 和 `buttonElement` 的输出能力不同,详见下方「ButtonTrigger(按钮触发器)」输出说明。 + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-change-record.md b/skills/lark-base/references/workflow-steps/trigger-change-record.md new file mode 100644 index 000000000..d96c2dcc2 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/trigger-change-record.md @@ -0,0 +1,22 @@ +# ChangeRecordTrigger + +```json +{ + "table_name": "任务表", + "trigger_control_list": [], + "condition": null +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 监控的数据表名 | +| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-lark-message.md b/skills/lark-base/references/workflow-steps/trigger-lark-message.md new file mode 100644 index 000000000..30d7a906e --- /dev/null +++ b/skills/lark-base/references/workflow-steps/trigger-lark-message.md @@ -0,0 +1,40 @@ +# LarkMessageTrigger + +```json +{ + "receive_scene": "group", + "receiver": [{ "value_type": "group", "value": {"id": "oc_xxxx", "name": "测试群"} }], + "scope": "all", + "filter": { + "conjunction": "and", + "content_contains": ["关键词"], + "sender_contains": [{ "value_type": "user", "value": {"id": "ou_xxxx", "name": ""} }], + "is_new_message": true, + "is_message_contain_attachment": false + } +} +``` + +| 字段 | 必填 | 说明| +|------|------|---| +| `receive_scene` | 是 | 接收场景:`group`(群聊)/ `chat`(单聊)| +| `receiver` | 是 | 触发来源,支持 `user` / `group` / `ref`。在单聊场景下,该字段指“可以和机器人单聊的用户”;在群聊场景下,该字段指“接收信息的群组”| +| `scope` | 是 | 触发范围:`at`(@提及)/ `all`(所有消息)。该参数仅在群聊场景有效,单聊场景请勿指定该参数| +| `filter` | 是 | MessageFilter 消息过滤条件| + +`MessageFilter`: + +| 字段 | 类型 | 说明 | +|------|------|----| +| `conjunction` | string | `and` 满足所有条件 / `or` 任一条件| +| `content_contains` | string[] | 关键词列表| +| `sender_contains` | ValueInfo[] | 筛选发送人(仅群聊+群组来源时生效,单聊场景请勿指定该参数)| +| `is_new_message` | boolean | 仅新话题消息(仅群聊时有效,单聊场景请勿指定该参数)| +| `is_message_contain_attachment` | boolean | 是否仅附件消息触发| + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-reminder.md b/skills/lark-base/references/workflow-steps/trigger-reminder.md new file mode 100644 index 000000000..96fa4324e --- /dev/null +++ b/skills/lark-base/references/workflow-steps/trigger-reminder.md @@ -0,0 +1,30 @@ +# ReminderTrigger + +```json +{ + "table_name": "项目表", + "field_name": "截止日期", + "offset": 1, + "unit": "DAY", + "hour": 9, + "minute": 0, + "condition_list": null +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 数据表名 | +| `field_name` | 是 | 日期字段名(必须为 `datetime` / `created_at` / `formula` / `lookup` 类型) | +| `unit` | 是 | 偏移单位:`MINUTE` / `HOUR` / `DAY` / `WEEK` / `MONTH` | +| `offset` | 是 | 提前/延后的偏移量(正数=提前,负数=延后;范围由 `unit` 决定):`MINUTE` ∈ {0, 5, 15, 30, -5, -15, -30};`HOUR` ∈ [-6, -1] ∪ [1, 6];`DAY` ∈ [-7, 7];`WEEK` ∈ [-7, -1] ∪ [1, 7];`MONTH` ∈ [-7, -1] ∪ [1, 7] | +| `hour` | 是 | 触发小时 (0-23),默认 9 | +| `minute` | 是 | 触发分钟 (0-59),默认 0 | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-set-record.md b/skills/lark-base/references/workflow-steps/trigger-set-record.md new file mode 100644 index 000000000..6b0bc3bf9 --- /dev/null +++ b/skills/lark-base/references/workflow-steps/trigger-set-record.md @@ -0,0 +1,38 @@ +# SetRecordTrigger + +```json +{ + "table_name": "订单表", + "record_watch_conjunction": "and", + "record_watch_info": [ /* FieldCondition[] */ ], + "field_watch_info": [ + { "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "已发货" }] } + ], + "trigger_control_list": [], + "condition_list": null +} +``` + +| 字段 | 必填 | 说明 | +|------|----|------| +| `table_name` | 是 | 监控的数据表名 | +| `record_watch_conjunction` | 否 | 记录筛选组合方式:`and` / `or`,默认 `and` | +| `record_watch_info` | 否 | 记录级过滤条件(修改前值匹配),为空则监听全部 | +| `field_watch_info` | 是 | 字段级监控条件列表,至少一个 | +| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | + +`FieldWatchItem`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `field_name` | string | 监听字段名称 | +| `operator` | string | 操作符(仅明确要求字段满足条件时填) | +| `value` | ValueInfo[] | 触发值 | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-timer.md b/skills/lark-base/references/workflow-steps/trigger-timer.md new file mode 100644 index 000000000..b7f5f272e --- /dev/null +++ b/skills/lark-base/references/workflow-steps/trigger-timer.md @@ -0,0 +1,27 @@ +# TimerTrigger + +```json +{ + "rule": "WEEKLY", + "start_time": "2025-01-01 09:00", + "sub_unit": [1, 3, 5], + "is_never_end": true +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `rule` | 是 | `NO_REPEAT` / `DAILY` / `WEEKLY` / `MONTHLY` / `YEARLY` / `WORKDAY` / `CUSTOM` | +| `start_time` | 否 | 开始时间,格式 `yyyy-MM-dd HH:mm` | +| `interval` | 否 | 自定义间隔 [1,30](仅 CUSTOM) | +| `unit` | 否 | 自定义单位:`SECOND` / `MINUTE` / `HOUR` / `DAY` / `WEEK` / `MONTH` / `YEAR` | +| `sub_unit` | 否 | 子单位(`WEEKLY` 时为星期几数组 0-6,`MONTHLY` 时为几号数组 1-31) | +| `end_time` | 否 | 结束时间 | +| `is_never_end` | 否 | 是否永不结束 | + +--- + +## 相关 + +- 返回 [Workflow schema index](../lark-base-workflow-schema.md) +- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md) From cebb313b3fc9653a786799ea16401828af021300 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Thu, 11 Jun 2026 15:23:27 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20workflow=20?= =?UTF-8?q?=E8=8D=89=E5=9B=BE=E5=BC=95=E5=AF=BC=EF=BC=8C=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E8=AF=BB=E5=8F=96=E6=89=80=E6=9C=89=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E7=9A=84=20step=20=E5=AF=B9=E5=BA=94=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ia577b7c10b945b06e741bb5463c23f4fe67838d0 --- skills/lark-base/SKILL.md | 8 +- .../references/lark-base-workflow-guide.md | 163 ++++++++++++++---- .../references/lark-base-workflow-schema.md | 11 +- 3 files changed, 136 insertions(+), 46 deletions(-) diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index a0b32ac66..334b1f57d 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -67,10 +67,10 @@ metadata: |---|---|---| | 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token | | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | -| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系, 适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令。 fewshot 看 `--help` | +| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系,适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令 | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | -| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段;需要多表结构时先 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,避免逐表多次调用 | +| 列/查/删字段 | `+field-list/get/delete/search-options` | 字段发现默认用 `+field-list --compact`;只有需要 formula/lookup 细节或完整字段 JSON 时再用 `+field-get` / 不带 compact 的 list;需要多表结构时先 `+table-list` 拿表,再用 `+field-list-batch --compact --table-id <表1> --table-id <表2>` 一次取多表字段 | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | @@ -84,7 +84,7 @@ metadata: | 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) | | 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 | | 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` | -| Workflow | `+workflow-*` | 创建/更新或理解 steps 时先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),再读 schema 路由 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);只打开涉及的 `workflow-steps/*.md` 小文件,公共 `value/ref/condition` 才读 [common-types-and-refs.md](references/workflow-steps/common-types-and-refs.md);list/get/enable/disable 只处理 workflow ID 与启停状态 | +| Workflow | `+workflow-*` | 先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md):它包含查询/启停/创建/修改的最短路径和常见 step 组合;只有创建/更新复杂 steps 时才继续读 schema 小文件;list/get/enable/disable 不读 schema | | 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 | ## 注意事项 @@ -153,7 +153,7 @@ done ### Dashboard / Workflow / Role - Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),再读 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) 的 step 路由表;只打开涉及的 `workflow-steps/*.md`,需要 `ValueInfo/ref/Condition` 时再读 [common-types-and-refs.md](references/workflow-steps/common-types-and-refs.md)。enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 +- Workflow 的复杂点是 `steps` 结构。先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),用其中的最短路径和场景表完成查询/启停/常见创建修改;只有需要具体 step 字段时,再按需读 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) 和对应 `workflow-steps/*.md`。enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 - Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 ## Token 与链接 diff --git a/skills/lark-base/references/lark-base-workflow-guide.md b/skills/lark-base/references/lark-base-workflow-guide.md index 8be33b18f..b96c574d9 100644 --- a/skills/lark-base/references/lark-base-workflow-guide.md +++ b/skills/lark-base/references/lark-base-workflow-guide.md @@ -1,67 +1,156 @@ # Workflow guide -本文档只做 Workflow 创建/更新入口路由,避免默认读入完整 steps schema。查询、启停 workflow 不需要读本文档。 +本文档是 Workflow 的操作地图:先用它决定最短路径,再按需打开 schema 小文件。Guide 要一次读完后能完成大多数查询、启停和常见创建/修改;schema 才是零件手册。 -## 什么时候读 +## 先判断任务类型 -| 目标 | 处理方式 | -|---|---| -| 列出或查看 workflow | 先看 `lark-cli base +workflow-list --help` / `+workflow-get --help`,按返回摘要回答 | -| 启用或停用 workflow | 先确认 workflow ID 和当前状态,再用 `+workflow-enable` / `+workflow-disable` | -| 创建或更新简单 workflow | 读本文件,再按 step type 打开 schema 小文件 | -| 复用或解释复杂 `steps` | 读 [lark-base-workflow-schema.md](lark-base-workflow-schema.md) 的路由表,只打开涉及的 step 文件 | - -## 创建/更新最小流程 - -1. 调用命令前先看 `--help`,不要猜参数名或 JSON 结构。 -2. 先确认真实 Base、表、字段、视图和用户/群 ID;不要凭口述猜字段名或 field ID。 -3. 选择一个 trigger;新增记录用 `AddRecordTrigger`,只监听修改用 `SetRecordTrigger`,新增或修改都触发/拿不准用 `ChangeRecordTrigger`。 -4. 选择 action/branch/system step,打开对应 schema 文件。 -5. 需要 `value_type`、`ref`、条件、字段值或输出引用时,再读 [common-types-and-refs.md](workflow-steps/common-types-and-refs.md)。 -6. 组装 `title/status/steps` 后用 `+workflow-create` 或 `+workflow-update`。 +| 目标 | 最短路径 | 是否读 schema | +|---|---|---| +| 列出 workflow | `+workflow-list --base-token `;需要筛选启停状态时用 `--status` | 不读 | +| 查看一个 workflow | 先 `+workflow-list` 后按标题本地匹配 `workflow_id`,再 `+workflow-get --workflow-id ` | 不读,除非要解释完整 `steps` | +| 启用/停用 workflow | `+workflow-list --status ` 定位,再 `+workflow-enable/disable` | 不读 | +| 创建简单 workflow | 读本 guide,按下方场景表打开必要 step schema | 只读命中的 step | +| 修改 workflow | `+workflow-get` 取现状,保留无关字段,只改目标 step;复杂 step 再读 schema | 只读被改的 step | +| 解释复杂 `steps` | 先用本 guide 的结构速记理解连线,再按 step type 打开 schema | 按需读 | -## Step 文件路由 +不要默认看 `--help`。只有命令报错、参数名不确定、或要确认复杂写入参数时,才看当前命令的 help。 -入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md) +## 资源发现顺序 -| 场景 | 常见步骤 | 只读这些文件 | -|---|---|---| -| 新增记录后通知 | `AddRecordTrigger -> LarkMessageAction` | [trigger-add-record.md](workflow-steps/trigger-add-record.md), [action-lark-message.md](workflow-steps/action-lark-message.md), common refs | -| 定时查找并循环处理 | `TimerTrigger -> FindRecordAction -> Loop -> ...` | [trigger-timer.md](workflow-steps/trigger-timer.md), [action-find-record.md](workflow-steps/action-find-record.md), [system-loop.md](workflow-steps/system-loop.md), common refs | -| 条件分支 | `... -> IfElseBranch -> ...` | [branch-if-else.md](workflow-steps/branch-if-else.md), common conditions | -| 多路分类 | `... -> SwitchBranch -> ...` | [branch-switch.md](workflow-steps/branch-switch.md), common conditions | -| 按钮触发外部系统 | `ButtonTrigger -> HTTPClientAction -> AddRecordAction` | [trigger-button.md](workflow-steps/trigger-button.md), [action-http-client.md](workflow-steps/action-http-client.md), [action-add-record.md](workflow-steps/action-add-record.md), common refs | -| AI 生成内容 | `... -> GenerateAiTextAction` | [action-generate-ai-text.md](workflow-steps/action-generate-ai-text.md), common refs | +1. 从用户链接提取 `base_token`。 +2. 需要知道文档内资源时用 `+base-block-list` 或 `+table-list`;不要两者都跑,除非一个结果不够。 +3. 字段发现默认用 `+field-list --compact`;只有需要公式、lookup 或完整字段配置时再 `+field-get`。 +4. 多表字段发现用 `+field-list-batch --compact --table-id --table-id `。 +5. workflow 定位用 `+workflow-list` 读取列表,再按 `title` 本地匹配;当前命令没有 `--title` flag。 -## 结构速记 +## Workflow 结构速记 ```json { + "client_token": "unique-create-token", "title": "工作流标题", - "status": "enable", "steps": [ - {"id":"step_trigger","type":"AddRecordTrigger","title":"触发器","next":"step_action","data":{}}, - {"id":"step_action","type":"LarkMessageAction","title":"动作","next":null,"data":{}} + { + "id": "step_trigger", + "type": "AddRecordTrigger", + "title": "触发器", + "next": "step_action", + "data": {} + }, + { + "id": "step_action", + "type": "LarkMessageAction", + "title": "动作", + "next": null, + "data": {} + } ] } ``` -- 普通 trigger/action 用 `next` 串联。 +- `id` 要稳定、可读,被 `next` 和 `children.links[].to` 引用。 +- 普通 trigger/action 用 `next` 串联;最后一个节点 `next:null`。 - `IfElseBranch` / `SwitchBranch` / `Loop` 用 `children.links` 表达分支或循环入口。 -- `ref` 路径用前置 step 的 `id`,字段下钻通常是 `$.{stepId}.{fieldId}` 或 `$.{loopStepId}.item.{fieldId}`。 - Action 节点不要设置 `children`。 +- `ref` 引用前置 step 的输出,字段下钻通常是 `$.{stepId}.{fieldId}`;循环内当前项常用 `$.{loopStepId}.item.{fieldId}`。 +- `+workflow-create` 需要唯一 `client_token`;新 workflow 创建后默认 disabled,用户需要启用时再调用 `+workflow-enable`。 +- `+workflow-update` 是完整替换;从 `+workflow-get` 返回中保留不想改的 `title/status/steps`。 + +## Step 选型 + +创建/修改前先产出一个草图:列出全部节点 `id/type/next/children`,把会用到的 `type` 去重后,再一次性读取对应的 step md 文档。不要“读一个 step、想一轮、再读下一个 step”;这会增加轮次和上下文重放。 + +| 用户说法 | 选型 | +|---|---| +| 新增记录时 | `AddRecordTrigger` | +| 记录被修改时 | `SetRecordTrigger` | +| 新增或修改都触发、或拿不准 | `ChangeRecordTrigger` | +| 每天/每周/每月/固定时间 | `TimerTrigger` | +| 日期字段到期提醒 | `ReminderTrigger` | +| 点击按钮 | `ButtonTrigger` | +| 收到群消息/私聊消息 | `LarkMessageTrigger` | +| 新增一条记录 | `AddRecordAction` | +| 更新当前或查找到的记录 | `SetRecordAction` | +| 查找多条记录再处理 | `FindRecordAction`,多条时接 `Loop` | +| 分两路判断 | `IfElseBranch` | +| 多档位/多类别判断 | `SwitchBranch` | +| 发送飞书消息 | `LarkMessageAction` | +| 调外部接口 | `HTTPClientAction` | +| 等待一段时间 | `Delay` | +| AI 生成文本 | `GenerateAiTextAction` | + +## 常见场景 + +| 场景 | 推荐步骤 | 需要读的 schema | +|---|---|---| +| 新增记录后发通知 | `AddRecordTrigger -> LarkMessageAction` | `trigger-add-record.md`, `action-lark-message.md` | +| 记录变化后更新同一行字段 | `ChangeRecordTrigger -> SetRecordAction` | `trigger-change-record.md`, `action-set-record.md`; 条件复杂再读 common refs | +| 金额/状态分档处理 | `AddRecordTrigger -> SwitchBranch -> SetRecordAction...` | `trigger-add-record.md`, `branch-switch.md`, `action-set-record.md`, common conditions | +| 二选一判断 | `... -> IfElseBranch -> ...` | `branch-if-else.md`, common conditions | +| 定时汇总并逐人通知 | `TimerTrigger -> FindRecordAction -> Loop -> LarkMessageAction` | `trigger-timer.md`, `action-find-record.md`, `system-loop.md`, `action-lark-message.md`, common refs | +| 群消息触发后回复 | `LarkMessageTrigger -> FindRecordAction/Loop -> LarkMessageAction` | `trigger-lark-message.md`, `action-find-record.md`, `system-loop.md`, `action-lark-message.md` | +| 按钮触发外部系统 | `ButtonTrigger -> HTTPClientAction -> AddRecordAction` | `trigger-button.md`, `action-http-client.md`, `action-add-record.md` | +| 调用 AI 生成内容并写回 | `... -> GenerateAiTextAction -> SetRecordAction` | `action-generate-ai-text.md`, `action-set-record.md`, common refs | + +Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。不要一次性打开所有 step 文件;先确定本次 workflow 的完整 step type 集合,再一次性打开这些文件。只有确定会写 `ref`、条件、字段值或节点输出引用时,才把 `common-types-and-refs.md` 加入同一批读取。 + +## 最小例子:新增记录后发送消息 + +只读 `trigger-add-record.md` 和 `action-lark-message.md` 即可。 + +```json +{ + "client_token": "wf-unique-token", + "title": "新订单通知", + "steps": [ + { + "id": "trig_new_order", + "type": "AddRecordTrigger", + "title": "新增订单时", + "next": "act_notify", + "data": { + "table_name": "订单表", + "watched_field_name": "订单号" + } + }, + { + "id": "act_notify", + "type": "LarkMessageAction", + "title": "发送通知", + "next": null, + "data": { + "receiver": [{ "value_type": "user", "value": { "id": "ou_xxx" } }], + "send_to_everyone": false, + "title": [{ "value_type": "text", "value": "新订单提醒" }], + "content": [{ "value_type": "text", "value": "收到新订单" }], + "btn_list": [] + } + } + ] +} +``` + +## 修改现有 workflow + +1. `+workflow-list` 后按标题定位 `workflow_id`。 +2. `+workflow-get --workflow-id ` 获取完整定义。 +3. 只修改目标 step,保留其他 steps 的 `id/type/title/data/next/children`。 +4. 用 `+workflow-update` 提交完整定义。 +5. 若只启停,不走 update,直接 `+workflow-enable/disable`。 ## 常见错误 | 错误 | 处理 | |---|---| -| 把字段名当 field ID 写入 ref | 先读真实字段结构;ref 下钻通常使用 field ID | +| 查询/启停也读 schema | 停下,直接用 `+workflow-list/get/enable/disable` | +| 为多个可能命令批量看 help | 只看当前报错或即将执行的一个命令 | +| 把字段名当 field ID 写入 ref | 先 `+field-list --compact`,ref 下钻优先用 field ID | | 分支/循环没有 `children.links` | 按 branch/loop schema 补 `if_true/if_false/case/loop_start` | | SetRecordAction/FindRecordAction 缺定位条件 | 提供 `filter_info` 或 `ref_info` | -| HTTPClientAction 后续节点引用不到字段 | `response_type=json` 时填写 `response_value` 声明输出字段 | +| HTTPClientAction 后续节点引用不到字段 | `response_type: "json"` 时填写 `response_value` 声明输出字段 | | Loop 内引用错路径 | 用 `$.{loopStepId}.item.{fieldId}` 和 `$.{loopStepId}.index` | ## 参考 -- [lark-base-workflow-schema.md](lark-base-workflow-schema.md):steps 基础结构和按需路由 -- [workflow-steps/common-types-and-refs.md](workflow-steps/common-types-and-refs.md):ValueInfo、ref、Condition、节点输出 +- [lark-base-workflow-schema.md](lark-base-workflow-schema.md):step type 路由和基础结构。 +- [workflow-steps/common-types-and-refs.md](workflow-steps/common-types-and-refs.md):ValueInfo、ref、Condition、节点输出;只有构造这些细节时才读。 diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index e36abdde8..64eae3138 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -1,12 +1,13 @@ # Workflow steps JSON SSOT -本文档是 Workflow steps 的按需读取入口。不要整篇读旧 schema;先确认要处理的 step type,再打开对应小文件。 +本文档是 Workflow steps 的按需读取入口。先读 [lark-base-workflow-guide.md](lark-base-workflow-guide.md) 确定任务路径;只有需要具体 step 字段时,再按 type 打开对应小文件。 ## 读取顺序 -1. 查询、启停 workflow:只用 `+workflow-list/get/enable/disable --help` 和命令返回,不读本目录。 -2. 创建或更新 workflow:先读本文件的基础结构和 step 路由表。 -3. 只打开会用到的 step type 文件;需要 value/ref/filter 条件时再读 [common-types-and-refs.md](workflow-steps/common-types-and-refs.md)。 +1. 查询、启停 workflow:只用 `+workflow-list/get/enable/disable` 和命令返回,不读本目录,也不要默认看 help。 +2. 创建或更新 workflow:先读 guide;如果 guide 的场景表不足以构造 step,再读本文件的基础结构和 step 路由表。 +3. 先确定本次 workflow 会用到的完整 step type 集合,去重后一次性打开对应 step md 文件;不要每确定一个节点就读一次文件。 +4. 需要 value/ref/filter 条件时,把 [common-types-and-refs.md](workflow-steps/common-types-and-refs.md) 加入同一批读取;不需要这些结构时不要读。 ## WorkflowStep 基础结构 @@ -94,4 +95,4 @@ ## 参考 - Workflow 创建/更新入口路由:[lark-base-workflow-guide.md](lark-base-workflow-guide.md) -- 命令参数以 `lark-cli base +workflow-create --help` / `+workflow-update --help` 为准。 +- 命令参数以 `lark-cli base +workflow-create --help` / `+workflow-update --help` 为准;只有参数不确定或命令报错时才读取 help。 From dc894e4ae3db74dd794d7a33b7f7d783cbccab8c Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Thu, 11 Jun 2026 17:59:16 +0800 Subject: [PATCH 07/17] fix: address r3 eval feedback on token waste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slim lark-base SKILL.md 6105->4719 tokens: drop 保留Reference chapter, condense caveat sections, sink write rules into cell-value.md; make help-first rule conditional for trivial commands - split formula-field-guide.md (9744->7243 tokens): move 37 rare functions to formula-functions-extended.md, move examples/requirement-translation to formula-examples.md; compress table padding - suggest: add containment signal so namespace-dropping guesses (+block-list -> +base-block-list) get did-you-mean hints - workflow docs: state that "add OR modify with same condition" is a single ChangeRecordTrigger workflow at guide/schema/leaf levels - batch-execution note: scripts should print counts/ids/failures only, not full payloads --- internal/suggest/suggest.go | 41 +- skills/lark-base/SKILL.md | 70 +-- .../lark-base/references/formula-examples.md | 115 +++++ .../references/formula-field-guide.md | 412 ++++++------------ .../references/formula-functions-extended.md | 66 +++ .../references/lark-base-cell-value.md | 3 + .../references/lark-base-workflow-guide.md | 2 +- .../references/lark-base-workflow-schema.md | 2 +- .../workflow-steps/trigger-change-record.md | 2 + 9 files changed, 375 insertions(+), 338 deletions(-) create mode 100644 skills/lark-base/references/formula-examples.md create mode 100644 skills/lark-base/references/formula-functions-extended.md diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index fe4471f6b..8c7f5f943 100644 --- a/internal/suggest/suggest.go +++ b/internal/suggest/suggest.go @@ -7,7 +7,10 @@ // carrying their own copy. package suggest -import "sort" +import ( + "sort" + "strings" +) // Levenshtein computes the classic edit distance between two strings. It is // rune-aware, so it is correct for multi-byte input. @@ -51,22 +54,29 @@ func Levenshtein(a, b string) int { // signal of intent that raw edit distance misses. func Closest(typed string, candidates []string, maxN int) []string { type scored struct { - name string - prefix int - dist int + name string + contain bool + prefix int + dist int } limit := editLimit(typed) ranked := make([]scored, 0, len(candidates)) for _, c := range candidates { p := sharedPrefixLen(typed, c) d := Levenshtein(typed, c) - // Keep only plausible matches: a meaningful shared prefix, or an edit - // distance within budget. Drop everything else so the hint stays short. - if p >= 3 || d <= limit { - ranked = append(ranked, scored{name: c, prefix: p, dist: d}) + ct := containsSegment(typed, c) + // Keep only plausible matches: a meaningful shared prefix, an edit + // distance within budget, or one name containing the other (a missing + // namespace prefix like "+block-list" vs "+base-block-list"). Drop + // everything else so the hint stays short. + if p >= 3 || d <= limit || ct { + ranked = append(ranked, scored{name: c, contain: ct, prefix: p, dist: d}) } } sort.Slice(ranked, func(i, j int) bool { + if ranked[i].contain != ranked[j].contain { + return ranked[i].contain + } if ranked[i].prefix != ranked[j].prefix { return ranked[i].prefix > ranked[j].prefix } @@ -94,6 +104,21 @@ func editLimit(s string) int { return 2 } +// containsSegment reports whether one name contains the other as a substring +// after stripping the "+"/"--" sigils. It catches hallucinated names that drop +// a namespace prefix (e.g. "+block-list" for "+base-block-list"), which share +// almost no prefix and sit far beyond the edit-distance budget. The shorter +// side must be at least 5 runes so generic fragments like "list" do not match +// half the catalog. +func containsSegment(a, b string) bool { + a = strings.TrimLeft(a, "+-") + b = strings.TrimLeft(b, "+-") + if len([]rune(a)) > len([]rune(b)) { + a, b = b, a + } + return len([]rune(a)) >= 5 && strings.Contains(b, a) +} + func sharedPrefixLen(a, b string) int { ra, rb := []rune(a), []rune(b) n := 0 diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index a0b32ac66..b731528ff 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -67,10 +67,10 @@ metadata: |---|---|---| | 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token | | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | -| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系, 适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令。 fewshot 看 `--help` | +| 查看 Base 内资源目录 | `+base-block-list` | 先判断 Base 里有什么(table/docx/dashboard/workflow/folder),再决定走哪类命令;fewshot 看 `--help` | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | -| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段;需要多表结构时先 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,避免逐表多次调用 | +| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入/删除前用 list/get 确认字段类型、选项、ID;多表结构用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取齐,不要逐表调用 | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | @@ -85,13 +85,14 @@ metadata: | 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 | | 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` | | Workflow | `+workflow-*` | 创建/更新或理解 steps 时先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),再读 schema 路由 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);只打开涉及的 `workflow-steps/*.md` 小文件,公共 `value/ref/condition` 才读 [common-types-and-refs.md](references/workflow-steps/common-types-and-refs.md);list/get/enable/disable 只处理 workflow ID 与启停状态 | -| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 | +| 高级权限与角色 | `+advperm-*` / `+role-*` | 先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md)(含安全边界);权限 JSON 再读 [role-config.md](references/role-config.md) | ## 注意事项 ### Help 先行 -- 执行不熟悉的命令前先看 `--help`,不要猜参数名或 JSON 结构;本轮任务会用到多个命令时,把它们的 `--help` 合并在一条 Bash 命令里一次看完,不要一轮对话只看一个 help: +- 参数不确定、要构造复杂 JSON、或命令带批量/隐藏选项时,先看 `--help`,不要猜参数名或 JSON 结构;`+table-list` / `+base-create` 这类参数显而易见的简单命令直接执行,报参数错误再查 help,不要为它单花一轮。 +- 需要看多个命令的 `--help` 时,合并在一条 Bash 命令里一次看完,不要一轮对话只看一个: ```bash lark-cli base +table-list --help; lark-cli base +field-list --help; lark-cli base +field-update --help @@ -102,6 +103,7 @@ lark-cli base +table-list --help; lark-cli base +field-list --help; lark-cli bas - 优先用原生批量能力:多表字段 `+field-list-batch`;批量写记录 `+record-batch-create` / `+record-batch-update`;部分命令参数本身支持多值(如 `+record-delete --record-id` 可重复传、`+record-share-link-create --record-ids`),先看 `--help`。 - 没有原生批量命令时,对多个对象做同类操作要在**一条 Bash 命令**里用 shell 循环完成,不要一轮对话只执行一个命令、看完结果再发下一个。 - 循环内先 `echo` 对象标识再执行,失败可定位到具体对象;写同一张表保持串行;只读命令可用 `--jq` 收窄输出,避免无关字段灌入上下文。 +- 脚本输出只打印计数、ID 和失败项,不要回显完整 payload 或原始返回。 示例——一次取多个视图的配置: @@ -120,41 +122,27 @@ done - `91403` 或明确不可访问错误不要循环换身份重试。 - `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。 -### 查询与统计规则 +### 查询与统计 -涉及查询、统计或判断结论时,先阅读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守: +- 涉及筛选、排序、Top/Bottom N、聚合、分组、多表关联或任何全局结论时,先读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 并按其 Hard Rules 执行。 +- 两条红线随时生效:能由 Base 云端表达的筛选/排序/聚合不要拉原始记录到本地手工处理;`has_more=true` 等分页信号未消除前,不能基于当前页下全局结论。 -1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。 -2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉原始记录到本地上下文再手工筛选排序。 -3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。 -4. 多表查询必须先确认关系字段和连接键;link 单元格里的 `record_id` 是关系键,不是用户可读答案。 -5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。 -6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。 -7. `+data-query` 可返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。 +### 写入前置 -### 写入前置规则 +- 写记录/字段前先读真实结构;表名、字段名、视图名必须来自真实返回,跨表场景还要读目标表结构。 +- 复杂 JSON 按快速路由读对应 reference:字段读 [lark-base-field-json.md](references/lark-base-field-json.md),记录读 [lark-base-cell-value.md](references/lark-base-cell-value.md)(写入红线:只写存储字段、批量上限、并发冲突等,见其顶层规则)。 +- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确先用 get/list 消歧;workflow/role 等复杂写操作创建后用 get 回读确认,必要时先 `--dry-run` 预演。 -- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。 -- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 -- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。 -- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。 -- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。 -- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。 -- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。 -- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。 +### 表单与视图 -### 表单与视图细节 - -- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。 -- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。 -- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。 -- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。 +- `+form-submit` 前必须先 `+form-detail`;提交规则(filter 隐藏题不填、附件写在 `attachments` 并带 `--base-token`)见 [lark-base-form-submit.md](references/lark-base-form-submit.md)。 +- 视图配置先用对应 get 命令读现状,只替换要变更的部分;一次性筛选/排序先用 `+record-list` / `+record-search` 验证,再按需沉淀为持久视图。 ### Dashboard / Workflow / Role -- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),再读 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) 的 step 路由表;只打开涉及的 `workflow-steps/*.md`,需要 `ValueInfo/ref/Condition` 时再读 [common-types-and-refs.md](references/workflow-steps/common-types-and-refs.md)。enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 -- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 +- Dashboard 的复杂点是 block 的 `data_config`:创建/更新 block 前读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件串行创建;布局/换图表类型/删除具名图表等操作要点见 [lark-base-dashboard.md](references/lark-base-dashboard.md) 的「执行要点」。`+dashboard-block-get-data` 只返回图表数据,元数据用 `+dashboard-block-get`。 +- Workflow 的复杂点是 `steps`:按快速路由 Workflow 行的阅读链取文档;创建后 `+workflow-get` 回读验证。 +- Role 的复杂点是权限 JSON:先读 [lark-base-role-guide.md](references/lark-base-role-guide.md)(含安全边界),权限 JSON SSOT 读 [role-config.md](references/role-config.md);删除角色、关闭高级权限前确认目标和影响。 ## Token 与链接 @@ -165,10 +153,7 @@ done | `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id`;`blk` 开头是 dashboard ID;`wkf` 开头是 workflow ID | | `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 | | `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token,走 `+form-detail` / `+form-submit --share-token ` | -| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 | -| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 | -| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token --table-id --record-ids ` | -| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 | +| `/share/base/view/...` / `/share/base/dashboard/...` / `/record/...` / `/base/workspace/...` | 分享链接与 workspace 链接,暂不支持用 CLI 直接访问,引导用户在飞书客户端打开;要生成记录分享链接用 `+record-share-link-create` | `wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。 @@ -186,18 +171,3 @@ done | `1254104` | 批量超过 200,分批调用 | | `1254291` | 并发写冲突,串行写入并在批次间短暂等待 | | `91403` | 无权限访问该 Base,按 `lark-shared` 权限流程处理,不要盲目重试 | - -## 保留 Reference - -- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP -- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT -- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造 -- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造 -- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段 -- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充 -- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释 -- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON -- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON -- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md) / [lark-base-dashboard-usecase.md](references/lark-base-dashboard-usecase.md):仪表盘、组件配置、图表结果协议与完整用例 -- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) / [workflow-steps/](references/workflow-steps/):workflow 入口、steps 路由和按 step type 拆分的 schema 小文件 -- [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT diff --git a/skills/lark-base/references/formula-examples.md b/skills/lark-base/references/formula-examples.md new file mode 100644 index 000000000..508b799a6 --- /dev/null +++ b/skills/lark-base/references/formula-examples.md @@ -0,0 +1,115 @@ +# Base Formula Examples and Requirement Translation + +> 本文件是 [formula-field-guide.md](formula-field-guide.md) 的按需补充:完整示例与"自然语言需求 → 公式"的翻译规则。 + +## Section 13: Complete Examples + +### Example 1: Employee sales summary + +**Table structure** (from `+table-get`): + +- Employees: EmployeeID (Text), Name (Text), Department (Text) +- Sales: ContractID (Number), SalespersonID (Text), Quantity (Number), Total (Number) + +**Current table**: Employees + +**Requirement**: For each employee, output "Sold XX orders" if they have sales records, otherwise "No sales records". + +**Formula**: + +``` +IF( + [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1, + "Sold " & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & " orders", + "No sales records" +) +``` + +**Field JSON**: + +```json +{ + "type": "formula", + "name": "Sales Summary", + "expression": "IF([Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1, \"Sold \" & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & \" orders\", \"No sales records\")" +} +``` + +**Explanation**: `[Sales].COUNTIF(...)` uses the entire Sales table as data range. CurrentValue represents each row in Sales, accessing `CurrentValue.[SalespersonID]` for that row's salesperson. `[EmployeeID]` refers to the current row in the Employees table (where the formula lives). + +### Example 2: Chained cross-table access via link fields + +**Table structure**: + +- Orders: ID (`auto_number`), OrderItems (`link` [target: OrderItems, foreign key: ID]) +- OrderItems: ID (`auto_number`), Product (`link` [target: Products, foreign key: ID]) +- Products: ID (`auto_number`), ProductName (`text`) + +**Current table**: Orders + +**Requirement**: Deduplicate and comma-join all product names from linked order items. + +**Formula**: + +``` +[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(",") +``` + +**Field JSON**: + +```json +{ + "type": "formula", + "name": "Product List", + "expression": "[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(\",\")" +} +``` + +**Explanation**: `[OrderItems]` gets linked order item records, `.[Product]` expands to each item's linked product, `.[ProductName]` gets all product names, `.UNIQUE()` deduplicates, `.ARRAYJOIN(",")` joins with commas. + +### Example 3: Cross-table filter + sort + +**Table structure**: + +- Projects: ProjectName (Text), Status (Text), Owner (Text) +- Tasks: TaskName (Text), Project (Text), Priority (Number), DueDate (Date) + +**Current table**: Projects + +**Requirement**: Find the highest-priority (lowest number) task name for the current project. + +**Formula**: + +``` +FIRST( + [Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName] +) +``` + +**Field JSON**: + +```json +{ + "type": "formula", + "name": "Top Priority Task", + "expression": "FIRST([Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName])" +} +``` + +**Explanation**: `[Tasks].FILTER(CurrentValue.[Project] = [ProjectName])` filters tasks belonging to the current project. `.SORTBY([Tasks].[Priority], TRUE)` sorts by priority ascending. `.[TaskName]` extracts task names. `FIRST(...)` gets the first one (highest priority). + +--- + +## Section 14: Translating User Requirements to Formulas + +When the user describes their formula need in natural language, follow these rules to convert it into a precise expression: + +1. **Numbers must use precise values**: "less than 80%" → field value less than `0.8`. "above 1000" → `>= 1000`. +2. **Interval boundaries**: "above/below/within" = closed (inclusive); "less than/more than/outside" = open (exclusive). +3. **Branching logic** must be organized as an ordered list with a fallback branch. Each branch has a condition and output. + - Example: "return risk level for 1-3" → `IFS([Value] = 1, "low", [Value] = 2, "medium", [Value] = 3, "high")` with an `IFERROR` or trailing empty-string fallback. +4. **Multi-level branches must be flattened** to a single level. Nested if-else chains → flat IFS. +5. **Branch conditions must be mutually exclusive**. If the user's conditions overlap, rewrite to eliminate ambiguity. +6. **Reorder branches by logical priority** if the user's order is illogical (e.g., check specific conditions before catch-all). + +--- diff --git a/skills/lark-base/references/formula-field-guide.md b/skills/lark-base/references/formula-field-guide.md index 5d9a46350..c430b964d 100644 --- a/skills/lark-base/references/formula-field-guide.md +++ b/skills/lark-base/references/formula-field-guide.md @@ -34,11 +34,11 @@ When creating a formula field, the Agent should: This is the foundation of formula logic. You must determine this before writing any formula. -| Syntax | Meaning | Return type | Example | -| --------------------- | -------------------------------------------- | ---------------------- | -------------------------------------------- | -| `[Field]` | Value of this field in the current row | Scalar (single value) | `[Name]` → `"Alice"` | +| Syntax | Meaning | Return type | Example | +|---|---|---|---| +| `[Field]` | Value of this field in the current row | Scalar (single value) | `[Name]` → `"Alice"` | | `[TableName].[Field]` | All values of this field in the target table | List (multiple values) | `[Employees].[Name]` → `["Alice","Bob",...]` | -| `[TableName]` | The target table (entire table) | Table reference | Used as data range for FILTER/COUNTIF etc. | +| `[TableName]` | The target table (entire table) | Table reference | Used as data range for FILTER/COUNTIF etc. | **Rules**: @@ -59,7 +59,7 @@ This is the foundation of formula logic. You must determine this before writing ### Field storage types | Type | Description | Supported operations | -|------|-------------|----------------------| +|---|---|---| | `number` | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation | | `text` | Stored as string | String operations; can participate in math if content is numeric, otherwise errors | | `datetime` | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output | @@ -69,13 +69,13 @@ This is the foundation of formula logic. You must determine this before writing ### Implicit type conversion -| Scenario | Conversion rule | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | -| Number + Float | → Float | -| Date + Number | → Date (adds/subtracts days). Use `+`/`-` for whole days, use `DURATION()` for hour/minute/second precision | -| Date - Date | → Duration | -| Boolean compared with Number | Boolean auto-converts to number (TRUE=1, FALSE=0) | -| `&` concatenation | Both sides auto-convert to string | +| Scenario | Conversion rule | +|---|---| +| Number + Float | → Float | +| Date + Number | → Date (adds/subtracts days). Use `+`/`-` for whole days, use `DURATION()` for hour/minute/second precision | +| Date - Date | → Duration | +| Boolean compared with Number | Boolean auto-converts to number (TRUE=1, FALSE=0) | +| `&` concatenation | Both sides auto-convert to string | ### Type consistency in comparisons @@ -97,12 +97,12 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides ### CurrentValue meaning in different contexts -| Data range type | CurrentValue represents | Access pattern | Example | -| ---------------------------- | ----------------------- | --------------------------- | --------------------------------------------------------- | -| Entire table `[TableName]` | A row in the table | `CurrentValue.[FieldName]` | `[Orders].FILTER(CurrentValue.[Amount] > 100).[Customer]` | -| Column `[TableName].[Field]` | A single field value | Use `CurrentValue` directly | `[Orders].[Amount].FILTER(CurrentValue > 100)` | +| Data range type | CurrentValue represents | Access pattern | Example | +|---|---|---|---| +| Entire table `[TableName]` | A row in the table | `CurrentValue.[FieldName]` | `[Orders].FILTER(CurrentValue.[Amount] > 100).[Customer]` | +| Column `[TableName].[Field]` | A single field value | Use `CurrentValue` directly | `[Orders].[Amount].FILTER(CurrentValue > 100)` | | `select` (`multiple=true`) field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` | -| LIST-generated list | One element | Use `CurrentValue` directly | `LIST(1,2,3).MAP(CurrentValue * 2)` | +| LIST-generated list | One element | Use `CurrentValue` directly | `LIST(1,2,3).MAP(CurrentValue * 2)` | ### Key rules @@ -113,11 +113,11 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides ### Anti-patterns -| Wrong | Reason | Correct | -| ---------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `[Table].[Col].FILTER(CurrentValue.[Col] > 0)` | Data range is a column; CurrentValue is a scalar, cannot use `.` to access fields | `[Table].[Col].FILTER(CurrentValue > 0)` | -| `[Table].FILTER(CurrentValue > 100)` | Data range is a table; CurrentValue is a row, cannot compare directly | `[Table].FILTER(CurrentValue.[Amount] > 100).[Amount]` | -| `CurrentValue + 1` (at top level) | CurrentValue can only be used inside iteration functions | Use inside MAP/FILTER etc. | +| Wrong | Reason | Correct | +|---|---|---| +| `[Table].[Col].FILTER(CurrentValue.[Col] > 0)` | Data range is a column; CurrentValue is a scalar, cannot use `.` to access fields | `[Table].[Col].FILTER(CurrentValue > 0)` | +| `[Table].FILTER(CurrentValue > 100)` | Data range is a table; CurrentValue is a row, cannot compare directly | `[Table].FILTER(CurrentValue.[Amount] > 100).[Amount]` | +| `CurrentValue + 1` (at top level) | CurrentValue can only be used inside iteration functions | Use inside MAP/FILTER etc. | --- @@ -125,12 +125,12 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides Base formulas **only allow** the following operators. `like`, `in`, `<>`, `**`, `^` etc. are prohibited. -| Category | Operators | Description | -| ------------- | -------------------------- | -------------------------------------------------------------------------- | -| Arithmetic | `+` `-` `*` `/` `%` | Add, subtract, multiply, divide, modulo (`%` is equivalent to `MOD()`) | -| Comparison | `>` `>=` `<` `<=` `=` `!=` | Greater than, greater or equal, less than, less or equal, equal, not equal | -| Logical | `&&` `\|\|` | AND, OR | -| Concatenation | `&` | Text concatenation; non-text values auto-convert to string | +| Category | Operators | Description | +|---|---|---| +| Arithmetic | `+` `-` `*` `/` `%` | Add, subtract, multiply, divide, modulo (`%` is equivalent to `MOD()`) | +| Comparison | `>` `>=` `<` `<=` `=` `!=` | Greater than, greater or equal, less than, less or equal, equal, not equal | +| Logical | `&&` `\|\|` | AND, OR | +| Concatenation | `&` | Text concatenation; non-text values auto-convert to string | **Important**: @@ -174,10 +174,10 @@ Retrieves the target field values for all linked records as a list. Supports con ### Two calling styles -| Style | Format | Description | -| ---------- | ------------------ | ----------------------------------- | -| Functional | `FUNC(arg1, arg2)` | Works for all functions | -| Chained | `arg1.FUNC(arg2)` | Moves the first argument before `.` | +| Style | Format | Description | +|---|---|---| +| Functional | `FUNC(arg1, arg2)` | Works for all functions | +| Chained | `arg1.FUNC(arg2)` | Moves the first argument before `.` | **Rules**: @@ -228,175 +228,139 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first --- -## Section 8: Complete Function Reference +## Section 8: Function Reference (common functions) + +> 本表覆盖常用函数(含评测与真实负载中 100% 出现过的函数)。三角/双曲/随机数/进制转换等罕见函数的签名在 [formula-functions-extended.md](formula-functions-extended.md),仅当用户明确要求这些函数时再读。 ### 8.1 Logic functions -| Function | Signature | Return type | Description | -| ------------- | ------------------------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------- | -| IF | `IF(condition, true_val, [false_val])` | Matches branch type | Returns true_val when TRUE, false_val otherwise; omitting false_val returns false (not null) | -| IFS | `IFS(cond1, val1, cond2, val2, ...)` | Matches branch type | Multi-condition branching; returns value for the first TRUE condition | -| SWITCH | `SWITCH(expr, match1, result1, [match2, result2, ...], [default])` | Matches branch type | Matches expression value and returns corresponding result | -| IFERROR | `IFERROR(expr, fallback)` | Matches branch type | Returns fallback when expression errors | -| IFBLANK | `IFBLANK(expr, fallback)` | Matches branch type | Returns fallback when expression is blank (blank = NULL/empty string/empty list) | -| AND | `AND(cond1, cond2, ...)` | Boolean | TRUE when all conditions are TRUE | -| OR | `OR(cond1, cond2, ...)` | Boolean | TRUE when any condition is TRUE | -| NOT | `NOT(condition)` | Boolean | Logical negation | -| ISBLANK | `ISBLANK(value)` | Boolean | Tests if blank (NULL/empty string/empty list are blank; 0 and FALSE are not) | -| ISNULL | `ISNULL(value)` | Boolean | Tests if NULL (only NULL is true; empty string is not) | -| ISERROR | `ISERROR(expr)` | Boolean | Tests if expression errors | -| ISNUMBER | `ISNUMBER(value)` | Boolean | Tests if value is a number | -| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains the value; **does NOT do text substring matching** | -| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains all specified values | -| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains only the specified values | -| TRUE | `TRUE()` | Boolean | Returns TRUE | -| FALSE | `FALSE()` | Boolean | Returns FALSE | -| RECORD_ID | `RECORD_ID()` | Text | Returns the current row's record ID | -| RANDOMBETWEEN | `RANDOMBETWEEN(min_int, max_int, [keep_updating])` | Number | Random integer in the specified range | -| RANDOMITEM | `RANDOMITEM(list, [keep_updating])` | Matches element type | Randomly picks one element from a list | +| Function | Signature | Return type | Description | +|---|---|---|---| +| IF | `IF(condition, true_val, [false_val])` | Matches branch type | Returns true_val when TRUE, false_val otherwise; omitting false_val returns false (not null) | +| IFS | `IFS(cond1, val1, cond2, val2, ...)` | Matches branch type | Multi-condition branching; returns value for the first TRUE condition | +| SWITCH | `SWITCH(expr, match1, result1, [match2, result2, ...], [default])` | Matches branch type | Matches expression value and returns corresponding result | +| IFERROR | `IFERROR(expr, fallback)` | Matches branch type | Returns fallback when expression errors | +| IFBLANK | `IFBLANK(expr, fallback)` | Matches branch type | Returns fallback when expression is blank (blank = NULL/empty string/empty list) | +| AND | `AND(cond1, cond2, ...)` | Boolean | TRUE when all conditions are TRUE | +| OR | `OR(cond1, cond2, ...)` | Boolean | TRUE when any condition is TRUE | +| NOT | `NOT(condition)` | Boolean | Logical negation | +| ISBLANK | `ISBLANK(value)` | Boolean | Tests if blank (NULL/empty string/empty list are blank; 0 and FALSE are not) | +| ISNULL | `ISNULL(value)` | Boolean | Tests if NULL (only NULL is true; empty string is not) | +| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains the value; **does NOT do text substring matching** | +| RECORD_ID | `RECORD_ID()` | Text | Returns the current row's record ID | ### 8.2 Numeric functions -| Function | Signature | Return type | Description | -| --- | --- | --- | --- | -| SUM | `SUM(val1, val2, ...)` | Number | Sum; accepts multiple values or a list | -| AVERAGE | `AVERAGE(val1, val2, ...)` | Number | Average | -| MAX | `MAX(val1, val2, ...)` | Number | Maximum | -| MIN | `MIN(val1, val2, ...)` | Number | Minimum | -| MEDIAN | `MEDIAN(val1, val2, ...)` | Number | Median | -| COUNTA | `COUNTA(val1, val2, ...)` | Number | Count of non-blank values | -| COUNTIF | `COUNTIF(data_range, condition)` | Number | Count matching items. Data range can be a **table** (CurrentValue is a row, use `CurrentValue.[Field]`) or a **column** (CurrentValue is a scalar value) | -| SUMIF | `SUMIF(data_range, condition)` | Number | Sum matching values. Data range **must be a numeric column** (e.g. `[Table].[NumField]`); CurrentValue is each value in that column (scalar), cannot use `CurrentValue.[Field]` to access other fields. For cross-field conditions, use FILTER+SUM instead | -| ROUND | `ROUND(number, digits)` | Number | Round. digits: 1=one decimal, 0=integer, -1=tens place | -| ROUNDUP | `ROUNDUP(number, digits)` | Number | Round away from zero. Same digits semantics as ROUND | -| ROUNDDOWN | `ROUNDDOWN(number, digits)` | Number | Round toward zero. Same digits semantics as ROUND | -| FLOOR | `FLOOR(number, [base])` | Number | Round down to nearest multiple of base (default 1) | -| CEILING | `CEILING(number, [base])` | Number | Round up to nearest multiple of base (default 1) | -| ABS | `ABS(number)` | Number | Absolute value | -| INT | `INT(number)` | Integer | Truncate to integer | -| MOD | `MOD(dividend, divisor)` | Number | Modulo | -| POWER | `POWER(base, exponent)` | Number | Exponentiation | -| QUOTIENT | `QUOTIENT(dividend, divisor)` | Number | Integer division | -| VALUE | `VALUE(text)` | Number | Convert text to number | -| ISODD | `ISODD(number)` | Boolean | Tests if number is odd | -| RANK | `RANK(value, search_range, [ascending])` | Number | Rank of value in range; default descending | -| SEQUENCE | `SEQUENCE(start, end, [step])` | List | Generate number sequence | -| PI | `PI()` | Number | Pi constant | -| SIN/COS/TAN/ASIN/ACOS/ATAN/ATAN2/SINH/COSH/TANH/ASINH/ACOSH/ATANH | `func(radians_or_value)` | Number | Trigonometric and hyperbolic functions; arguments in radians | +| Function | Signature | Return type | Description | +|---|---|---|---| +| SUM | `SUM(val1, val2, ...)` | Number | Sum; accepts multiple values or a list | +| AVERAGE | `AVERAGE(val1, val2, ...)` | Number | Average | +| MAX | `MAX(val1, val2, ...)` | Number | Maximum | +| MIN | `MIN(val1, val2, ...)` | Number | Minimum | +| COUNTA | `COUNTA(val1, val2, ...)` | Number | Count of non-blank values | +| COUNTIF | `COUNTIF(data_range, condition)` | Number | Count matching items. Data range can be a **table** (CurrentValue is a row, use `CurrentValue.[Field]`) or a **column** (CurrentValue is a scalar value) | +| SUMIF | `SUMIF(data_range, condition)` | Number | Sum matching values. Data range **must be a numeric column** (e.g. `[Table].[NumField]`); CurrentValue is each value in that column (scalar), cannot use `CurrentValue.[Field]` to access other fields. For cross-field conditions, use FILTER+SUM instead | +| ROUND | `ROUND(number, digits)` | Number | Round. digits: 1=one decimal, 0=integer, -1=tens place | +| ABS | `ABS(number)` | Number | Absolute value | +| INT | `INT(number)` | Integer | Truncate to integer | +| MOD | `MOD(dividend, divisor)` | Number | Modulo | +| VALUE | `VALUE(text)` | Number | Convert text to number | ### 8.3 Text functions -| Function | Signature | Return type | Description | -| --------------- | ---------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------- | -| CONCATENATE | `CONCATENATE(text1, text2, ...)` | Text | Concatenate multiple texts; supports lists as input | -| LEN | `LEN(text)` | Number | Character count | -| LEFT | `LEFT(text, [count])` | Text | Extract from left; default 1 | -| RIGHT | `RIGHT(text, [count])` | Text | Extract from right; default 1 | -| MID | `MID(text, start, count)` | Text | Extract from middle | -| FIND | `FIND(search_val, search_range, [start])` | Number | Find substring position (case-sensitive); returns -1 if not found | -| REPLACE | `REPLACE(text, start, count, new_text)` | Text | Replace by position | -| SUBSTITUTE | `SUBSTITUTE(text, old_text, new_text, [occurrence])` | Text | Replace by content; can specify which occurrence | -| UPPER | `UPPER(text)` | Text | Convert to uppercase | -| LOWER | `LOWER(text)` | Text | Convert to lowercase | -| TRIM | `TRIM(text)` | Text | Remove leading/trailing spaces | -| TEXT | `TEXT(value, format)` | Text | Format output. Date formats: `"YYYY-MM-DD"`, `"YYYY/MM/DD hh:mm:ss"`; number formats: `"00"`, `"000.00"` | -| CONTAINTEXT | `CONTAINTEXT(text, search_text)` | Boolean | Tests if text contains substring (text substring matching) | -| SPLIT | `SPLIT(text, delimiter)` | List | Split text by delimiter | -| TODATE | `TODATE(value)` | Date | Convert date string to date type | -| CHAR | `CHAR(number)` | Text | ASCII code to character | -| FORMAT | `FORMAT(template, [val1, val2, ...])` | Text | Template string formatting; use `{1}`, `{2}` as placeholders | -| HYPERLINK | `HYPERLINK(url, [display_text])` | Hyperlink | Create a hyperlink | -| ENCODEURL | `ENCODEURL(text)` | Text | URL encode | -| REGEXMATCH | `REGEXMATCH(text, regex)` | Boolean | Regex match test | -| REGEXEXTRACT | `REGEXEXTRACT(text, regex)` | List | Extract first match's capture groups | -| REGEXEXTRACTALL | `REGEXEXTRACTALL(text, regex)` | 2D List | Extract all matches | -| REGEXREPLACE | `REGEXREPLACE(text, regex, replacement)` | Text | Regex replace | +| Function | Signature | Return type | Description | +|---|---|---|---| +| CONCATENATE | `CONCATENATE(text1, text2, ...)` | Text | Concatenate multiple texts; supports lists as input | +| LEN | `LEN(text)` | Number | Character count | +| LEFT | `LEFT(text, [count])` | Text | Extract from left; default 1 | +| RIGHT | `RIGHT(text, [count])` | Text | Extract from right; default 1 | +| MID | `MID(text, start, count)` | Text | Extract from middle | +| REPLACE | `REPLACE(text, start, count, new_text)` | Text | Replace by position | +| SUBSTITUTE | `SUBSTITUTE(text, old_text, new_text, [occurrence])` | Text | Replace by content; can specify which occurrence | +| UPPER | `UPPER(text)` | Text | Convert to uppercase | +| LOWER | `LOWER(text)` | Text | Convert to lowercase | +| TRIM | `TRIM(text)` | Text | Remove leading/trailing spaces | +| TEXT | `TEXT(value, format)` | Text | Format output. Date formats: `"YYYY-MM-DD"`, `"YYYY/MM/DD hh:mm:ss"`; number formats: `"00"`, `"000.00"` | +| CONTAINTEXT | `CONTAINTEXT(text, search_text)` | Boolean | Tests if text contains substring (text substring matching) | +| SPLIT | `SPLIT(text, delimiter)` | List | Split text by delimiter | ### 8.4 Date functions -| Function | Signature | Return type | Description | -| ----------- | ----------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------- | -| NOW | `NOW()` | Date | Current date and time | -| TODAY | `TODAY()` | Date | Current date (midnight) | -| DATE | `DATE(year, month, day)` | Date | Construct a date | -| YEAR | `YEAR(date)` | Number | Extract year | -| MONTH | `MONTH(date)` | Number | Extract month | -| DAY | `DAY(date)` | Number | Extract day | -| HOUR | `HOUR(date)` | Number | Extract hour | -| MINUTE | `MINUTE(date)` | Number | Extract minute | -| SECOND | `SECOND(date)` | Number | Extract second | -| WEEKDAY | `WEEKDAY(date, [type])` | Number | Day of week | -| WEEKNUM | `WEEKNUM(date, [type])` | Number | Week number | -| DAYS | `DAYS(end_date, start_date)` | Number | Days between two dates (end - start), includes decimals. **Note parameter order: end date comes first** | -| DATEDIF | `DATEDIF(start_date, end_date, [unit])` | Number | Whole days/months/years between dates. Unit: `"D"`(default)/`"M"`/`"Y"`. **Start must be before end** | -| DURATION | `DURATION(days, [hours], [minutes], [seconds])` | Duration | Create a duration for date arithmetic | -| EDATE | `EDATE(date, months)` | Date | Date N months later | -| EOMONTH | `EOMONTH(date, [months])` | Date | End of month N months later; months default 0 | -| WORKDAY | `WORKDAY(start_date, days, [holidays])` | Date | Date N workdays later (skips weekends and holidays) | -| NETWORKDAYS | `NETWORKDAYS(start_date, end_date, [holidays])` | Number | Workdays between dates (inclusive) | +| Function | Signature | Return type | Description | +|---|---|---|---| +| NOW | `NOW()` | Date | Current date and time | +| TODAY | `TODAY()` | Date | Current date (midnight) | +| DATE | `DATE(year, month, day)` | Date | Construct a date | +| YEAR | `YEAR(date)` | Number | Extract year | +| MONTH | `MONTH(date)` | Number | Extract month | +| DAY | `DAY(date)` | Number | Extract day | +| HOUR | `HOUR(date)` | Number | Extract hour | +| MINUTE | `MINUTE(date)` | Number | Extract minute | +| SECOND | `SECOND(date)` | Number | Extract second | +| WEEKDAY | `WEEKDAY(date, [type])` | Number | Day of week | +| WEEKNUM | `WEEKNUM(date, [type])` | Number | Week number | +| DAYS | `DAYS(end_date, start_date)` | Number | Days between two dates (end - start), includes decimals. **Note parameter order: end date comes first** | +| DATEDIF | `DATEDIF(start_date, end_date, [unit])` | Number | Whole days/months/years between dates. Unit: `"D"`(default)/`"M"`/`"Y"`. **Start must be before end** | +| NETWORKDAYS | `NETWORKDAYS(start_date, end_date, [holidays])` | Number | Workdays between dates (inclusive) | ### 8.5 List functions -| Function | Signature | Return type | Description | -| --- | --- | --- | --- | -| LIST | `LIST(val1, val2, ...)` | List | Create a list | -| FIRST | `FIRST(list)` | Scalar | First element | -| LAST | `LAST(list)` | Scalar | Last element | -| NTH | `NTH(list, index)` | Scalar | Nth element (1-based) | -| FILTER | `[Table].FILTER(condition).[ResultCol]` or `[Table].[Col].FILTER(condition)` | List | Filter by condition. When data range is a table, result column is **required**; when it's a column/list, it's not needed. Use CurrentValue in conditions. Add `.LISTCOMBINE()` when result column is multi-value | -| MAP | `data_range.MAP(mapping_expr)` | List | Apply mapping to each element. Use CurrentValue in mapping | -| SORT | `SORT(list, [ascending])` | List | Sort; default ascending (TRUE) | -| SORTBY | `[Table].SORTBY([Table].[SortCol], [ascending]).[OutputCol]` | List | Sort by column then extract output column. **Chain-only, must include output column** | -| UNIQUE | `UNIQUE(list)` | List | Deduplicate | -| ARRAYJOIN | `ARRAYJOIN(list, [delimiter])` | Text | Join list elements as text; default comma-separated | -| LISTCOMBINE | `LISTCOMBINE(val1, [val2, ...])` or `list.LISTCOMBINE()` | List | Two uses: (1) merge values/lists into one list; (2) chained call to flatten 2D array (commonly used when FILTER result column is a multi-value field) | -| DISTANCE | `DISTANCE(location1, location2)` | Number | Distance between two geographic locations (km) | +| Function | Signature | Return type | Description | +|---|---|---|---| +| FIRST | `FIRST(list)` | Scalar | First element | +| LAST | `LAST(list)` | Scalar | Last element | +| FILTER | `[Table].FILTER(condition).[ResultCol]` or `[Table].[Col].FILTER(condition)` | List | Filter by condition. When data range is a table, result column is **required**; when it's a column/list, it's not needed. Use CurrentValue in conditions. Add `.LISTCOMBINE()` when result column is multi-value | +| MAP | `data_range.MAP(mapping_expr)` | List | Apply mapping to each element. Use CurrentValue in mapping | +| SORT | `SORT(list, [ascending])` | List | Sort; default ascending (TRUE) | +| SORTBY | `[Table].SORTBY([Table].[SortCol], [ascending]).[OutputCol]` | List | Sort by column then extract output column. **Chain-only, must include output column** | +| UNIQUE | `UNIQUE(list)` | List | Deduplicate | +| ARRAYJOIN | `ARRAYJOIN(list, [delimiter])` | Text | Join list elements as text; default comma-separated | +| LISTCOMBINE | `LISTCOMBINE(val1, [val2, ...])` or `list.LISTCOMBINE()` | List | Two uses: (1) merge values/lists into one list; (2) chained call to flatten 2D array (commonly used when FILTER result column is a multi-value field) | --- - ## Section 9: Commonly Confused Functions ### CONTAIN vs CONTAINTEXT -| | CONTAIN | CONTAINTEXT | -| ----------- | -------------------------------------------------------------- | ---------------------------------------------------------- | -| Purpose | Tests if a **list / `select` (`multiple=true`)** contains a value | Tests if **text** contains a substring | -| Example | `[Tags].CONTAIN("Urgent")` | `[Notes].CONTAINTEXT("completed")` | +| | CONTAIN | CONTAINTEXT | +|---|---|---| +| Purpose | Tests if a **list / `select` (`multiple=true`)** contains a value | Tests if **text** contains a substring | +| Example | `[Tags].CONTAIN("Urgent")` | `[Notes].CONTAINTEXT("completed")` | | Wrong usage | `CONTAIN([Notes], "completed")` — cannot do substring matching | `CONTAINTEXT([Tags], "Urgent")` — Tags is a list, not text | ### ISBLANK vs ISNULL -| | ISBLANK | ISNULL | -| ----------------- | ------- | ------ | -| NULL | TRUE | TRUE | -| `""` empty string | TRUE | FALSE | -| Empty list `[]` | TRUE | FALSE | -| `0` | FALSE | FALSE | -| `FALSE` | FALSE | FALSE | +| | ISBLANK | ISNULL | +|---|---|---| +| NULL | TRUE | TRUE | +| `""` empty string | TRUE | FALSE | +| Empty list `[]` | TRUE | FALSE | +| `0` | FALSE | FALSE | +| `FALSE` | FALSE | FALSE | ### DAYS vs DATEDIF -| | DAYS | DATEDIF | -| --------------- | ------------------------------------------------------------ | ----------------------------------------- | -| Parameter order | `DAYS(end, start)` — end first | `DATEDIF(start, end, unit)` — start first | -| Precision | Includes decimals (hours/minutes/seconds as fractional days) | Integer only (whole days/months/years) | -| Negative values | Returns negative when start is after end | **Errors** when start is after end | +| | DAYS | DATEDIF | +|---|---|---| +| Parameter order | `DAYS(end, start)` — end first | `DATEDIF(start, end, unit)` — start first | +| Precision | Includes decimals (hours/minutes/seconds as fractional days) | Integer only (whole days/months/years) | +| Negative values | Returns negative when start is after end | **Errors** when start is after end | ### SUM vs SUMIF -| | SUM | SUMIF | -| --------- | ---------------------------------------------- | -------------------------------------------------------------- | -| Purpose | Sum all values | Sum values **matching a condition** | -| Arguments | `SUM(val1, val2, ...)` or `SUM([Table].[Col])` | `SUMIF(data_range, condition)` with CurrentValue in condition | -| Example | `SUM([Orders].[Amount])` — sum all | `SUMIF([Orders].[Amount], CurrentValue > 100)` — sum only >100 | +| | SUM | SUMIF | +|---|---|---| +| Purpose | Sum all values | Sum values **matching a condition** | +| Arguments | `SUM(val1, val2, ...)` or `SUM([Table].[Col])` | `SUMIF(data_range, condition)` with CurrentValue in condition | +| Example | `SUM([Orders].[Amount])` — sum all | `SUMIF([Orders].[Amount], CurrentValue > 100)` — sum only >100 | ### FILTER+aggregation vs COUNTIF/SUMIF -| | FILTER+aggregation | COUNTIF/SUMIF | -| ----------- | ----------------------------------------------------- | ------------------------------------------------------------------------------ | -| Nature | Filter then aggregate (two steps) | One-step (syntactic sugar) | -| Equivalence | `[Table].FILTER(cond).[Col].LISTCOMBINE().SUM()` | `SUMIF([Table].[Col], cond)` (only when condition involves only column values) | -| When to use | Conditions span multiple fields, or multi-step needed | Conditions only involve column values (e.g. `CurrentValue > 100`) | +| | FILTER+aggregation | COUNTIF/SUMIF | +|---|---|---| +| Nature | Filter then aggregate (two steps) | One-step (syntactic sugar) | +| Equivalence | `[Table].FILTER(cond).[Col].LISTCOMBINE().SUM()` | `SUMIF([Table].[Col], cond)` (only when condition involves only column values) | +| When to use | Conditions span multiple fields, or multi-step needed | Conditions only involve column values (e.g. `CurrentValue > 100`) | --- @@ -612,119 +576,11 @@ Reason: NOW, TODAY, PI and other zero-argument functions must include parenthese --- -## Section 13: Complete Examples - -### Example 1: Employee sales summary - -**Table structure** (from `+table-get`): - -- Employees: EmployeeID (Text), Name (Text), Department (Text) -- Sales: ContractID (Number), SalespersonID (Text), Quantity (Number), Total (Number) - -**Current table**: Employees - -**Requirement**: For each employee, output "Sold XX orders" if they have sales records, otherwise "No sales records". - -**Formula**: - -``` -IF( - [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1, - "Sold " & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & " orders", - "No sales records" -) -``` - -**Field JSON**: - -```json -{ - "type": "formula", - "name": "Sales Summary", - "expression": "IF([Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1, \"Sold \" & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & \" orders\", \"No sales records\")" -} -``` - -**Explanation**: `[Sales].COUNTIF(...)` uses the entire Sales table as data range. CurrentValue represents each row in Sales, accessing `CurrentValue.[SalespersonID]` for that row's salesperson. `[EmployeeID]` refers to the current row in the Employees table (where the formula lives). - -### Example 2: Chained cross-table access via link fields - -**Table structure**: - -- Orders: ID (`auto_number`), OrderItems (`link` [target: OrderItems, foreign key: ID]) -- OrderItems: ID (`auto_number`), Product (`link` [target: Products, foreign key: ID]) -- Products: ID (`auto_number`), ProductName (`text`) - -**Current table**: Orders - -**Requirement**: Deduplicate and comma-join all product names from linked order items. - -**Formula**: - -``` -[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(",") -``` +## Section 13: Examples -**Field JSON**: - -```json -{ - "type": "formula", - "name": "Product List", - "expression": "[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(\",\")" -} -``` - -**Explanation**: `[OrderItems]` gets linked order item records, `.[Product]` expands to each item's linked product, `.[ProductName]` gets all product names, `.UNIQUE()` deduplicates, `.ARRAYJOIN(",")` joins with commas. - -### Example 3: Cross-table filter + sort - -**Table structure**: - -- Projects: ProjectName (Text), Status (Text), Owner (Text) -- Tasks: TaskName (Text), Project (Text), Priority (Number), DueDate (Date) - -**Current table**: Projects - -**Requirement**: Find the highest-priority (lowest number) task name for the current project. - -**Formula**: - -``` -FIRST( - [Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName] -) -``` - -**Field JSON**: - -```json -{ - "type": "formula", - "name": "Top Priority Task", - "expression": "FIRST([Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName])" -} -``` - -**Explanation**: `[Tasks].FILTER(CurrentValue.[Project] = [ProjectName])` filters tasks belonging to the current project. `.SORTBY([Tasks].[Priority], TRUE)` sorts by priority ascending. `.[TaskName]` extracts task names. `FIRST(...)` gets the first one (highest priority). - ---- - -## Section 14: Translating User Requirements to Formulas - -When the user describes their formula need in natural language, follow these rules to convert it into a precise expression: - -1. **Numbers must use precise values**: "less than 80%" → field value less than `0.8`. "above 1000" → `>= 1000`. -2. **Interval boundaries**: "above/below/within" = closed (inclusive); "less than/more than/outside" = open (exclusive). -3. **Branching logic** must be organized as an ordered list with a fallback branch. Each branch has a condition and output. - - Example: "return risk level for 1-3" → `IFS([Value] = 1, "low", [Value] = 2, "medium", [Value] = 3, "high")` with an `IFERROR` or trailing empty-string fallback. -4. **Multi-level branches must be flattened** to a single level. Nested if-else chains → flat IFS. -5. **Branch conditions must be mutually exclusive**. If the user's conditions overlap, rewrite to eliminate ambiguity. -6. **Reorder branches by logical priority** if the user's order is illogical (e.g., check specific conditions before catch-all). - ---- +完整示例与"自然语言需求 → 公式"翻译规则按需读 [formula-examples.md](formula-examples.md)。 -## Section 15: Constraint Summary +## Section 14: Constraint Summary - Request body must include `"type": "formula"` — this field is required - Only use functions and operators listed in this document diff --git a/skills/lark-base/references/formula-functions-extended.md b/skills/lark-base/references/formula-functions-extended.md new file mode 100644 index 000000000..fd30b275f --- /dev/null +++ b/skills/lark-base/references/formula-functions-extended.md @@ -0,0 +1,66 @@ +# Base Formula Functions — Extended (rare functions) + +> 本文件是 [formula-field-guide.md](formula-field-guide.md) Section 8 的长尾补充:三角/双曲/随机数/进制/统计扩展等罕见函数。 +> 表格列含义与主文档一致:Function | Signature | Return type | Description。 + +## 8.1 Logic functions (extended) + +| Function | Signature | Return type | Description | +|---|---|---|---| +| ISERROR | `ISERROR(expr)` | Boolean | Tests if expression errors | +| ISNUMBER | `ISNUMBER(value)` | Boolean | Tests if value is a number | +| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains all specified values | +| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains only the specified values | +| TRUE | `TRUE()` | Boolean | Returns TRUE | +| FALSE | `FALSE()` | Boolean | Returns FALSE | +| RANDOMBETWEEN | `RANDOMBETWEEN(min_int, max_int, [keep_updating])` | Number | Random integer in the specified range | +| RANDOMITEM | `RANDOMITEM(list, [keep_updating])` | Matches element type | Randomly picks one element from a list | + +## 8.2 Numeric functions (extended) + +| Function | Signature | Return type | Description | +|---|---|---|---| +| MEDIAN | `MEDIAN(val1, val2, ...)` | Number | Median | +| ROUNDUP | `ROUNDUP(number, digits)` | Number | Round away from zero. Same digits semantics as ROUND | +| ROUNDDOWN | `ROUNDDOWN(number, digits)` | Number | Round toward zero. Same digits semantics as ROUND | +| FLOOR | `FLOOR(number, [base])` | Number | Round down to nearest multiple of base (default 1) | +| CEILING | `CEILING(number, [base])` | Number | Round up to nearest multiple of base (default 1) | +| POWER | `POWER(base, exponent)` | Number | Exponentiation | +| QUOTIENT | `QUOTIENT(dividend, divisor)` | Number | Integer division | +| ISODD | `ISODD(number)` | Boolean | Tests if number is odd | +| RANK | `RANK(value, search_range, [ascending])` | Number | Rank of value in range; default descending | +| SEQUENCE | `SEQUENCE(start, end, [step])` | List | Generate number sequence | +| PI | `PI()` | Number | Pi constant | +| SIN/COS/TAN/ASIN/ACOS/ATAN/ATAN2/SINH/COSH/TANH/ASINH/ACOSH/ATANH | `func(radians_or_value)` | Number | Trigonometric and hyperbolic functions; arguments in radians | + +## 8.3 Text functions (extended) + +| Function | Signature | Return type | Description | +|---|---|---|---| +| FIND | `FIND(search_val, search_range, [start])` | Number | Find substring position (case-sensitive); returns -1 if not found | +| TODATE | `TODATE(value)` | Date | Convert date string to date type | +| CHAR | `CHAR(number)` | Text | ASCII code to character | +| FORMAT | `FORMAT(template, [val1, val2, ...])` | Text | Template string formatting; use `{1}`, `{2}` as placeholders | +| HYPERLINK | `HYPERLINK(url, [display_text])` | Hyperlink | Create a hyperlink | +| ENCODEURL | `ENCODEURL(text)` | Text | URL encode | +| REGEXMATCH | `REGEXMATCH(text, regex)` | Boolean | Regex match test | +| REGEXEXTRACT | `REGEXEXTRACT(text, regex)` | List | Extract first match's capture groups | +| REGEXEXTRACTALL | `REGEXEXTRACTALL(text, regex)` | 2D List | Extract all matches | +| REGEXREPLACE | `REGEXREPLACE(text, regex, replacement)` | Text | Regex replace | + +## 8.4 Date functions (extended) + +| Function | Signature | Return type | Description | +|---|---|---|---| +| DURATION | `DURATION(days, [hours], [minutes], [seconds])` | Duration | Create a duration for date arithmetic | +| EDATE | `EDATE(date, months)` | Date | Date N months later | +| EOMONTH | `EOMONTH(date, [months])` | Date | End of month N months later; months default 0 | +| WORKDAY | `WORKDAY(start_date, days, [holidays])` | Date | Date N workdays later (skips weekends and holidays) | + +## 8.5 List functions (extended) + +| Function | Signature | Return type | Description | +|---|---|---|---| +| LIST | `LIST(val1, val2, ...)` | List | Create a list | +| NTH | `NTH(list, index)` | Scalar | Nth element (1-based) | +| DISTANCE | `DISTANCE(location1, location2)` | Number | Distance between two geographic locations (km) | diff --git a/skills/lark-base/references/lark-base-cell-value.md b/skills/lark-base/references/lark-base-cell-value.md index bb9cd6631..2cacd8876 100644 --- a/skills/lark-base/references/lark-base-cell-value.md +++ b/skills/lark-base/references/lark-base-cell-value.md @@ -13,6 +13,9 @@ - 一次 payload 里同一字段只用一种 key(字段名或字段 ID),不要重复。 - 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。 - 需要清空字段时优先传 `null`(字段允许清空时)。 +- 只写存储字段:系统字段、`formula`、`lookup` 只读;附件字段不走 CellValue,用 `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment`。 +- 批量写入单批最多 200 条(超出报 `1254104`);同一张表串行写,遇 `1254291` 并发冲突短暂等待后重试。 +- select/multiselect 写入未知选项会触发平台新增该选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。 ## 2. 各类型 CellValue diff --git a/skills/lark-base/references/lark-base-workflow-guide.md b/skills/lark-base/references/lark-base-workflow-guide.md index 8be33b18f..a63c4d473 100644 --- a/skills/lark-base/references/lark-base-workflow-guide.md +++ b/skills/lark-base/references/lark-base-workflow-guide.md @@ -15,7 +15,7 @@ 1. 调用命令前先看 `--help`,不要猜参数名或 JSON 结构。 2. 先确认真实 Base、表、字段、视图和用户/群 ID;不要凭口述猜字段名或 field ID。 -3. 选择一个 trigger;新增记录用 `AddRecordTrigger`,只监听修改用 `SetRecordTrigger`,新增或修改都触发/拿不准用 `ChangeRecordTrigger`。 +3. 选择一个 trigger;新增记录用 `AddRecordTrigger`,只监听修改用 `SetRecordTrigger`,新增或修改都触发/拿不准用 `ChangeRecordTrigger`。用户描述"修改为 X **或** 新增 X 时"这类同条件多来源需求,是 `ChangeRecordTrigger` 的典型场景:**一条工作流 + condition_list 即可,不要拆成 AddRecordTrigger 和 SetRecordTrigger 两条工作流**。 4. 选择 action/branch/system step,打开对应 schema 文件。 5. 需要 `value_type`、`ref`、条件、字段值或输出引用时,再读 [common-types-and-refs.md](workflow-steps/common-types-and-refs.md)。 6. 组装 `title/status/steps` 后用 `+workflow-create` 或 `+workflow-update`。 diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index e36abdde8..4a02528e9 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -63,7 +63,7 @@ | `ButtonTrigger` | 按钮点击触发 | [trigger-button.md](workflow-steps/trigger-button.md) | | `LarkMessageTrigger` | 接收飞书消息触发 | [trigger-lark-message.md](workflow-steps/trigger-lark-message.md) | -触发器选型:新增记录用 `AddRecordTrigger`;只监听修改用 `SetRecordTrigger`;新增或修改都触发、或拿不准时用 `ChangeRecordTrigger`。 +触发器选型:新增记录用 `AddRecordTrigger`;只监听修改用 `SetRecordTrigger`;新增或修改都触发、或拿不准时用 `ChangeRecordTrigger`。"新增或修改满足同一条件就触发"(如"改为 X 或新增 X 时通知")是单个 `ChangeRecordTrigger` 的典型场景,不要拆成两条工作流。 ### Action diff --git a/skills/lark-base/references/workflow-steps/trigger-change-record.md b/skills/lark-base/references/workflow-steps/trigger-change-record.md index d96c2dcc2..4ca0847b9 100644 --- a/skills/lark-base/references/workflow-steps/trigger-change-record.md +++ b/skills/lark-base/references/workflow-steps/trigger-change-record.md @@ -1,5 +1,7 @@ # ChangeRecordTrigger +记录满足条件时触发,**新增和修改都会触发**。"修改为 X 或新增 X 时执行动作"这类需求用本触发器 + `condition_list`,一条工作流即可表达,不要拆成 AddRecordTrigger 和 SetRecordTrigger 两条。 + ```json { "table_name": "任务表", From f7096d230e86eae961296ac0d9e224800025d4b1 Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Thu, 11 Jun 2026 19:37:35 +0800 Subject: [PATCH 08/17] feat: add guess-tolerant flag aliases and misuse hints for base shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence-driven from 3 eval rounds of actual agent flag guesses: - projection aliases --field-names/--fields on record-list/search/get; --record-ids on record-get; --query on field-search-options; --filter/--sort on record-list/search (canonical+alias rejects with "use only one") - plural aliases accept repeated values, JSON arrays and comma-separated ids (comma split guarded by id prefix to protect names with commas) - cross-source dedupe: same value via canonical flag and alias is sent once; in-source duplicates still rejected downstream - misuse hints: record-list --json and data-query --table-id now return actionable errors; dashboard-block-get-data accepts --dashboard-id - SKILL.md: reorder 批量执行 before 善用 help, conditional help wording --- shortcuts/base/base_data_query.go | 5 ++ shortcuts/base/base_execute_test.go | 23 ++++-- shortcuts/base/dashboard_block_get_data.go | 1 + shortcuts/base/field_ops.go | 13 +++- shortcuts/base/field_search_options.go | 4 ++ shortcuts/base/helpers_test.go | 58 ++++++++++++++++ shortcuts/base/record_get.go | 3 + shortcuts/base/record_list.go | 9 +++ shortcuts/base/record_ops.go | 81 ++++++++++++++++++++-- shortcuts/base/record_query.go | 30 +++++++- shortcuts/base/record_search.go | 4 ++ skills/lark-base/SKILL.md | 21 +++--- 12 files changed, 224 insertions(+), 28 deletions(-) diff --git a/shortcuts/base/base_data_query.go b/shortcuts/base/base_data_query.go index 616680a7c..51565d264 100644 --- a/shortcuts/base/base_data_query.go +++ b/shortcuts/base/base_data_query.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "strings" "github.com/larksuite/cli/shortcuts/common" ) @@ -20,6 +21,7 @@ var BaseDataQuery = common.Shortcut{ AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), + {Name: "table-id", Hidden: true}, {Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true}, }, Tips: []string{ @@ -28,6 +30,9 @@ var BaseDataQuery = common.Shortcut{ "`dimensions` and `measures` cannot both be empty.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("table-id")) != "" { + return baseFlagErrorf("+data-query does not support --table-id; put table names/fields inside --dsl (read lark-base-data-query-guide.md)") + } var dsl map[string]interface{} dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) dec.UseNumber() diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 9b10a68ed..3a6b7b42d 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1408,20 +1408,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) - t.Run("list legacy fields flag rejected", func(t *testing.T) { - factory, stdout, _ := newExecuteFactory(t) - err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout) - if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") { + t.Run("list fields alias projects columns", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=Name&limit=100&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{}, "has_more": false}, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } }) - t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) { + t.Run("list fields alias works in dry-run", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout) - if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") { + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } + if got := stdout.String(); !strings.Contains(got, "field_id=Name") { + t.Fatalf("stdout=%s", got) + } }) t.Run("get", func(t *testing.T) { diff --git a/shortcuts/base/dashboard_block_get_data.go b/shortcuts/base/dashboard_block_get_data.go index 3ee3b5a66..0074860f7 100644 --- a/shortcuts/base/dashboard_block_get_data.go +++ b/shortcuts/base/dashboard_block_get_data.go @@ -19,6 +19,7 @@ var BaseDashboardBlockGetData = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), + {Name: "dashboard-id", Hidden: true}, blockIDFlag(true), }, Tips: []string{ diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 129dfbaba..afe2d4062 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -85,7 +85,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) if params["limit"].(int) <= 0 { params["limit"] = 30 } - if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" { params["query"] = keyword } return common.NewDryRunAPI(). @@ -274,6 +274,13 @@ func fieldSearchOptionsRef(runtime *common.RuntimeContext) string { return fieldRef } +func fieldSearchOptionsKeyword(runtime *common.RuntimeContext) string { + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + return keyword + } + return strings.TrimSpace(runtime.Str("query")) +} + func executeFieldSearchOptions(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) @@ -285,7 +292,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error { if params["limit"].(int) <= 0 { params["limit"] = 30 } - if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" { params["query"] = keyword } data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef, "options"), params, nil) @@ -300,7 +307,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error { runtime.Out(map[string]interface{}{ "field_id": fieldRef, "field_name": fieldRef, - "keyword": strings.TrimSpace(runtime.Str("keyword")), + "keyword": fieldSearchOptionsKeyword(runtime), "options": options, "total": total, }, nil) diff --git a/shortcuts/base/field_search_options.go b/shortcuts/base/field_search_options.go index 865c56cf5..6d2775c45 100644 --- a/shortcuts/base/field_search_options.go +++ b/shortcuts/base/field_search_options.go @@ -23,6 +23,7 @@ var BaseFieldSearchOptions = common.Shortcut{ fieldRefFlag(false), {Name: "field-name", Hidden: true}, {Name: "keyword", Desc: "keyword for option query"}, + {Name: "query", Hidden: true}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"}, }, @@ -35,6 +36,9 @@ var BaseFieldSearchOptions = common.Shortcut{ if strings.TrimSpace(fieldSearchOptionsRef(runtime)) == "" { return baseFlagErrorf("--field-id is required") } + if strings.TrimSpace(runtime.Str("keyword")) != "" && strings.TrimSpace(runtime.Str("query")) != "" { + return baseFlagErrorf("--query is a deprecated alias for --keyword; use only one") + } return nil }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index a49f0efc4..cdfde9186 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -4,6 +4,7 @@ package base import ( + "context" "encoding/json" "os" "reflect" @@ -496,3 +497,60 @@ func TestCanonicalSelectAndCompareHelpers(t *testing.T) { t.Fatalf("err=%v", err) } } + +func TestNormalizePluralReferenceValues(t *testing.T) { + cases := []struct { + name string + in []string + prefix string + want []string + }{ + {"repeated single values", []string{"fldA", "fldB"}, "fld", []string{"fldA", "fldB"}}, + {"json array", []string{`["fldA","fldB"]`}, "fld", []string{"fldA", "fldB"}}, + {"comma separated ids", []string{"fldA, fldB"}, "fld", []string{"fldA", "fldB"}}, + {"comma inside name kept whole", []string{"销售额,utf"}, "fld", []string{"销售额,utf"}}, + {"mixed forms", []string{`["fldA"]`, "fldB,fldC", "Name"}, "fld", []string{"fldA", "fldB", "fldC", "Name"}}, + {"invalid json kept literal", []string{`[fldA`}, "fld", []string{`[fldA`}}, + {"blank dropped", []string{" ", "fldA"}, "fld", []string{"fldA"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizePluralReferenceValues(tc.in, tc.prefix); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got=%v want=%v", got, tc.want) + } + }) + } +} + +func TestRecordFlagAliasMergeAndDedupe(t *testing.T) { + fieldRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{ + "field-id": {"fldA"}, + "fields": {"fldA,fldB"}, + }, nil, nil) + if got := recordFieldFlags(fieldRT); !reflect.DeepEqual(got, []string{"fldA", "fldB"}) { + t.Fatalf("field flags=%v", got) + } + recordRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{ + "record-id": {"recA"}, + "record-ids": {`["recA","recB"]`}, + }, nil, nil) + if got := recordIDFlags(recordRT); !reflect.DeepEqual(got, []string{"recA", "recB"}) { + t.Fatalf("record flags=%v", got) + } +} + +func TestFieldSearchOptionsKeywordQueryAlias(t *testing.T) { + ctx := context.Background() + if err := BaseFieldSearchOptions.Validate(ctx, newBaseTestRuntime( + map[string]string{"field-id": "Status", "keyword": "A", "query": "B"}, nil, nil, + )); err == nil || !strings.Contains(err.Error(), "use only one") { + t.Fatalf("err=%v", err) + } + queryOnly := newBaseTestRuntime(map[string]string{"field-id": "Status", "query": "Do"}, nil, nil) + if err := BaseFieldSearchOptions.Validate(ctx, queryOnly); err != nil { + t.Fatalf("err=%v", err) + } + if got := fieldSearchOptionsKeyword(queryOnly); got != "Do" { + t.Fatalf("keyword=%q", got) + } +} diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go index f8d0b720a..9b0e3e554 100644 --- a/shortcuts/base/record_get.go +++ b/shortcuts/base/record_get.go @@ -21,7 +21,10 @@ var BaseRecordGet = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, + {Name: "record-ids", Type: "string_array", Hidden: true}, {Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"}, + {Name: "field-names", Type: "string_array", Hidden: true}, + {Name: "fields", Type: "string_array", Hidden: true}, {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, recordReadFormatFlag(), }, diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index b6489a5c8..678851d91 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -5,6 +5,7 @@ package base import ( "context" + "strings" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" @@ -21,9 +22,14 @@ var BaseRecordList = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), recordListFieldRefFlag(), + {Name: "field-names", Type: "string_array", Hidden: true}, + {Name: "fields", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), + recordFilterAliasFlag(), recordSortFlag(), + recordSortAliasFlag(), + {Name: "json", Hidden: true}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), @@ -45,6 +51,9 @@ var BaseRecordList = common.Shortcut{ if err := validateRecordReadFormat(runtime); err != nil { return err } + if strings.TrimSpace(runtime.Str("json")) != "" { + return baseFlagErrorf("+record-list does not support --json; use --filter-json for filters and --sort-json for sorting") + } return validateRecordQueryOptions(runtime) }, DryRun: dryRunRecordList, diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 1f5264097..d7050deb4 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -5,6 +5,7 @@ package base import ( "context" + "encoding/json" "net/url" "strconv" "strings" @@ -45,11 +46,11 @@ func validateRecordSelection(runtime *common.RuntimeContext) error { } func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) { - recordIDs := runtime.StrArray("record-id") - fieldIDs := runtime.StrArray("field-id") + recordIDs := recordIDFlags(runtime) + fieldIDs := recordFieldFlags(runtime) jsonRaw := strings.TrimSpace(runtime.Str("json")) if len(recordIDs) > 0 && jsonRaw != "" { - return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive") + return recordSelection{}, baseFlagErrorf("--record-id/--record-ids and --json are mutually exclusive") } if jsonRaw != "" { pc := newParseCtx(runtime) @@ -145,6 +146,78 @@ func normalizeRecordGetSelectFields(values interface{}) ([]string, error) { }) } +func recordIDFlags(runtime *common.RuntimeContext) []string { + return mergeReferenceSources( + runtime.StrArray("record-id"), + normalizePluralReferenceValues(runtime.StrArray("record-ids"), "rec"), + ) +} + +func recordFieldFlags(runtime *common.RuntimeContext) []string { + return mergeReferenceSources( + runtime.StrArray("field-id"), + normalizePluralReferenceValues(runtime.StrArray("field-names"), "fld"), + normalizePluralReferenceValues(runtime.StrArray("fields"), "fld"), + ) +} + +// mergeReferenceSources concatenates flag sources, dropping values from later +// sources that an earlier source already provided — so the same reference +// passed through both a canonical flag and its plural alias is sent only once. +// Duplicates inside a single source are kept on purpose: repeating a value on +// one flag is a user mistake that downstream validation should keep rejecting. +func mergeReferenceSources(sources ...[]string) []string { + var out []string + seenBefore := map[string]struct{}{} + for _, source := range sources { + for _, value := range source { + if _, ok := seenBefore[value]; ok { + continue + } + out = append(out, value) + } + for _, value := range source { + seenBefore[value] = struct{}{} + } + } + return out +} + +func normalizePluralReferenceValues(values []string, idPrefix string) []string { + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if strings.HasPrefix(value, "[") { + var parsed []string + if err := json.Unmarshal([]byte(value), &parsed); err == nil { + out = append(out, parsed...) + continue + } + } + if strings.Contains(value, ",") { + parts := strings.Split(value, ",") + allIDs := true + trimmed := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + trimmed = append(trimmed, part) + if !strings.HasPrefix(part, idPrefix) { + allIDs = false + } + } + if allIDs { + out = append(out, trimmed...) + continue + } + } + out = append(out, value) + } + return out +} + func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) { var rawItems []interface{} switch typed := values.(type) { @@ -375,7 +448,7 @@ func validateRecordJSON(runtime *common.RuntimeContext) error { } func recordListFields(runtime *common.RuntimeContext) []string { - return runtime.StrArray("field-id") + return recordFieldFlags(runtime) } func executeRecordList(runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go index 75b324d00..edde9af3c 100644 --- a/shortcuts/base/record_query.go +++ b/shortcuts/base/record_query.go @@ -26,6 +26,10 @@ func recordFilterFlag() common.Flag { } } +func recordFilterAliasFlag() common.Flag { + return common.Flag{Name: "filter", Hidden: true, Input: []string{common.File}} +} + func recordSortFlag() common.Flag { return common.Flag{ Name: recordSortJSONFlag, @@ -34,6 +38,10 @@ func recordSortFlag() common.Flag { } } +func recordSortAliasFlag() common.Flag { + return common.Flag{Name: "sort", Hidden: true, Input: []string{common.File}} +} + func validateRecordQueryOptions(runtime *common.RuntimeContext) error { if _, err := parseRecordFilterFlag(runtime); err != nil { return err @@ -43,7 +51,10 @@ func validateRecordQueryOptions(runtime *common.RuntimeContext) error { } func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) { - filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag)) + filterRaw, err := recordQueryFlagValue(runtime, recordFilterJSONFlag, "filter") + if err != nil { + return nil, err + } if filterRaw == "" { return nil, nil } @@ -52,7 +63,10 @@ func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) } func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) { - sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag)) + sortRaw, err := recordQueryFlagValue(runtime, recordSortJSONFlag, "sort") + if err != nil { + return nil, err + } if sortRaw == "" { return nil, nil } @@ -64,6 +78,18 @@ func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) return normalizeRecordSortValue(value, "--"+recordSortJSONFlag) } +func recordQueryFlagValue(runtime *common.RuntimeContext, canonical string, alias string) (string, error) { + canonicalRaw := strings.TrimSpace(runtime.Str(canonical)) + aliasRaw := strings.TrimSpace(runtime.Str(alias)) + if canonicalRaw != "" && aliasRaw != "" { + return "", baseFlagErrorf("--%s is a deprecated alias for --%s; use only one", alias, canonical) + } + if canonicalRaw != "" { + return canonicalRaw, nil + } + return aliasRaw, nil +} + func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) { var sortConfig []interface{} if parsed, ok := value.([]interface{}); ok { diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index 27a30f501..ba2979e6a 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -25,9 +25,13 @@ var BaseRecordSearch = common.Shortcut{ {Name: "query", Desc: "deprecated alias for --keyword", Hidden: true}, {Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"}, recordListFieldRefFlag(), + {Name: "field-names", Type: "string_array", Hidden: true}, + {Name: "fields", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), + recordFilterAliasFlag(), recordSortFlag(), + recordSortAliasFlag(), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 4e32dfe9a..1fa534427 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -89,21 +89,13 @@ metadata: ## 注意事项 -### Help 先行 - -- 参数不确定、要构造复杂 JSON、或命令带批量/隐藏选项时,先看 `--help`,不要猜参数名或 JSON 结构;`+table-list` / `+base-create` 这类参数显而易见的简单命令直接执行,报参数错误再查 help,不要为它单花一轮。 -- 需要看多个命令的 `--help` 时,合并在一条 Bash 命令里一次看完,不要一轮对话只看一个: - -```bash -lark-cli base +table-list --help; lark-cli base +field-list --help; lark-cli base +field-update --help -``` - ### 批量执行 +能批量的操作尽量批量,不要一轮对话只处理一个对象。 + - 优先用原生批量能力:多表字段 `+field-list-batch`;批量写记录 `+record-batch-create` / `+record-batch-update`;部分命令参数本身支持多值(如 `+record-delete --record-id` 可重复传、`+record-share-link-create --record-ids`),先看 `--help`。 -- 没有原生批量命令时,对多个对象做同类操作要在**一条 Bash 命令**里用 shell 循环完成,不要一轮对话只执行一个命令、看完结果再发下一个。 -- 循环内先 `echo` 对象标识再执行,失败可定位到具体对象;写同一张表保持串行;只读命令可用 `--jq` 收窄输出,避免无关字段灌入上下文。 -- 脚本输出只打印计数、ID 和失败项,不要回显完整 payload 或原始返回。 +- 没有原生批量命令时,对多个对象做同类操作在**一条 Bash 命令**里用 shell 循环完成。 +- 只读命令可用 `--jq` 收窄输出,避免无关字段灌入上下文。脚本输出只打印计数、ID 和失败项,不要回显完整 payload 或原始返回 示例——一次取多个视图的配置: @@ -114,6 +106,11 @@ for v in vewAAA vewBBB vewCCC; do done ``` +### 善用 help + +- 参数不确定、要构造复杂 JSON、或命令带批量/隐藏选项时,先看对应reference或 `--help`,不要猜参数名或 JSON 结构;`+table-list` / `+base-create` 这类参数显而易见的简单命令直接执行,报参数错误再查 help,不要为它单花一轮。 +- 需要看多个命令的 help 时,合并在一条 Bash 命令里一次看完。 + ### 身份与权限降级 - 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`。 From 19daffad3d65629cd72c38aedc575f1bf15ba2b9 Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Thu, 11 Jun 2026 20:06:45 +0800 Subject: [PATCH 09/17] fix: plural alias flags always split ASCII comma as list separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eval traces show comma-joined values on --field-names/--fields/--record-ids are exclusively lists (mostly field names), so the fld/rec prefix guard blocked every real usage and a trailing comma broke id lists too. Rule is now: singular flags stay literal (escape hatch for names containing a comma), plural aliases treat ASCII comma as separator; fullwidth "," is never split. --- shortcuts/base/base_execute_test.go | 71 ++++++++++++++++++++++++++ shortcuts/base/field_list_batch.go | 2 +- shortcuts/base/field_ops.go | 78 ++++++++++++++++++++++++++--- shortcuts/base/helpers_test.go | 25 ++++----- shortcuts/base/record_ops.go | 33 ++++++------ shortcuts/base/table_ops.go | 18 +++++++ 6 files changed, 189 insertions(+), 38 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 3a6b7b42d..42fc47e67 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -855,6 +855,36 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("list resolves table name", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_orders", "name": "Orders"}, + }, "total": 1}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_name", "name": "Name", "type": "text"}, + }, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "Orders"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"name": "Name"`) { + t.Fatalf("stdout=%s", got) + } + }) + t.Run("list batch multiple tables", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -886,6 +916,47 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("list batch resolves table names", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_orders", "name": "Orders"}, + }, "total": 1}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text"}, + }, "total": 1}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_order", "name": "Status", "type": "select"}, + }, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "Orders"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_orders"`) || !strings.Contains(got, `"table_ref": "Orders"`) || !strings.Contains(got, `"table_name": "Orders"`) { + t.Fatalf("stdout=%s", got) + } + }) + t.Run("list batch default keeps full fields", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/field_list_batch.go b/shortcuts/base/field_list_batch.go index 544ad41be..c45ed3f10 100644 --- a/shortcuts/base/field_list_batch.go +++ b/shortcuts/base/field_list_batch.go @@ -18,7 +18,7 @@ var BaseFieldListBatch = common.Shortcut{ AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), - {Name: "table-id", Type: "string_array", Desc: "table ID (must start with tbl if ID) or name; repeat to list fields for multiple tables", Required: true}, + {Name: "table-id", Type: "string_array", Desc: tableRefFlag(true).Desc + "; repeat to list fields for multiple tables", Required: true}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, {Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"}, diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index afe2d4062..84815da6e 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -10,6 +10,12 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +type fieldListTableRef struct { + input string + id string + name string +} + func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") if offset < 0 { @@ -135,7 +141,12 @@ func executeFieldList(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) - fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) + baseToken := runtime.Str("base-token") + tableRef, err := resolveFieldListTableRefs(runtime, baseToken, []string{baseTableID(runtime)}) + if err != nil { + return err + } + fields, total, err := listAllFields(runtime, baseToken, tableRef[0].id, offset, limit) if err != nil { return err } @@ -156,10 +167,13 @@ func executeFieldListBatch(runtime *common.RuntimeContext) error { } limit := common.ParseIntBounded(runtime, "limit", 1, 200) baseToken := runtime.Str("base-token") - tableRefs := runtime.StrArray("table-id") + tableRefs, err := resolveFieldListTableRefs(runtime, baseToken, runtime.StrArray("table-id")) + if err != nil { + return err + } results := make([]map[string]interface{}, 0, len(tableRefs)) for _, tableRef := range tableRefs { - fields, total, err := listAllFields(runtime, baseToken, tableRef, offset, limit) + fields, total, err := listAllFields(runtime, baseToken, tableRef.id, offset, limit) if err != nil { return err } @@ -169,16 +183,68 @@ func executeFieldListBatch(runtime *common.RuntimeContext) error { if runtime.Bool("compact") { fields = compactFields(fields) } - results = append(results, map[string]interface{}{ - "table_id": tableRef, + result := map[string]interface{}{ + "table_id": tableRef.id, "fields": fields, "total": total, - }) + } + if tableRef.input != tableRef.id { + result["table_ref"] = tableRef.input + } + if tableRef.name != "" { + result["table_name"] = tableRef.name + } + results = append(results, result) } runtime.Out(map[string]interface{}{"tables": results, "total": len(results)}, nil) return nil } +func resolveFieldListTableRefs(runtime *common.RuntimeContext, baseToken string, refs []string) ([]fieldListTableRef, error) { + if len(refs) == 0 { + return nil, baseValidationErrorf("--table-id is required") + } + resolved := make([]fieldListTableRef, 0, len(refs)) + needsTableList := false + for _, raw := range refs { + ref := strings.TrimSpace(raw) + if ref == "" { + return nil, baseValidationErrorf("--table-id must not be empty") + } + if !isBaseTableID(ref) { + needsTableList = true + } + resolved = append(resolved, fieldListTableRef{input: ref, id: ref}) + } + if !needsTableList { + return resolved, nil + } + tables, err := listEveryTable(runtime, baseToken) + if err != nil { + return nil, err + } + for i, tableRef := range resolved { + if isBaseTableID(tableRef.input) { + continue + } + table, err := resolveTableRef(tables, tableRef.input) + if err != nil { + return nil, baseValidationErrorf("table %q not found; run +table-list to verify the table name or pass the tbl... ID", tableRef.input) + } + tableIDValue := tableID(table) + if tableIDValue == "" { + return nil, baseValidationErrorf("table %q resolved without a table ID; run +table-list and pass the tbl... ID", tableRef.input) + } + resolved[i].id = tableIDValue + resolved[i].name = tableNameFromMap(table) + } + return resolved, nil +} + +func isBaseTableID(ref string) bool { + return strings.HasPrefix(strings.TrimSpace(ref), "tbl") +} + // compactFields projects each field to the keys an agent needs for selection // (id / name / type / style, plus select option names), dropping formula // expressions and lookup internals that bloat agent context. Opt-in via diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index cdfde9186..6a7b4152f 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -500,22 +500,23 @@ func TestCanonicalSelectAndCompareHelpers(t *testing.T) { func TestNormalizePluralReferenceValues(t *testing.T) { cases := []struct { - name string - in []string - prefix string - want []string + name string + in []string + want []string }{ - {"repeated single values", []string{"fldA", "fldB"}, "fld", []string{"fldA", "fldB"}}, - {"json array", []string{`["fldA","fldB"]`}, "fld", []string{"fldA", "fldB"}}, - {"comma separated ids", []string{"fldA, fldB"}, "fld", []string{"fldA", "fldB"}}, - {"comma inside name kept whole", []string{"销售额,utf"}, "fld", []string{"销售额,utf"}}, - {"mixed forms", []string{`["fldA"]`, "fldB,fldC", "Name"}, "fld", []string{"fldA", "fldB", "fldC", "Name"}}, - {"invalid json kept literal", []string{`[fldA`}, "fld", []string{`[fldA`}}, - {"blank dropped", []string{" ", "fldA"}, "fld", []string{"fldA"}}, + {"repeated single values", []string{"fldA", "fldB"}, []string{"fldA", "fldB"}}, + {"json array", []string{`["fldA","fldB"]`}, []string{"fldA", "fldB"}}, + {"comma separated ids", []string{"fldA, fldB"}, []string{"fldA", "fldB"}}, + {"comma separated names", []string{"商品名称,SKU,单价"}, []string{"商品名称", "SKU", "单价"}}, + {"trailing comma ignored", []string{"recA,recB,"}, []string{"recA", "recB"}}, + {"fullwidth comma kept whole", []string{"销售额,单价"}, []string{"销售额,单价"}}, + {"mixed forms", []string{`["fldA"]`, "fldB,fldC", "Name"}, []string{"fldA", "fldB", "fldC", "Name"}}, + {"invalid json kept literal", []string{`[fldA`}, []string{`[fldA`}}, + {"blank dropped", []string{" ", "fldA"}, []string{"fldA"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := normalizePluralReferenceValues(tc.in, tc.prefix); !reflect.DeepEqual(got, tc.want) { + if got := normalizePluralReferenceValues(tc.in); !reflect.DeepEqual(got, tc.want) { t.Fatalf("got=%v want=%v", got, tc.want) } }) diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index d7050deb4..ef49b3af9 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -149,15 +149,15 @@ func normalizeRecordGetSelectFields(values interface{}) ([]string, error) { func recordIDFlags(runtime *common.RuntimeContext) []string { return mergeReferenceSources( runtime.StrArray("record-id"), - normalizePluralReferenceValues(runtime.StrArray("record-ids"), "rec"), + normalizePluralReferenceValues(runtime.StrArray("record-ids")), ) } func recordFieldFlags(runtime *common.RuntimeContext) []string { return mergeReferenceSources( runtime.StrArray("field-id"), - normalizePluralReferenceValues(runtime.StrArray("field-names"), "fld"), - normalizePluralReferenceValues(runtime.StrArray("fields"), "fld"), + normalizePluralReferenceValues(runtime.StrArray("field-names")), + normalizePluralReferenceValues(runtime.StrArray("fields")), ) } @@ -183,7 +183,14 @@ func mergeReferenceSources(sources ...[]string) []string { return out } -func normalizePluralReferenceValues(values []string, idPrefix string) []string { +// normalizePluralReferenceValues expands each raw value of a plural alias flag +// (--field-names / --fields / --record-ids) into individual references. Plural +// flags carry list semantics, so an ASCII comma is always a separator (eval +// traces show comma-joined values are exclusively lists, mostly field names); +// a JSON string array is also accepted. Names that contain a literal ASCII +// comma must use the singular flag (--field-id), which never splits. Fullwidth +// "," and "、" are untouched, so ordinary Chinese names are safe here too. +func normalizePluralReferenceValues(values []string) []string { var out []string for _, value := range values { value = strings.TrimSpace(value) @@ -197,23 +204,11 @@ func normalizePluralReferenceValues(values []string, idPrefix string) []string { continue } } - if strings.Contains(value, ",") { - parts := strings.Split(value, ",") - allIDs := true - trimmed := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - trimmed = append(trimmed, part) - if !strings.HasPrefix(part, idPrefix) { - allIDs = false - } - } - if allIDs { - out = append(out, trimmed...) - continue + for _, part := range strings.Split(value, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) } } - out = append(out, value) } return out } diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index e3b87154b..7de9625c7 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -172,6 +172,24 @@ func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) ( return items, nil } +func listEveryTable(runtime *common.RuntimeContext, baseToken string) ([]map[string]interface{}, error) { + const pageLimit = 100 + offset := 0 + items := []map[string]interface{}{} + for { + batch, total, err := listAllTables(runtime, baseToken, offset, pageLimit) + if err != nil { + return nil, err + } + items = append(items, batch...) + if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) { + break + } + offset += len(batch) + } + return items, nil +} + func listEveryView(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { const pageLimit = 100 offset := 0 From e4df47f7c5075e598ba6efd9c0c0f58881323101 Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Thu, 11 Jun 2026 20:07:12 +0800 Subject: [PATCH 10/17] docs(lark-base): align dashboard guide field-list examples with batch/compact flow --- skills/lark-base/references/lark-base-dashboard-usecase.md | 6 +++--- skills/lark-base/references/lark-base-dashboard.md | 5 ----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/skills/lark-base/references/lark-base-dashboard-usecase.md b/skills/lark-base/references/lark-base-dashboard-usecase.md index 0d8bf8ff4..d78822bae 100644 --- a/skills/lark-base/references/lark-base-dashboard-usecase.md +++ b/skills/lark-base/references/lark-base-dashboard-usecase.md @@ -34,7 +34,7 @@ lark-cli base +dashboard-create --base-token xxx --name "销售数据分析" # 第 2 步:获取数据源信息 lark-cli base +table-list --base-token xxx # 先拿表名/table_id -lark-cli base +field-list --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 +lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 # 第 3 步:规划应该创建哪些组件(根据用户需求确定组件类型和数量) # 例如:总销售额(指标卡)、月度趋势(折线图)、品类占比(饼图) @@ -81,7 +81,7 @@ lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx # 第 3 步:获取数据源信息 lark-cli base +table-list --base-token xxx # 先拿表名/table_id -lark-cli base +field-list --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 +lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 # 第 4 步:顺序创建每个新组件(必须串行执行,不能并发) # 重要:先确定 dashboard_id、组件 name/type 和真实表字段 @@ -115,7 +115,7 @@ lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --blo # 第 4 步:根据用户编辑诉求准备更新 # 如果编辑诉求涉及数据源变更,需要先获取数据源信息 lark-cli base +table-list --base-token xxx # 先拿表名/table_id -lark-cli base +field-list --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 +lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 # 第 5 步:执行更新 # 重要:先读取当前 block 的 name/type/data_config diff --git a/skills/lark-base/references/lark-base-dashboard.md b/skills/lark-base/references/lark-base-dashboard.md index 44aa72796..4faa22aaa 100644 --- a/skills/lark-base/references/lark-base-dashboard.md +++ b/skills/lark-base/references/lark-base-dashboard.md @@ -21,11 +21,6 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成** | 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` | | 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 | -硬规则: - -- 删除并用 `+dashboard-get` 复核 `not_found` 后,用户只回复“确认/好的/收到”视为结束,不要再次创建或保留同名/正式版仪表盘。 -- 用户只说“更新标题”但未给新标题时,可基于原名生成一次新标题;先用 `+dashboard-list` 避开已存在名称,遇同名冲突换名更新,不要创建新仪表盘。 - ## 执行要点 - 创建/改图前先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,不要逐表多次 `+field-list`,多余调用会显著抬高 token。 From 878607456f13b5a140523da5d9b6d25b967ce85e Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Mon, 15 Jun 2026 16:29:03 +0800 Subject: [PATCH 11/17] feat(base): forgive record pagination flags and consolidate dashboard skill docs record pagination: - record-list / record-search accept a hidden --page-size alias for --limit and a hidden --page-token flag that hints back to --offset/--limit, so the im-domain pagination habits agents carry over no longer error out - add recordPageLimit / validateRecordPageLimit and route list/search/dry-run through them; reject passing both --limit and --page-size - cover the alias, page-size-only and page-token paths in helpers_test dashboard skill docs: - rename dashboard-block-data-config.md to lark-base-dashboard-block-data-config.md and update all flag descs, tips, validation errors and tests to the new name - slim lark-base-dashboard.md and split the usecase guide into lark-base-dashboard-write.md; SKILL.md routes dashboard read/write/chart tasks to the right file SKILL.md hints: - mark record-get/list/search as markdown-by-default, add --format json only when piping/parsing - steer multi-field reads to +field-list, reserve +field-get for a single field --- shortcuts/base/base_shortcuts_test.go | 4 +- shortcuts/base/dashboard_block_create.go | 6 +- shortcuts/base/dashboard_block_update.go | 4 +- shortcuts/base/helpers.go | 2 +- shortcuts/base/helpers_test.go | 23 ++ shortcuts/base/record_list.go | 5 + shortcuts/base/record_ops.go | 4 +- shortcuts/base/record_query.go | 29 +- shortcuts/base/record_search.go | 2 + skills/lark-base/SKILL.md | 10 +- ... lark-base-dashboard-block-data-config.md} | 2 +- .../lark-base-dashboard-block-get-data.md | 2 +- .../references/lark-base-dashboard-usecase.md | 238 ---------------- .../references/lark-base-dashboard-write.md | 102 +++++++ .../references/lark-base-dashboard.md | 262 ++---------------- 15 files changed, 206 insertions(+), 489 deletions(-) rename skills/lark-base/references/{dashboard-block-data-config.md => lark-base-dashboard-block-data-config.md} (96%) delete mode 100644 skills/lark-base/references/lark-base-dashboard-usecase.md create mode 100644 skills/lark-base/references/lark-base-dashboard-write.md diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 0bdcecfd3..3bca6e49c 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -458,7 +458,7 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) { `--type text --data-config '{"text":"# Sales Dashboard"}'`, "+table-list and +field-list", "not table_id or field_id", - "dashboard-block-data-config.md as the SSOT", + "lark-base-dashboard-block-data-config.md as the SSOT", "do not invent data_config from natural language", "sequentially", }, @@ -469,7 +469,7 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) { wantTips: []string{ `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --name "Total Sales"`, `--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`, - "dashboard-block-data-config.md as the SSOT", + "lark-base-dashboard-block-data-config.md as the SSOT", "do not invent data_config from natural language", "Block type cannot be changed", "top-level keys", diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index 0cd8d4ed5..98f44ea49 100644 --- a/shortcuts/base/dashboard_block_create.go +++ b/shortcuts/base/dashboard_block_create.go @@ -24,8 +24,8 @@ var BaseDashboardBlockCreate = common.Shortcut{ baseTokenFlag(true), dashboardIDFlag(true), {Name: "name", Desc: "block name", Required: true}, - {Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true}, - {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, + {Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read lark-base-dashboard-block-data-config.md before creating.", Required: true}, + {Name: "data-config", Desc: "data_config JSON object; read lark-base-dashboard-block-data-config.md for the SSOT"}, {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, }, @@ -34,7 +34,7 @@ var BaseDashboardBlockCreate = common.Shortcut{ `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`, "Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.", "data_config uses table and field names, not table_id or field_id.", - "Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", + "Read lark-base-dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", "Record the returned block_id; block update/delete/get-data commands need it.", "Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.", }, diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go index 718d7e856..de02ecf33 100644 --- a/shortcuts/base/dashboard_block_update.go +++ b/shortcuts/base/dashboard_block_update.go @@ -24,14 +24,14 @@ var BaseDashboardBlockUpdate = common.Shortcut{ dashboardIDFlag(true), blockIDFlag(true), {Name: "name", Desc: "new block name"}, - {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, + {Name: "data-config", Desc: "data_config JSON object; read lark-base-dashboard-block-data-config.md for the SSOT"}, {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, }, Tips: []string{ `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --name "Total Sales"`, `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`, - "Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", + "Read lark-base-dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", "Use +dashboard-block-get first to inspect the current data_config before replacing nested values.", "Block type cannot be changed; delete and recreate the block to change chart type.", "data_config update merges top-level keys, but each provided key is replaced as a whole.", diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go index b2dbcc05d..f75dd028f 100644 --- a/shortcuts/base/helpers.go +++ b/shortcuts/base/helpers.go @@ -1178,5 +1178,5 @@ func formatDataConfigErrors(problems []string) error { if len(problems) == 0 { return nil } - return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/lark-base-dashboard-block-data-config.md", strings.Join(problems, "\n- ")) } diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index 6a7b4152f..86833916c 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -555,3 +555,26 @@ func TestFieldSearchOptionsKeywordQueryAlias(t *testing.T) { t.Fatalf("keyword=%q", got) } } + +func TestRecordPageSizeAlias(t *testing.T) { + ctx := context.Background() + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1"}, + nil, + map[string]int{"limit": 20, "page-size": 20}, + )); err == nil || !strings.Contains(err.Error(), "use only one") { + t.Fatalf("err=%v", err) + } + psOnly := newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 7}) + if err := BaseRecordList.Validate(ctx, psOnly); err != nil { + t.Fatalf("err=%v", err) + } + if got := recordPageLimit(psOnly); got != 7 { + t.Fatalf("limit=%d", got) + } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1", "page-token": "tok"}, nil, nil, + )); err == nil || !strings.Contains(err.Error(), "did you mean --offset/--limit") { + t.Fatalf("err=%v", err) + } +} diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index 678851d91..d32a14100 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -32,6 +32,8 @@ var BaseRecordList = common.Shortcut{ {Name: "json", Hidden: true}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, + {Name: "page-size", Type: "int", Default: "0", Desc: "deprecated alias for --limit", Hidden: true}, + {Name: "page-token", Hidden: true}, recordReadFormatFlag(), }, Tips: []string{ @@ -51,6 +53,9 @@ var BaseRecordList = common.Shortcut{ if err := validateRecordReadFormat(runtime); err != nil { return err } + if err := validateRecordPageLimit(runtime); err != nil { + return err + } if strings.TrimSpace(runtime.Str("json")) != "" { return baseFlagErrorf("+record-list does not support --json; use --filter-json for filters and --sort-json for sorting") } diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index ef49b3af9..05173b68e 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -275,7 +275,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common if offset < 0 { offset = 0 } - limit := common.ParseIntBounded(runtime, "limit", 1, 200) + limit := recordPageLimit(runtime) params := url.Values{} params.Set("offset", strconv.Itoa(offset)) params.Set("limit", strconv.Itoa(limit)) @@ -454,7 +454,7 @@ func executeRecordList(runtime *common.RuntimeContext) error { if offset < 0 { offset = 0 } - limit := common.ParseIntBounded(runtime, "limit", 1, 200) + limit := recordPageLimit(runtime) params := map[string]interface{}{"offset": offset, "limit": limit} fields := recordListFields(runtime) if len(fields) > 0 { diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go index edde9af3c..33e707459 100644 --- a/shortcuts/base/record_query.go +++ b/shortcuts/base/record_query.go @@ -78,6 +78,27 @@ func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) return normalizeRecordSortValue(value, "--"+recordSortJSONFlag) } +// recordPageLimit resolves --limit together with the hidden --page-size +// alias. --page-size is the im domain's pagination flag name, which agents +// habitually carry over to base record commands; accepting it here keeps +// pagination naming forgiving across domains. +func recordPageLimit(runtime *common.RuntimeContext) int { + if runtime.Changed("page-size") && !runtime.Changed("limit") { + return common.ParseIntBounded(runtime, "page-size", 1, 200) + } + return common.ParseIntBounded(runtime, "limit", 1, 200) +} + +func validateRecordPageLimit(runtime *common.RuntimeContext) error { + if runtime.Changed("limit") && runtime.Changed("page-size") { + return baseFlagErrorf("--page-size is a deprecated alias for --limit; use only one") + } + if strings.TrimSpace(runtime.Str("page-token")) != "" { + return baseFlagErrorf("this command uses offset pagination, not page tokens; did you mean --offset/--limit? (use --offset for the next page)") + } + return nil +} + func recordQueryFlagValue(runtime *common.RuntimeContext, canonical string, alias string) (string, error) { canonicalRaw := strings.TrimSpace(runtime.Str(canonical)) aliasRaw := strings.TrimSpace(runtime.Str(alias)) @@ -212,7 +233,7 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{ offset = 0 } body["offset"] = offset - body["limit"] = common.ParseIntBounded(runtime, "limit", 1, 200) + body["limit"] = recordPageLimit(runtime) return body, applyRecordQueryToBody(runtime, body) } @@ -243,6 +264,9 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error { if err := validateRecordReadFormat(runtime); err != nil { return err } + if err := validateRecordPageLimit(runtime); err != nil { + return err + } if strings.TrimSpace(runtime.Str("keyword")) != "" && strings.TrimSpace(runtime.Str("query")) != "" { return baseFlagErrorf("--query is a deprecated alias for --keyword; use only one") } @@ -269,7 +293,8 @@ func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool len(recordListFields(runtime)) > 0 || runtime.Str("view-id") != "" || runtime.Changed("offset") || - runtime.Changed("limit") + runtime.Changed("limit") || + runtime.Changed("page-size") } func recordSearchKeyword(runtime *common.RuntimeContext) string { diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index ba2979e6a..2c2561d13 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -34,6 +34,8 @@ var BaseRecordSearch = common.Shortcut{ recordSortAliasFlag(), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"}, + {Name: "page-size", Type: "int", Default: "0", Desc: "deprecated alias for --limit", Hidden: true}, + {Name: "page-token", Hidden: true}, recordReadFormatFlag(), }, Tips: []string{ diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index c1af3882c..1eff55c39 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -72,9 +72,9 @@ metadata: | 查看 Base 内资源目录 | `+base-block-list` | 先判断 Base 里有什么(table/docx/dashboard/workflow/folder),再决定走哪类命令 | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | -| 列/查/删字段 | `+field-list/get/delete/search-options` | 字段发现默认用 `+field-list --compact`;需要 formula/lookup 细节或完整字段 JSON 再用 `+field-get` / 不带 compact 的 list;多表结构用 `+field-list-batch --compact --table-id <表1> --table-id <表2>` 一次取齐,不要逐表调用 | +| 列/查/删字段 | `+field-list/get/delete/search-options` | 字段发现默认用 `+field-list --compact`(一次返回全部);`+field-get` 仅用于深挖单个字段的 formula/lookup 细节或完整 JSON;多表结构用 `+field-list-batch --compact --table-id <表1> --table-id <表2>` 一次取齐,不要逐表调用 | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | -| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | +| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 默认输出 markdown(更紧凑、省 token,适合直接阅读);需要 `--jq` 或程序化解析时显式加 `--format json`。涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | | 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 | | 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 | @@ -85,7 +85,9 @@ metadata: | 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) | | 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) | | 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 | -| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` | +| 仪表盘读操作 | `+dashboard-list/get` / `+dashboard-block-list/get/get-data` | 读 [lark-base-dashboard.md](references/lark-base-dashboard.md) | +| 仪表盘写操作(非图表 data_config) | `+dashboard-create/update/delete/arrange`、删除/重命名 block、text block | 读 [lark-base-dashboard.md](references/lark-base-dashboard.md) + [lark-base-dashboard-write.md](references/lark-base-dashboard-write.md) | +| 仪表盘图表组件创建/更新 | `+dashboard-block-create/update` 且涉及 chart/statistics 的 `data_config` | 一次性读 [lark-base-dashboard.md](references/lark-base-dashboard.md) + [lark-base-dashboard-write.md](references/lark-base-dashboard-write.md) + [lark-base-dashboard-block-data-config.md](references/lark-base-dashboard-block-data-config.md) | | Workflow | `+workflow-*` | 先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md):它包含查询/启停/创建/修改的最短路径和常见 step 组合;只有创建/更新复杂 steps 时才继续读 schema 小文件;list/get/enable/disable 不读 schema | | 高级权限与角色 | `+advperm-*` / `+role-*` | 先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md)(含安全边界);权限 JSON 再读 [role-config.md](references/role-config.md) | @@ -139,7 +141,7 @@ done ### Dashboard / Workflow / Role -- Dashboard 的复杂点是 block 的 `data_config`:创建/更新 block 前读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件串行创建;布局/换图表类型/删除具名图表等操作要点见 [lark-base-dashboard.md](references/lark-base-dashboard.md) 的「执行要点」。`+dashboard-block-get-data` 只返回图表数据,元数据用 `+dashboard-block-get`。 +- Dashboard 的复杂点是 block 的 `data_config`:创建/更新 block 前读 [lark-base-dashboard-block-data-config.md](references/lark-base-dashboard-block-data-config.md),组件串行创建;布局/换图表类型/删除具名图表等操作要点见 [lark-base-dashboard.md](references/lark-base-dashboard.md) 的「执行要点」。`+dashboard-block-get-data` 只返回图表数据,元数据用 `+dashboard-block-get`。 - Workflow 的复杂点是 `steps`:先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),用其中的最短路径和场景表完成查询/启停/常见创建修改;需要具体 step 字段再按需读 schema 小文件;创建后 `+workflow-get` 回读验证。 - Role 的复杂点是权限 JSON:先读 [lark-base-role-guide.md](references/lark-base-role-guide.md)(含安全边界),权限 JSON SSOT 读 [role-config.md](references/role-config.md);删除角色、关闭高级权限前确认目标和影响。 diff --git a/skills/lark-base/references/dashboard-block-data-config.md b/skills/lark-base/references/lark-base-dashboard-block-data-config.md similarity index 96% rename from skills/lark-base/references/dashboard-block-data-config.md rename to skills/lark-base/references/lark-base-dashboard-block-data-config.md index ece680358..db060cd65 100644 --- a/skills/lark-base/references/dashboard-block-data-config.md +++ b/skills/lark-base/references/lark-base-dashboard-block-data-config.md @@ -42,7 +42,7 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty |------|------|------| | `table_name` | string | 关联数据表名称 | | `series` | `[{ "field_name": "xxx", "rollup": "SUM" }]` | 指标/Y 轴(与 `count_all` 二选一)。rollup 支持 `SUM` / `MAX` / `MIN` / `AVERAGE` | -| `count_all` | boolean | COUNTA 聚合,统计所有记录数(与 `series` 二选一) | +| `count_all` | boolean | COUNTA 聚合,统计所有**记录条数**(与 `series` 二选一)。注意:用户指标是"数量/人数/金额"且表中存在对应数量语义字段(如「访客人数」「销售额」,一条记录可能代表多个计数单位)时,必须用 `series` + `SUM(该字段)`,不要用 `count_all` 数行数;同一仪表盘内同口径指标统计方式必须一致 | | `group_by` | `[{ "field_name": "xxx", "mode": "integrated", "sort": {...} }]` | X 轴分组维度。`mode` 必填,`sort` 可选,见下方说明 | | `filter` | object | 筛选条件 | | `filter.conjunction` | `"and"` / `"or"` | 筛选逻辑 | diff --git a/skills/lark-base/references/lark-base-dashboard-block-get-data.md b/skills/lark-base/references/lark-base-dashboard-block-get-data.md index 8470323f8..68da77851 100644 --- a/skills/lark-base/references/lark-base-dashboard-block-get-data.md +++ b/skills/lark-base/references/lark-base-dashboard-block-get-data.md @@ -714,4 +714,4 @@ GET /open-apis/base/v3/bases/bascn_example_token/dashboards/blocks/chtxxxxxxxx/d - [lark-base-dashboard.md](lark-base-dashboard.md) — dashboard 模块总指引 - `+dashboard-block-get` — 获取 block 元数据 -- [dashboard-block-data-config.md](dashboard-block-data-config.md) — data_config 结构和组件类型说明 +- [lark-base-dashboard-block-data-config.md](lark-base-dashboard-block-data-config.md) — data_config 结构和组件类型说明 diff --git a/skills/lark-base/references/lark-base-dashboard-usecase.md b/skills/lark-base/references/lark-base-dashboard-usecase.md deleted file mode 100644 index d78822bae..000000000 --- a/skills/lark-base/references/lark-base-dashboard-usecase.md +++ /dev/null @@ -1,238 +0,0 @@ -# Dashboard(仪表盘/数据看板)模块指引 - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**组件**(图表、指标卡等)进行展示。 - -## 核心概念 - -- **Dashboard(仪表盘)**:容器,包含多个组件 -- **Block(组件)**:仪表盘中的单个可视化元素(柱状图、折线图、饼图、指标卡等) -- **data_config**:组件的数据源配置(表名、字段、分组等) - -## 能力速览 - -| 你想做什么 | 用这些命令 | 关键文档 | -|------|-----------|---------| -| 创建/删除/改名称 | `+dashboard-create/delete/update` | 本页下方「仪表盘管理」 | -| 在仪表盘里添加组件 | `+dashboard-block-create` | 先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 批量拿字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` | -| 修改组件 | `+dashboard-block-update` | 先读 block 现状,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 决定替换哪些顶层 key | -| 查看仪表盘有哪些组件 | `+dashboard-get` 或 `+dashboard-block-list` | 本页下方「查看仪表盘」 | -| 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` | -| 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 | - -## 典型场景工作流 - -### 场景 1:从 0 到 1 创建仪表盘 - -示例:搭建一个销售数据分析仪表盘 - -```bash -# 第 1 步:创建空白仪表盘 -lark-cli base +dashboard-create --base-token xxx --name "销售数据分析" -# 记录返回的 dashboard_id - -# 第 2 步:获取数据源信息 -lark-cli base +table-list --base-token xxx # 先拿表名/table_id -lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 - -# 第 3 步:规划应该创建哪些组件(根据用户需求确定组件类型和数量) -# 例如:总销售额(指标卡)、月度趋势(折线图)、品类占比(饼图) - -# 第 4 步:顺序创建每个组件(必须串行执行,不能并发) -# 重要:创建组件前,先确定 dashboard_id、组件 name/type 和真实表字段 -# 再阅读 dashboard-block-data-config.md 了解 data_config 结构、组件类型和 filter 规则 - -# 第 1 个组件 -lark-cli base +dashboard-block-create \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --name "总销售额" \ - --type statistics \ - --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}]}' - -# 第 2 个组件(等上一个完成后再执行) -lark-cli base +dashboard-block-create \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --name "月度趋势" \ - --type line \ - --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated"}]}' - -# 继续创建其他组件... - -# 第 5 步:组件创建完成后,使用 arrange 命令智能重排布局(可选但推荐) -# 默认布局可能不够美观,arrange 会根据组件数量和类型自动优化布局 -lark-cli base +dashboard-arrange \ - --base-token xxx \ - --dashboard-id blk_xxx -``` - -### 场景 2:在已有仪表盘上添加新组件 - -```bash -# 第 1 步:列出仪表盘,定位到当前仪表盘 -lark-cli base +dashboard-list --base-token xxx -# 获取目标 dashboard_id - -# 第 2 步:根据用户诉求规划组件类型和数据源 -# 建议先查看当前仪表盘已有组件,避免重复创建,或作为参考 -lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx - -# 第 3 步:获取数据源信息 -lark-cli base +table-list --base-token xxx # 先拿表名/table_id -lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 - -# 第 4 步:顺序创建每个新组件(必须串行执行,不能并发) -# 重要:先确定 dashboard_id、组件 name/type 和真实表字段 -# 再阅读 dashboard-block-data-config.md 了解 data_config 结构 -lark-cli base +dashboard-block-create \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --name "新组件名" \ - --type column \ - --data-config '{...}' -``` - -### 场景 3:编辑已有组件 - -> [!IMPORTANT] -> `+dashboard-block-update` **不能修改组件的 `type`**(图表类型),只能更新 `name` 和 `data_config`。 -> 如需更换组件类型,必须先删除再重新创建。 - -```bash -# 第 1 步:列出仪表盘,定位到当前仪表盘 -lark-cli base +dashboard-list --base-token xxx - -# 第 2 步:列出组件,获取到目标组件 -lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx -# 获取目标 block_id -# 提示:查看已有组件可作为参考,或检查是否重复创建相似组件 - -# 第 3 步:获取组件当前详情 -lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx - -# 第 4 步:根据用户编辑诉求准备更新 -# 如果编辑诉求涉及数据源变更,需要先获取数据源信息 -lark-cli base +table-list --base-token xxx # 先拿表名/table_id -lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段 - -# 第 5 步:执行更新 -# 重要:先读取当前 block 的 name/type/data_config -# 再阅读 dashboard-block-data-config.md 了解 data_config 更新规则 -lark-cli base +dashboard-block-update \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --block-id chtxxxxxxxx \ - --data-config '{...}' -``` - -### 场景 4:重排仪表盘布局 - -当用户明确要求对已有仪表盘进行布局重排或美化时使用。 - -> [!CAUTION] -> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期 -> - 无法指定具体位置(如"第一排放 A,第二排放 B"),排列逻辑是**自适应**的 -> - **不建议**在已有仪表盘上自动调用,除非用户明确要求 - -```bash -# 第 1 步:列出仪表盘,定位到目标仪表盘 -lark-cli base +dashboard-list --base-token xxx - -# 第 2 步:执行智能重排 -lark-cli base +dashboard-arrange \ - --base-token xxx \ - --dashboard-id blk_xxx -``` - -### 场景 5:读取仪表盘或组件现状 - -**选择查询方式:** -- 想看仪表盘整体结构(含主题、所有组件名称和类型)→ 用 **方式 A** -- 只想快速查看有哪些组件 → 用 **方式 B** -- 想看某个组件的详细 data_config 配置 → 用 **方式 C** -- 想看某个图表/指标卡实际算出来的数据 → 用 **方式 D** - -```bash -# 第 1 步:列出仪表盘,定位到当前仪表盘 -lark-cli base +dashboard-list --base-token xxx - -# 第 2 步:根据用户诉求查看详情 - -# 方式 A:查看仪表盘整体情况(包含所有组件列表) -lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx - -# 方式 B:列出所有组件 -lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx - -# 方式 C:查看某个组件的详细配置 -lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx - -# 方式 D:查看某个图表组件的计算结果(AI 友好的 chart protocol) -lark-cli base +dashboard-block-get-data --base-token xxx --block-id chtxxxxxxxx - -# 最后:把获取到的现状信息整理好告诉用户 -``` - -## 组件类型选择 - -组件 `type` 决定展示形式: - -| 用户想看什么 | 选什么 type | 说明 | -|-------------|------------|------| -| 数据趋势(时间变化) | line | 折线图组件 | -| 类别比较(谁高谁低) | column | 柱状图组件 | -| 占比分布(各部分比例) | pie | 饼图组件 | -| 单个关键指标 | statistics | 指标卡组件 | -| 富文本说明/标题/注释 | text | 文本组件(支持 Markdown) | - -详细组件类型和 data_config 完整规则:[dashboard-block-data-config.md](dashboard-block-data-config.md) - -## 常见问题 - -**Q: 创建组件的命令和 data_config 怎么写?** -A: -1. 先确定 `dashboard_id`、组件 `name`、组件 `type` 和真实表字段 -2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解: - - 全部组件类型的可复制模板 - - filter 筛选条件格式 - - 字段类型与操作符对应表 - -**Q: 为什么组件创建失败了?** -A: 常见原因: -- `table_name` 用了 table_id 而不是表名(必须用表名称,如「订单表」) -- `series` 和 `count_all` 同时存在(必须二选一,互斥) -- 字段名拼写错误(必须用 `+field-list` 获取的真实字段名,禁止猜测) -- 组件创建并发执行(必须串行,等上一个完成再执行下一个) - -**Q: 可以一次创建多个组件吗?** -A: 不可以,必须串行执行。等上一个 `+dashboard-block-create` 完成后再执行下一个。 - -**Q: 组件的 `type` 创建后能改吗?** -A: 不能。`+dashboard-block-update` 只能修改 `name` 和 `data_config`,不能修改 `type`。 - -**Q: 更新组件的命令和 data_config 怎么写?** -A: -1. 先读取当前 block,确认 `block_id`、当前 `type` 和已有 `data_config` -2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解 data_config 结构 - -**data_config 更新策略(顶层 key merge)**: -- 只传入需要修改的顶层字段(如 `series`、`filter`) -- 未传的顶层字段(如 `group_by`)自动保留原值 -- 但每个传入的字段内部是**全量替换**(如传新 `filter` 会完整覆盖旧 `filter`) - -**Q: 查看已有组件有什么用?** -A: 在「添加新组件」或「编辑组件」前查看已有组件可以: -- 了解当前仪表盘已有哪些可视化 -- 避免重复创建相似的组件 -- 参考已有组件的 data_config 结构作为模板 - -**Q: 我想直接拿图表算好的结果给 AI 分析,应该用什么?** -A: 用 `+dashboard-block-get-data`。它返回图表协议 JSON(常见字段包括 `dimensions`、`measures`、`main_data`,指标卡可能还有 `comparison_data`、`trend_data`),不返回 block 名称、类型、布局或 `data_config`;需要这些元数据时先用 `+dashboard-block-get`。 - -## 写入前检查 - -- 创建 block 前必须知道 `base_token`、`dashboard_id`、组件 `name/type` 和 `data_config`。 -- 更新 block 前必须知道 `base_token`、`dashboard_id`、`block_id`,并读过当前 block。 -- `data_config` 中使用表名和字段名,不使用 table_id / field_id;名称必须来自 `+table-list` / `+field-list` 的真实返回。 diff --git a/skills/lark-base/references/lark-base-dashboard-write.md b/skills/lark-base/references/lark-base-dashboard-write.md new file mode 100644 index 000000000..5e93c8881 --- /dev/null +++ b/skills/lark-base/references/lark-base-dashboard-write.md @@ -0,0 +1,102 @@ +# Dashboard 写操作指引 + +本文档只管 dashboard / dashboard block 写操作流程。图表组件的 `data_config` 结构、模板和筛选规则见 [lark-base-dashboard-block-data-config.md](lark-base-dashboard-block-data-config.md)。 + +## 写操作速查 + +| 用户目标 | 命令 | 是否读 data_config SSOT | +|---|---|---| +| 创建/重命名/删除仪表盘 | `+dashboard-create/update/delete` | 否 | +| 添加 chart/statistics 组件 | `+dashboard-block-create` | 是 | +| 更新 chart/statistics 的数据配置 | `+dashboard-block-update` | 是 | +| 添加/更新 text 组件 | `+dashboard-block-create/update --type text` | 否,`data_config` 只传 `{"text":"..."}` | +| 删除组件 | `+dashboard-block-delete` | 否 | +| 重排/美化布局 | `+dashboard-arrange` | 否 | + +## 写前定位 + +- 写操作前先定位真实 `base_token`、`dashboard_id`、表名/字段名;不要凭用户口述猜。 +- 创建/改图前先 `+table-list` 拿表,再用 `+field-list-batch --compact --table-id <表1> --table-id <表2>` 一次取相关表字段,不要逐表调用。 +- 更新组件前先 `+dashboard-block-get` 读取当前 block 的 `name/type/data_config`,只改目标字段。 + +## 创建仪表盘并添加多个组件 + +```bash +# 1. 创建空白仪表盘 +lark-cli base +dashboard-create --base-token xxx --name "销售数据分析" +# 记录 dashboard_id + +# 2. 获取数据源结构 +lark-cli base +table-list --base-token xxx +lark-cli base +field-list-batch --base-token xxx --compact --table-id tbl_a --table-id tbl_b + +# 3. 规划组件(根据用户需求确定组件类型和数量) +# 例如:总销售额(statistics)、月度趋势(line)、品类占比(pie) + +# 4. 串行创建每个组件;chart/statistics 的 data_config 先读 lark-base-dashboard-block-data-config.md +lark-cli base +dashboard-block-create \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --name "总销售额" \ + --type statistics \ + --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}]}' + +# 第 2 个组件(等上一个完成后再执行) +lark-cli base +dashboard-block-create \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --name "月度趋势" \ + --type line \ + --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated"}]}' + +# 5. 需要美化布局时再执行 arrange +lark-cli base +dashboard-arrange --base-token xxx --dashboard-id blk_xxx +``` + +## 在已有仪表盘添加组件 + +```bash +# 1. 定位 dashboard 并查看现状,避免重复创建;已有组件也可作为 data_config 参考 +lark-cli base +dashboard-list --base-token xxx +lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx + +# 2. 获取相关表字段 +lark-cli base +table-list --base-token xxx +lark-cli base +field-list-batch --base-token xxx --compact --table-id tbl_a --table-id tbl_b + +# 3. 串行创建组件 +lark-cli base +dashboard-block-create \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --name "新组件名" \ + --type column \ + --data-config '{...}' +``` + +## 编辑已有组件 + +- `+dashboard-block-update` **不能修改组件 `type`**;如需换图表类型,删除旧 block 后用 `+dashboard-block-create` 新建。 +- block 换数据源表(`table_name`)时,通常也应删除旧 block 后新建,避免旧字段上下文残留。 +- `+dashboard-block-update` 适合同一数据源内改 `name` / `series` / `filter` / `group_by` 等顶层字段;未传顶层字段会保留,传入字段内部是全量替换。 + +```bash +# 先列出组件,精确定位目标 block;查看已有组件可避免重复创建,也可参考 data_config 结构 +lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx +lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id cht_xxx +lark-cli base +dashboard-block-update \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --block-id cht_xxx \ + --data-config '{...}' +``` + +## 删除、重排和 text 组件 + +- 删除具名图表:`+dashboard-list` → `+dashboard-block-list` 精确匹配名称 → `+dashboard-block-delete`;长 `block_id` 用变量传参,避免手抄截断。 +- 布局/重排/撑满/排列美观:直接用 `+dashboard-arrange`;不要尝试用 `+dashboard-block-update` 修改 layout,layout 不是 `data_config`。`+dashboard-arrange` 是服务端智能布局,无法指定具体位置(如第一排放 A、第二排放 B);不建议在已有仪表盘上自动调用,除非用户明确要求。 +- text 组件:如果只是新增/更新说明文案,不需要读取 table/field;确认 dashboard/block 后直接写 `data_config={"text":"..."}`。 + +## 写后验证 + +- 创建/更新后用 `+dashboard-block-get` 回读元数据;需要验证图表计算结果时再用 `+dashboard-block-get-data`。 +- 多组件创建要串行执行;不要并发创建 dashboard block。 diff --git a/skills/lark-base/references/lark-base-dashboard.md b/skills/lark-base/references/lark-base-dashboard.md index 4faa22aaa..d0e1c5dc6 100644 --- a/skills/lark-base/references/lark-base-dashboard.md +++ b/skills/lark-base/references/lark-base-dashboard.md @@ -1,246 +1,42 @@ # Dashboard(仪表盘/数据看板)模块指引 -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**组件**(图表、指标卡等)进行展示。 +Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成组件(chart/block)展示。 ## 核心概念 -- **Dashboard(仪表盘)**:容器,包含多个组件 -- **Block(组件)**:仪表盘中的单个可视化元素(柱状图、折线图、饼图、指标卡等) -- **data_config**:组件的数据源配置(表名、字段、分组等) - -## 能力速览 - -| 你想做什么 | 用这些命令 | 关键文档 | -|------|-----------|---------| -| 创建/删除/改名称 | `+dashboard-create/delete/update` | 本页下方「仪表盘管理」 | -| 在仪表盘里添加组件 | `+dashboard-block-create` | 先定位 dashboard、表和字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` | -| 修改组件 | `+dashboard-block-update` | 先读 block 现状,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 决定替换哪些顶层 key | -| 查看仪表盘有哪些组件 | `+dashboard-get` 或 `+dashboard-block-list` | 本页下方「查看仪表盘」 | -| 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` | -| 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 | - -## 执行要点 - -- 创建/改图前先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,不要逐表多次 `+field-list`,多余调用会显著抬高 token。 -- 布局/重排/撑满/排列美观直接用 `+dashboard-arrange`,不要尝试用 `+dashboard-block-update` 修改 layout,layout 不是 `data_config`。 -- block 换图表类型或换数据源表(`table_name`)时,删除旧 block 后用 `+dashboard-block-create` 新建;`+dashboard-block-update` 只适合同一数据源内改 `series/filter/group_by/name`。 -- 删除具名图表:`+dashboard-list` → `+dashboard-block-list` 精确匹配名称 → `+dashboard-block-delete`;长 `block_id` 用变量传参,避免手抄截断。 -- 完整 dashboard 用例(从需求到逐组件落地)按需读 [lark-base-dashboard-usecase.md](lark-base-dashboard-usecase.md)。 - -## 典型场景工作流 - -### 场景 1:从 0 到 1 创建仪表盘 - -示例:搭建一个销售数据分析仪表盘 - -```bash -# 第 1 步:创建空白仪表盘 -lark-cli base +dashboard-create --base-token xxx --name "销售数据分析" -# 记录返回的 dashboard_id - -# 第 2 步:获取数据源信息 -lark-cli base +table-list --base-token xxx -lark-cli base +field-list --base-token xxx --table-id - -# 第 3 步:规划应该创建哪些组件(根据用户需求确定组件类型和数量) -# 例如:总销售额(指标卡)、月度趋势(折线图)、品类占比(饼图) - -# 第 4 步:顺序创建每个组件(必须串行执行,不能并发) -# 重要:创建组件前,先确定 dashboard_id、组件 name/type 和真实表字段 -# 再阅读 dashboard-block-data-config.md 了解 data_config 结构、组件类型和 filter 规则 - -# 第 1 个组件 -lark-cli base +dashboard-block-create \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --name "总销售额" \ - --type statistics \ - --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}]}' - -# 第 2 个组件(等上一个完成后再执行) -lark-cli base +dashboard-block-create \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --name "月度趋势" \ - --type line \ - --data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated"}]}' - -# 继续创建其他组件... - -# 第 5 步:组件创建完成后,使用 arrange 命令智能重排布局(可选但推荐) -# 默认布局可能不够美观,arrange 会根据组件数量和类型自动优化布局 -lark-cli base +dashboard-arrange \ - --base-token xxx \ - --dashboard-id blk_xxx -``` - -### 场景 2:在已有仪表盘上添加新组件 - -```bash -# 第 1 步:列出仪表盘,定位到当前仪表盘 -lark-cli base +dashboard-list --base-token xxx -# 获取目标 dashboard_id - -# 第 2 步:根据用户诉求规划组件类型和数据源 -# 建议先查看当前仪表盘已有组件,避免重复创建,或作为参考 -lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx - -# 第 3 步:获取数据源信息 -lark-cli base +table-list --base-token xxx -lark-cli base +field-list --base-token xxx --table-id - -# 第 4 步:顺序创建每个新组件(必须串行执行,不能并发) -# 重要:先确定 dashboard_id、组件 name/type 和真实表字段 -# 再阅读 dashboard-block-data-config.md 了解 data_config 结构 -lark-cli base +dashboard-block-create \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --name "新组件名" \ - --type column \ - --data-config '{...}' -``` - -### 场景 3:编辑已有组件 - -> [!IMPORTANT] -> `+dashboard-block-update` **不能修改组件的 `type`**(图表类型),只能更新 `name` 和 `data_config`。 -> 如需更换组件类型,必须先删除再重新创建。 - -```bash -# 第 1 步:列出仪表盘,定位到当前仪表盘 -lark-cli base +dashboard-list --base-token xxx - -# 第 2 步:列出组件,获取到目标组件 -lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx -# 获取目标 block_id -# 提示:查看已有组件可作为参考,或检查是否重复创建相似组件 - -# 第 3 步:获取组件当前详情 -lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx - -# 第 4 步:根据用户编辑诉求准备更新 -# 如果编辑诉求涉及数据源变更,需要先获取数据源信息 -lark-cli base +table-list --base-token xxx -lark-cli base +field-list --base-token xxx --table-id - -# 第 5 步:执行更新 -# 重要:先读取当前 block 的 name/type/data_config -# 再阅读 dashboard-block-data-config.md 了解 data_config 更新规则 -lark-cli base +dashboard-block-update \ - --base-token xxx \ - --dashboard-id blk_xxx \ - --block-id chtxxxxxxxx \ - --data-config '{...}' -``` - -### 场景 4:重排仪表盘布局 - -当用户明确要求对已有仪表盘进行布局重排或美化时使用。 - -> [!CAUTION] -> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期 -> - 无法指定具体位置(如"第一排放 A,第二排放 B"),排列逻辑是**自适应**的 -> - **不建议**在已有仪表盘上自动调用,除非用户明确要求 - -```bash -# 第 1 步:列出仪表盘,定位到目标仪表盘 -lark-cli base +dashboard-list --base-token xxx - -# 第 2 步:执行智能重排 -lark-cli base +dashboard-arrange \ - --base-token xxx \ - --dashboard-id blk_xxx -``` - -### 场景 5:读取仪表盘或组件现状 - -**选择查询方式:** -- 想看仪表盘整体结构(含主题、所有组件名称和类型)→ 用 **方式 A** -- 只想快速查看有哪些组件 → 用 **方式 B** -- 想看某个组件的详细 data_config 配置 → 用 **方式 C** -- 想看某个图表/指标卡实际算出来的数据 → 用 **方式 D** - -```bash -# 第 1 步:列出仪表盘,定位到当前仪表盘 -lark-cli base +dashboard-list --base-token xxx - -# 第 2 步:根据用户诉求查看详情 - -# 方式 A:查看仪表盘整体情况(包含所有组件列表) -lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx - -# 方式 B:列出所有组件 -lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx - -# 方式 C:查看某个组件的详细配置 -lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx - -# 方式 D:查看某个图表组件的计算结果(AI 友好的 chart protocol) -lark-cli base +dashboard-block-get-data --base-token xxx --block-id chtxxxxxxxx - -# 最后:把获取到的现状信息整理好告诉用户 -``` - -## 组件类型选择 - -组件 `type` 决定展示形式: - -| 用户想看什么 | 选什么 type | 说明 | -|-------------|------------|------| -| 数据趋势(时间变化) | line | 折线图组件 | -| 类别比较(谁高谁低) | column | 柱状图组件 | -| 占比分布(各部分比例) | pie | 饼图组件 | -| 单个关键指标 | statistics | 指标卡组件 | -| 富文本说明/标题/注释 | text | 文本组件(支持 Markdown) | - -详细组件类型和 data_config 完整规则:[dashboard-block-data-config.md](dashboard-block-data-config.md) - -## 常见问题 - -**Q: 创建组件的命令和 data_config 怎么写?** -A: -1. 先确定 `dashboard_id`、组件 `name`、组件 `type` 和真实表字段 -2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解: - - 全部组件类型的可复制模板 - - filter 筛选条件格式 - - 字段类型与操作符对应表 - -**Q: 为什么组件创建失败了?** -A: 常见原因: -- `table_name` 用了 table_id 而不是表名(必须用表名称,如「订单表」) -- `series` 和 `count_all` 同时存在(必须二选一,互斥) -- 字段名拼写错误(必须用 `+field-list` 获取的真实字段名,禁止猜测) -- 组件创建并发执行(必须串行,等上一个完成再执行下一个) +- **Dashboard(仪表盘)**:可视化容器,ID 通常为 `blk...`,属于 Base 资源目录里的 block。 +- **Dashboard block / Chart(组件/图表)**:仪表盘内的单个可视化组件,`block_id` 通常为 `cht...`。 +- **data_config**:图表组件的数据源和统计配置;只有创建/更新 chart/statistics 组件时才需要读 [lark-base-dashboard-block-data-config.md](lark-base-dashboard-block-data-config.md)。 -**Q: 可以一次创建多个组件吗?** -A: 不可以,必须串行执行。等上一个 `+dashboard-block-create` 完成后再执行下一个。 +## 读操作命令速查 -**Q: 组件的 `type` 创建后能改吗?** -A: 不能。`+dashboard-block-update` 只能修改 `name` 和 `data_config`,不能修改 `type`。 +| 用户目标 | 命令 | 说明 | +|---|---|---| +| 列出仪表盘 | `+dashboard-list` | 定位 dashboard_id;需要更全资源目录时可用 `+base-block-list` | +| 查看仪表盘整体 | `+dashboard-get` | 返回 dashboard 元数据和组件列表 | +| 列出组件 | `+dashboard-block-list` | 只要组件清单时优先用它 | +| 查看组件元数据 | `+dashboard-block-get` | 返回组件 name/type/data_config/layout 等 | +| 读取图表最终数据 | `+dashboard-block-get-data` | 返回图表计算结果;不需要 `--dashboard-id`,也不返回 name/type/data_config | -**Q: 更新组件的命令和 data_config 怎么写?** -A: -1. 先读取当前 block,确认 `block_id`、当前 `type` 和已有 `data_config` -2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解 data_config 结构 +## 读取路径 -**data_config 更新策略(顶层 key merge)**: -- 只传入需要修改的顶层字段(如 `series`、`filter`) -- 未传的顶层字段(如 `group_by`)自动保留原值 -- 但每个传入的字段内部是**全量替换**(如传新 `filter` 会完整覆盖旧 `filter`) +1. 先 `+dashboard-list` 或从 URL `table=blk...` 定位 dashboard。 +2. 看整体结构用 `+dashboard-get`;只看组件清单用 `+dashboard-block-list`。 +3. 要解释某个组件配置时用 `+dashboard-block-get`;要分析图表算出的数据时用 `+dashboard-block-get-data`。 +4. text block 没有图表计算结果,不要调用 `+dashboard-block-get-data`。 -**Q: 查看已有组件有什么用?** -A: 在「添加新组件」或「编辑组件」前查看已有组件可以: -- 了解当前仪表盘已有哪些可视化 -- 避免重复创建相似的组件 -- 参考已有组件的 data_config 结构作为模板 +## 组件类型速查 -**Q: 我想直接拿图表算好的结果给 AI 分析,应该用什么?** -A: 用 `+dashboard-block-get-data`。它返回图表协议 JSON(常见字段包括 `dimensions`、`measures`、`main_data`,指标卡可能还有 `comparison_data`、`trend_data`),不返回 block 名称、类型、布局或 `data_config`;需要这些元数据时先用 `+dashboard-block-get`。 +| 用户想看什么 | type | 说明 | +|---|---|---| +| 数据趋势(时间变化) | `line` | 折线图 | +| 类别比较(谁高谁低) | `column` | 柱状图 | +| 占比分布 | `pie` | 饼图 | +| 单个关键指标 | `statistics` | 指标卡 | +| 富文本说明/标题/注释 | `text` | 文本组件,通常不需要 table/field/data_config 复杂模板 | -## 写入前检查 +## 注意 -- 创建 block 前必须知道 `base_token`、`dashboard_id`、组件 `name/type` 和 `data_config`。 -- 更新 block 前必须知道 `base_token`、`dashboard_id`、`block_id`,并读过当前 block。 -- `data_config` 中使用表名和字段名,不使用 table_id / field_id;名称必须来自 `+table-list` / `+field-list` 的真实返回。 +- dashboard 的 ID 是 Base 资源目录层的 `blk...`;dashboard 内组件的 `block_id` 通常是 `cht...`,不要混用。 +- `+dashboard-block-get-data` 只适合 chart/statistics 等有计算结果的组件;需要元数据先用 `+dashboard-block-get`。 +- 创建/更新/删除/重排等写操作读 [lark-base-dashboard-write.md](lark-base-dashboard-write.md)。 From 2754e84914fb59bd4af9fb63d6a95817e6844ddf Mon Sep 17 00:00:00 2001 From: "zhouyue.z" Date: Mon, 15 Jun 2026 17:28:46 +0800 Subject: [PATCH 12/17] feat(base): accept /base and /wiki URLs as --base-token, with token-overhead and validation fixes base-token resolution: - --base-token now accepts a raw base token, a /base/ URL, or a /wiki/ URL; wiki URLs resolve to the underlying bitable via wiki get_node, gated by a conditional wiki:node:retrieve scope on the base commands - resolution is memoized per command, so a wiki URL triggers at most one get_node call however many times the token is referenced - baseV3 calls surface a resolution failure (e.g. a wiki URL that is not a bitable) with a clear error before issuing the request - base-get tip updated to describe the accepted token/URL forms output token overhead: - +field-list defaults to full field objects again; compact projection is now opt-in via --compact, and the multi-table total sums per-table field counts - +workflow-list gains --compact for lower-context workflow_id/title/status output validation: - record query rejects passing a canonical flag and its deprecated alias together (--filter-json / --sort-json), matching the --limit/--page-size behavior --- shortcuts/base/base_advperm_disable.go | 19 +++--- shortcuts/base/base_advperm_enable.go | 19 +++--- shortcuts/base/base_block_create.go | 13 ++-- shortcuts/base/base_block_delete.go | 13 ++-- shortcuts/base/base_block_list.go | 13 ++-- shortcuts/base/base_block_move.go | 13 ++-- shortcuts/base/base_block_ops.go | 20 +++--- shortcuts/base/base_block_rename.go | 13 ++-- shortcuts/base/base_command_common.go | 2 +- shortcuts/base/base_copy.go | 15 ++--- shortcuts/base/base_data_query.go | 17 ++--- shortcuts/base/base_form_create.go | 19 +++--- shortcuts/base/base_form_delete.go | 19 +++--- shortcuts/base/base_form_get.go | 19 +++--- shortcuts/base/base_form_list.go | 19 +++--- shortcuts/base/base_form_questions_create.go | 19 +++--- shortcuts/base/base_form_questions_delete.go | 19 +++--- shortcuts/base/base_form_questions_list.go | 19 +++--- shortcuts/base/base_form_questions_update.go | 19 +++--- shortcuts/base/base_form_submit.go | 21 ++++--- shortcuts/base/base_form_update.go | 19 +++--- shortcuts/base/base_get.go | 17 ++--- shortcuts/base/base_ops.go | 8 +-- shortcuts/base/base_role_create.go | 19 +++--- shortcuts/base/base_role_delete.go | 19 +++--- shortcuts/base/base_role_get.go | 21 ++++--- shortcuts/base/base_role_list.go | 21 ++++--- shortcuts/base/base_role_update.go | 19 +++--- shortcuts/base/base_shortcut_helpers.go | 66 ++++++++++++++++++++ shortcuts/base/dashboard_arrange.go | 15 ++--- shortcuts/base/dashboard_block_create.go | 17 ++--- shortcuts/base/dashboard_block_delete.go | 17 ++--- shortcuts/base/dashboard_block_get.go | 17 ++--- shortcuts/base/dashboard_block_get_data.go | 15 ++--- shortcuts/base/dashboard_block_list.go | 17 ++--- shortcuts/base/dashboard_block_update.go | 17 ++--- shortcuts/base/dashboard_create.go | 17 ++--- shortcuts/base/dashboard_delete.go | 17 ++--- shortcuts/base/dashboard_get.go | 17 ++--- shortcuts/base/dashboard_list.go | 17 ++--- shortcuts/base/dashboard_ops.go | 28 ++++----- shortcuts/base/dashboard_update.go | 17 ++--- shortcuts/base/field_create.go | 13 ++-- shortcuts/base/field_delete.go | 15 ++--- shortcuts/base/field_get.go | 15 ++--- shortcuts/base/field_list.go | 13 ++-- shortcuts/base/field_ops.go | 32 +++++----- shortcuts/base/field_search_options.go | 13 ++-- shortcuts/base/field_update.go | 13 ++-- shortcuts/base/helpers.go | 8 +++ shortcuts/base/record_batch_create.go | 13 ++-- shortcuts/base/record_batch_update.go | 13 ++-- shortcuts/base/record_delete.go | 13 ++-- shortcuts/base/record_get.go | 13 ++-- shortcuts/base/record_history_list.go | 15 ++--- shortcuts/base/record_list.go | 13 ++-- shortcuts/base/record_ops.go | 36 +++++------ shortcuts/base/record_search.go | 13 ++-- shortcuts/base/record_share_link_create.go | 13 ++-- shortcuts/base/record_upload_attachment.go | 61 +++++++++--------- shortcuts/base/record_upsert.go | 13 ++-- shortcuts/base/table_create.go | 13 ++-- shortcuts/base/table_delete.go | 15 ++--- shortcuts/base/table_get.go | 15 ++--- shortcuts/base/table_list.go | 13 ++-- shortcuts/base/table_ops.go | 20 +++--- shortcuts/base/table_update.go | 13 ++-- shortcuts/base/view_create.go | 13 ++-- shortcuts/base/view_delete.go | 15 ++--- shortcuts/base/view_get.go | 17 ++--- shortcuts/base/view_get_card.go | 17 ++--- shortcuts/base/view_get_filter.go | 17 ++--- shortcuts/base/view_get_group.go | 17 ++--- shortcuts/base/view_get_sort.go | 17 ++--- shortcuts/base/view_get_timebar.go | 17 ++--- shortcuts/base/view_get_visible_fields.go | 17 ++--- shortcuts/base/view_list.go | 13 ++-- shortcuts/base/view_ops.go | 20 +++--- shortcuts/base/view_rename.go | 13 ++-- shortcuts/base/view_set_card.go | 13 ++-- shortcuts/base/view_set_filter.go | 13 ++-- shortcuts/base/view_set_group.go | 13 ++-- shortcuts/base/view_set_sort.go | 13 ++-- shortcuts/base/view_set_timebar.go | 13 ++-- shortcuts/base/view_set_visible_fields.go | 13 ++-- shortcuts/base/workflow_create.go | 19 +++--- shortcuts/base/workflow_disable.go | 19 +++--- shortcuts/base/workflow_enable.go | 19 +++--- shortcuts/base/workflow_execute_test.go | 38 +++++++++++ shortcuts/base/workflow_get.go | 19 +++--- shortcuts/base/workflow_list.go | 45 ++++++++++--- shortcuts/base/workflow_update.go | 19 +++--- 92 files changed, 931 insertions(+), 712 deletions(-) diff --git a/shortcuts/base/base_advperm_disable.go b/shortcuts/base/base_advperm_disable.go index 7d403aa58..0dff70179 100644 --- a/shortcuts/base/base_advperm_disable.go +++ b/shortcuts/base/base_advperm_disable.go @@ -16,12 +16,13 @@ import ( ) var BaseAdvpermDisable = common.Shortcut{ - Service: "base", - Command: "+advperm-disable", - Description: "Disable advanced permissions for a Base", - Risk: "high-risk-write", - Scopes: []string{"base:app:update"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+advperm-disable", + Description: "Disable advanced permissions for a Base", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:app:update"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, }, @@ -30,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{ "Disabling advanced permissions invalidates existing custom roles; confirm the target Base before passing --yes.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } return nil @@ -38,10 +39,10 @@ var BaseAdvpermDisable = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/advperm/enable?enable=false"). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) queryParams := make(larkcore.QueryParams) queryParams.Set("enable", "false") diff --git a/shortcuts/base/base_advperm_enable.go b/shortcuts/base/base_advperm_enable.go index f7c4d8afc..9791fb84b 100644 --- a/shortcuts/base/base_advperm_enable.go +++ b/shortcuts/base/base_advperm_enable.go @@ -16,12 +16,13 @@ import ( ) var BaseAdvpermEnable = common.Shortcut{ - Service: "base", - Command: "+advperm-enable", - Description: "Enable advanced permissions for a Base", - Risk: "write", - Scopes: []string{"base:app:update"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+advperm-enable", + Description: "Enable advanced permissions for a Base", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:app:update"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, }, @@ -29,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{ "Caller must be a Base admin; enable advanced permissions before creating or updating roles.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } return nil @@ -37,10 +38,10 @@ var BaseAdvpermEnable = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/advperm/enable?enable=true"). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) queryParams := make(larkcore.QueryParams) queryParams.Set("enable", "true") diff --git a/shortcuts/base/base_block_create.go b/shortcuts/base/base_block_create.go index 40b3b23cb..e6efc88cf 100644 --- a/shortcuts/base/base_block_create.go +++ b/shortcuts/base/base_block_create.go @@ -10,12 +10,13 @@ import ( ) var BaseBaseBlockCreate = common.Shortcut{ - Service: "base", - Command: "+base-block-create", - Description: "Create a block", - Risk: "write", - Scopes: []string{"base:block:create"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+base-block-create", + Description: "Create a block", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:block:create"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), {Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums}, diff --git a/shortcuts/base/base_block_delete.go b/shortcuts/base/base_block_delete.go index 3faf28430..a76027159 100644 --- a/shortcuts/base/base_block_delete.go +++ b/shortcuts/base/base_block_delete.go @@ -10,12 +10,13 @@ import ( ) var BaseBaseBlockDelete = common.Shortcut{ - Service: "base", - Command: "+base-block-delete", - Description: "Delete a block", - Risk: "high-risk-write", - Scopes: []string{"base:block:delete"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+base-block-delete", + Description: "Delete a block", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:block:delete"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), baseBlockIDFlag(true), diff --git a/shortcuts/base/base_block_list.go b/shortcuts/base/base_block_list.go index c0862f09c..3c6613bc0 100644 --- a/shortcuts/base/base_block_list.go +++ b/shortcuts/base/base_block_list.go @@ -10,12 +10,13 @@ import ( ) var BaseBaseBlockList = common.Shortcut{ - Service: "base", - Command: "+base-block-list", - Description: "List blocks in a base", - Risk: "read", - Scopes: []string{"base:block:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+base-block-list", + Description: "List blocks in a base", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:block:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), {Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums}, diff --git a/shortcuts/base/base_block_move.go b/shortcuts/base/base_block_move.go index 1b1198d53..edf6deccf 100644 --- a/shortcuts/base/base_block_move.go +++ b/shortcuts/base/base_block_move.go @@ -10,12 +10,13 @@ import ( ) var BaseBaseBlockMove = common.Shortcut{ - Service: "base", - Command: "+base-block-move", - Description: "Move a block", - Risk: "write", - Scopes: []string{"base:block:update"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+base-block-move", + Description: "Move a block", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:block:update"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), baseBlockIDFlag(true), diff --git a/shortcuts/base/base_block_ops.go b/shortcuts/base/base_block_ops.go index 68586f619..652856dce 100644 --- a/shortcuts/base/base_block_ops.go +++ b/shortcuts/base/base_block_ops.go @@ -20,21 +20,21 @@ func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *com return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/blocks/list"). Body(buildBaseBlockListBody(runtime)). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) } func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/blocks"). Body(buildBaseBlockCreateBody(runtime)). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) } func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move"). Body(buildBaseBlockMoveBody(runtime)). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("block_id", runtime.Str("block-id")) } @@ -42,14 +42,14 @@ func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *c return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename"). Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("block_id", runtime.Str("block-id")) } func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("block_id", runtime.Str("block-id")) } @@ -78,7 +78,7 @@ func validateBaseBlockRename(runtime *common.RuntimeContext) error { } func executeBaseBlockList(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime)) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "blocks", "list"), nil, buildBaseBlockListBody(runtime)) if err != nil { return err } @@ -88,7 +88,7 @@ func executeBaseBlockList(runtime *common.RuntimeContext) error { } func executeBaseBlockCreate(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime)) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "blocks"), nil, buildBaseBlockCreateBody(runtime)) if err != nil { return err } @@ -97,7 +97,7 @@ func executeBaseBlockCreate(runtime *common.RuntimeContext) error { } func executeBaseBlockMove(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime)) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime)) if err != nil { return err } @@ -106,7 +106,7 @@ func executeBaseBlockMove(runtime *common.RuntimeContext) error { } func executeBaseBlockRename(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{ + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{ "name": strings.TrimSpace(runtime.Str("name")), }) if err != nil { @@ -117,7 +117,7 @@ func executeBaseBlockRename(runtime *common.RuntimeContext) error { } func executeBaseBlockDelete(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil) + data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseTokenOrRaw(runtime), "blocks", runtime.Str("block-id")), nil, nil) if err != nil { return err } diff --git a/shortcuts/base/base_block_rename.go b/shortcuts/base/base_block_rename.go index f1926898e..b24a20ae1 100644 --- a/shortcuts/base/base_block_rename.go +++ b/shortcuts/base/base_block_rename.go @@ -10,12 +10,13 @@ import ( ) var BaseBaseBlockRename = common.Shortcut{ - Service: "base", - Command: "+base-block-rename", - Description: "Rename a block", - Risk: "write", - Scopes: []string{"base:block:update"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+base-block-rename", + Description: "Rename a block", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:block:update"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), baseBlockIDFlag(true), diff --git a/shortcuts/base/base_command_common.go b/shortcuts/base/base_command_common.go index b1849ee96..64d09a460 100644 --- a/shortcuts/base/base_command_common.go +++ b/shortcuts/base/base_command_common.go @@ -10,7 +10,7 @@ func authTypes() []string { } func baseTokenFlag(required bool) common.Flag { - return common.Flag{Name: "base-token", Desc: "base token", Required: required} + return common.Flag{Name: "base-token", Desc: "base token or /base|/wiki URL", Required: required} } func tableRefFlag(required bool) common.Flag { diff --git a/shortcuts/base/base_copy.go b/shortcuts/base/base_copy.go index 5e2210a85..95ca49d2c 100644 --- a/shortcuts/base/base_copy.go +++ b/shortcuts/base/base_copy.go @@ -10,13 +10,14 @@ import ( ) var BaseBaseCopy = common.Shortcut{ - Service: "base", - Command: "+base-copy", - Description: "Copy a base resource", - Risk: "write", - UserScopes: []string{"base:app:copy"}, - BotScopes: []string{"base:app:copy", "docs:permission.member:create"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+base-copy", + Description: "Copy a base resource", + Risk: "write", + ConditionalUserScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:app:copy"}, + BotScopes: []string{"base:app:copy", "docs:permission.member:create"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), {Name: "name", Desc: "new base name"}, diff --git a/shortcuts/base/base_data_query.go b/shortcuts/base/base_data_query.go index 51565d264..8463ffecc 100644 --- a/shortcuts/base/base_data_query.go +++ b/shortcuts/base/base_data_query.go @@ -13,12 +13,13 @@ import ( ) var BaseDataQuery = common.Shortcut{ - Service: "base", - Command: "+data-query", - Description: "Query and analyze Base data with JSON DSL (aggregation, filter, sort)", - Risk: "read", - Scopes: []string{"base:table:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+data-query", + Description: "Query and analyze Base data with JSON DSL (aggregation, filter, sort)", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:table:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), {Name: "table-id", Hidden: true}, @@ -54,10 +55,10 @@ var BaseDataQuery = common.Shortcut{ return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/data/query"). Body(dsl). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) var dsl map[string]interface{} dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) diff --git a/shortcuts/base/base_form_create.go b/shortcuts/base/base_form_create.go index 238892aa7..a6f50a7db 100644 --- a/shortcuts/base/base_form_create.go +++ b/shortcuts/base/base_form_create.go @@ -12,13 +12,14 @@ import ( ) var BaseFormCreate = common.Shortcut{ - Service: "base", - Command: "+form-create", - Description: "Create a form in a Base table", - Risk: "write", - Scopes: []string{"base:form:create"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-create", + Description: "Create a form in a Base table", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:create"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "table-id", Desc: "table ID", Required: true}, @@ -31,11 +32,11 @@ var BaseFormCreate = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") name := runtime.Str("name") description := runtime.Str("description") diff --git a/shortcuts/base/base_form_delete.go b/shortcuts/base/base_form_delete.go index 75735ba70..9f0637ed0 100644 --- a/shortcuts/base/base_form_delete.go +++ b/shortcuts/base/base_form_delete.go @@ -10,13 +10,14 @@ import ( ) var BaseFormDelete = common.Shortcut{ - Service: "base", - Command: "+form-delete", - Description: "Delete a form in a Base table", - Risk: "high-risk-write", - Scopes: []string{"base:form:delete"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-delete", + Description: "Delete a form in a Base table", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:delete"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), {Name: "table-id", Desc: "table ID", Required: true}, @@ -29,12 +30,12 @@ var BaseFormDelete = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") formId := runtime.Str("form-id") diff --git a/shortcuts/base/base_form_get.go b/shortcuts/base/base_form_get.go index a4c3a2eeb..d439b304c 100644 --- a/shortcuts/base/base_form_get.go +++ b/shortcuts/base/base_form_get.go @@ -12,13 +12,14 @@ import ( ) var BaseFormGet = common.Shortcut{ - Service: "base", - Command: "+form-get", - Description: "Get a form in a Base table", - Risk: "read", - Scopes: []string{"base:form:read"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-get", + Description: "Get a form in a Base table", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), {Name: "table-id", Desc: "table ID", Required: true}, @@ -27,12 +28,12 @@ var BaseFormGet = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") formId := runtime.Str("form-id") diff --git a/shortcuts/base/base_form_list.go b/shortcuts/base/base_form_list.go index 34f5bfe0f..affce53b1 100644 --- a/shortcuts/base/base_form_list.go +++ b/shortcuts/base/base_form_list.go @@ -13,13 +13,14 @@ import ( ) var BaseFormsList = common.Shortcut{ - Service: "base", - Command: "+form-list", - Description: "List all forms in a Base table (auto-paginated)", - Risk: "read", - Scopes: []string{"base:form:read"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-list", + Description: "List all forms in a Base table (auto-paginated)", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "table-id", Desc: "table ID", Required: true}, @@ -28,11 +29,11 @@ var BaseFormsList = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") var allForms []interface{} diff --git a/shortcuts/base/base_form_questions_create.go b/shortcuts/base/base_form_questions_create.go index bd60ac997..7c66dee47 100644 --- a/shortcuts/base/base_form_questions_create.go +++ b/shortcuts/base/base_form_questions_create.go @@ -14,13 +14,14 @@ import ( ) var BaseFormQuestionsCreate = common.Shortcut{ - Service: "base", - Command: "+form-questions-create", - Description: "Create questions for a form in a Base table", - Risk: "write", - Scopes: []string{"base:form:update"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-questions-create", + Description: "Create questions for a form in a Base table", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "table-id", Desc: "table ID", Required: true}, @@ -30,12 +31,12 @@ var BaseFormQuestionsCreate = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") formId := runtime.Str("form-id") questionsJSON := runtime.Str("questions") diff --git a/shortcuts/base/base_form_questions_delete.go b/shortcuts/base/base_form_questions_delete.go index b2875e462..322434a21 100644 --- a/shortcuts/base/base_form_questions_delete.go +++ b/shortcuts/base/base_form_questions_delete.go @@ -11,13 +11,14 @@ import ( ) var BaseFormQuestionsDelete = common.Shortcut{ - Service: "base", - Command: "+form-questions-delete", - Description: "Delete questions from a form in a Base table", - Risk: "high-risk-write", - Scopes: []string{"base:form:update"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-questions-delete", + Description: "Delete questions from a form in a Base table", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "table-id", Desc: "table ID", Required: true}, @@ -30,12 +31,12 @@ var BaseFormQuestionsDelete = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") formId := runtime.Str("form-id") questionIdsJSON := runtime.Str("question-ids") diff --git a/shortcuts/base/base_form_questions_list.go b/shortcuts/base/base_form_questions_list.go index 3384fd3d9..50ea13d1e 100644 --- a/shortcuts/base/base_form_questions_list.go +++ b/shortcuts/base/base_form_questions_list.go @@ -13,13 +13,14 @@ import ( ) var BaseFormQuestionsList = common.Shortcut{ - Service: "base", - Command: "+form-questions-list", - Description: "List questions of a form in a Base table", - Risk: "read", - Scopes: []string{"base:form:read"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-questions-list", + Description: "List questions of a form in a Base table", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), {Name: "table-id", Desc: "table ID", Required: true}, @@ -31,12 +32,12 @@ var BaseFormQuestionsList = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") formId := runtime.Str("form-id") diff --git a/shortcuts/base/base_form_questions_update.go b/shortcuts/base/base_form_questions_update.go index 77831e5e5..0b03f3ae1 100644 --- a/shortcuts/base/base_form_questions_update.go +++ b/shortcuts/base/base_form_questions_update.go @@ -14,13 +14,14 @@ import ( ) var BaseFormQuestionsUpdate = common.Shortcut{ - Service: "base", - Command: "+form-questions-update", - Description: "Update questions of a form in a Base table", - Risk: "write", - Scopes: []string{"base:form:update"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-questions-update", + Description: "Update questions of a form in a Base table", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "table-id", Desc: "table ID", Required: true}, @@ -30,12 +31,12 @@ var BaseFormQuestionsUpdate = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") formId := runtime.Str("form-id") questionsJSON := runtime.Str("questions") diff --git a/shortcuts/base/base_form_submit.go b/shortcuts/base/base_form_submit.go index e23284954..4f6fad7d2 100644 --- a/shortcuts/base/base_form_submit.go +++ b/shortcuts/base/base_form_submit.go @@ -23,13 +23,14 @@ const ( ) var BaseFormSubmit = common.Shortcut{ - Service: "base", - Command: "+form-submit", - Description: "Submit a form (fill and submit form data)", - Risk: "write", - Scopes: []string{"base:form:update", "docs:document.media:upload"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+form-submit", + Description: "Submit a form (fill and submit form data)", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:update", "docs:document.media:upload"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ {Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true}, {Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"}, @@ -66,7 +67,7 @@ func validateFormSubmit(runtime *common.RuntimeContext) error { if hasAttachments { // 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要) - if runtime.Str("base-token") == "" { + if baseTokenOrRaw(runtime) == "" { return baseFlagErrorf("--base-token is required when --json contains \"attachments\"") } @@ -155,7 +156,7 @@ func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common Body(map[string]interface{}{ "file_name": fileName, "parent_type": baseFormAttachmentParentType, - "parent_node": runtime.Str("base-token"), + "parent_node": baseTokenOrRaw(runtime), "extra": baseFormAttachmentExtra(runtime.Str("share-token")), "file": "@" + p, "size": "", @@ -191,7 +192,7 @@ func executeFormSubmit(runtime *common.RuntimeContext) error { // 上传附件并合并到字段中 if len(attachmentMap) > 0 { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) fio := runtime.FileIO() if fio == nil { return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)") diff --git a/shortcuts/base/base_form_update.go b/shortcuts/base/base_form_update.go index 53096e206..7f91796ac 100644 --- a/shortcuts/base/base_form_update.go +++ b/shortcuts/base/base_form_update.go @@ -12,13 +12,14 @@ import ( ) var BaseFormUpdate = common.Shortcut{ - Service: "base", - Command: "+form-update", - Description: "Update a form in a Base table", - Risk: "write", - Scopes: []string{"base:form:update"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+form-update", + Description: "Update a form in a Base table", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "table-id", Desc: "table ID", Required: true}, @@ -29,12 +30,12 @@ var BaseFormUpdate = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableId := runtime.Str("table-id") formId := runtime.Str("form-id") name := runtime.Str("name") diff --git a/shortcuts/base/base_get.go b/shortcuts/base/base_get.go index 6a869ff4a..6a20cf92c 100644 --- a/shortcuts/base/base_get.go +++ b/shortcuts/base/base_get.go @@ -10,15 +10,16 @@ import ( ) var BaseBaseGet = common.Shortcut{ - Service: "base", - Command: "+base-get", - Description: "Get a base resource", - Risk: "read", - Scopes: []string{"base:app:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true)}, + Service: "base", + Command: "+base-get", + Description: "Get a base resource", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:app:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true)}, Tips: []string{ - "Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.", + "Accepts a Base token, a /base/ URL, or a /wiki/ URL (wiki links auto-resolve to the underlying bitable); workspace tokens are not accepted.", }, DryRun: dryRunBaseGet, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/base_ops.go b/shortcuts/base/base_ops.go index 68bbf795b..60530b0d6 100644 --- a/shortcuts/base/base_ops.go +++ b/shortcuts/base/base_ops.go @@ -20,14 +20,14 @@ var baseCreateDefaultTableDeleteDelay = time.Second func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token"). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) } func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { d := common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/copy"). Body(buildBaseCopyBody(runtime)). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) if runtime.IsBot() { d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.") } @@ -73,7 +73,7 @@ func validateBaseCreate(runtime *common.RuntimeContext) error { } func executeBaseGet(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime)), nil, nil) if err != nil { return err } @@ -82,7 +82,7 @@ func executeBaseGet(runtime *common.RuntimeContext) error { } func executeBaseCopy(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime)) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "copy"), nil, buildBaseCopyBody(runtime)) if err != nil { return err } diff --git a/shortcuts/base/base_role_create.go b/shortcuts/base/base_role_create.go index 7e557d59f..a7c745abd 100644 --- a/shortcuts/base/base_role_create.go +++ b/shortcuts/base/base_role_create.go @@ -17,12 +17,13 @@ import ( ) var BaseRoleCreate = common.Shortcut{ - Service: "base", - Command: "+role-create", - Description: "Create a custom role in a Base", - Risk: "write", - Scopes: []string{"base:role:create"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+role-create", + Description: "Create a custom role in a Base", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:role:create"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", Required: true}, @@ -33,7 +34,7 @@ var BaseRoleCreate = common.Shortcut{ "Create supports custom_role only.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } var body map[string]any @@ -48,10 +49,10 @@ var BaseRoleCreate = common.Shortcut{ return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/roles"). Body(body). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) var body map[string]any json.Unmarshal([]byte(runtime.Str("json")), &body) diff --git a/shortcuts/base/base_role_delete.go b/shortcuts/base/base_role_delete.go index c36de7c5b..dcd15f083 100644 --- a/shortcuts/base/base_role_delete.go +++ b/shortcuts/base/base_role_delete.go @@ -16,12 +16,13 @@ import ( ) var BaseRoleDelete = common.Shortcut{ - Service: "base", - Command: "+role-delete", - Description: "Delete a custom role (system roles cannot be deleted)", - Risk: "high-risk-write", - Scopes: []string{"base:role:delete"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+role-delete", + Description: "Delete a custom role (system roles cannot be deleted)", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:role:delete"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, @@ -33,7 +34,7 @@ var BaseRoleDelete = common.Shortcut{ "Use +role-get first if the role target is ambiguous, then pass --yes to confirm deletion.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } if strings.TrimSpace(runtime.Str("role-id")) == "" { @@ -44,11 +45,11 @@ var BaseRoleDelete = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/roles/:role_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("role_id", runtime.Str("role-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) roleId := runtime.Str("role-id") apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ diff --git a/shortcuts/base/base_role_get.go b/shortcuts/base/base_role_get.go index a064ceec6..6e387ad6c 100644 --- a/shortcuts/base/base_role_get.go +++ b/shortcuts/base/base_role_get.go @@ -16,13 +16,14 @@ import ( ) var BaseRoleGet = common.Shortcut{ - Service: "base", - Command: "+role-get", - Description: "Get full config of a role", - Risk: "read", - Scopes: []string{"base:role:read"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+role-get", + Description: "Get full config of a role", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:role:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, @@ -32,7 +33,7 @@ var BaseRoleGet = common.Shortcut{ "Use before +role-update to inspect the current full permission config.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } if strings.TrimSpace(runtime.Str("role-id")) == "" { @@ -43,11 +44,11 @@ var BaseRoleGet = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/roles/:role_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("role_id", runtime.Str("role-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) roleId := runtime.Str("role-id") apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ diff --git a/shortcuts/base/base_role_list.go b/shortcuts/base/base_role_list.go index 93a52e9fb..d13a6c2a7 100644 --- a/shortcuts/base/base_role_list.go +++ b/shortcuts/base/base_role_list.go @@ -16,13 +16,14 @@ import ( ) var BaseRoleList = common.Shortcut{ - Service: "base", - Command: "+role-list", - Description: "List all roles in a Base", - Risk: "read", - Scopes: []string{"base:role:read"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + Service: "base", + Command: "+role-list", + Description: "List all roles in a Base", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:role:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, }, @@ -31,7 +32,7 @@ var BaseRoleList = common.Shortcut{ "Returns role summaries; use +role-get for the full permission config.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } return nil @@ -39,10 +40,10 @@ var BaseRoleList = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/roles"). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodGet, diff --git a/shortcuts/base/base_role_update.go b/shortcuts/base/base_role_update.go index 1baa96705..6068047ad 100644 --- a/shortcuts/base/base_role_update.go +++ b/shortcuts/base/base_role_update.go @@ -17,12 +17,13 @@ import ( ) var BaseRoleUpdate = common.Shortcut{ - Service: "base", - Command: "+role-update", - Description: "Update a role config (delta merge, only changed fields needed)", - Risk: "high-risk-write", - Scopes: []string{"base:role:update"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+role-update", + Description: "Update a role config (delta merge, only changed fields needed)", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:role:update"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, @@ -35,7 +36,7 @@ var BaseRoleUpdate = common.Shortcut{ "Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } if strings.TrimSpace(runtime.Str("role-id")) == "" { @@ -54,11 +55,11 @@ var BaseRoleUpdate = common.Shortcut{ Desc("Delta merge: only changed fields are updated, others remain unchanged"). PUT("/open-apis/base/v3/bases/:base_token/roles/:role_id"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("role_id", runtime.Str("role-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) roleId := runtime.Str("role-id") var body map[string]any json.Unmarshal([]byte(runtime.Str("json")), &body) diff --git a/shortcuts/base/base_shortcut_helpers.go b/shortcuts/base/base_shortcut_helpers.go index fad33fe3a..1d547368b 100644 --- a/shortcuts/base/base_shortcut_helpers.go +++ b/shortcuts/base/base_shortcut_helpers.go @@ -9,7 +9,9 @@ import ( "fmt" "io" "strings" + "sync" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/shortcuts/common" ) @@ -23,6 +25,70 @@ func newParseCtx(runtime *common.RuntimeContext) *parseCtx { return &parseCtx{fio: runtime.FileIO()} } +// baseTokenMemo caches the resolved --base-token per runtime so a command that +// references the token multiple times (path building, request params, attachment +// upload loops) performs the parse — and at most one wiki get_node call — only +// once. Entries are keyed by the per-command *RuntimeContext, which a CLI run +// holds exactly one of. +var baseTokenMemo sync.Map // *common.RuntimeContext -> baseTokenResolution + +type baseTokenResolution struct { + token string + err error +} + +// baseToken resolves --base-token (a raw base token, a /base/ URL, or a /wiki/ +// URL) to a bitable app token, memoizing the result per runtime. +func baseToken(runtime *common.RuntimeContext) (string, error) { + if cached, ok := baseTokenMemo.Load(runtime); ok { + res := cached.(baseTokenResolution) + return res.token, res.err + } + token, err := resolveBaseToken(runtime) + baseTokenMemo.Store(runtime, baseTokenResolution{token: token, err: err}) + return token, err +} + +func resolveBaseToken(runtime *common.RuntimeContext) (string, error) { + raw := strings.TrimSpace(runtime.Str("base-token")) + if raw == "" { + return "", baseFlagErrorf("--base-token must not be blank") + } + ref, ok := common.ParseResourceURL(raw) + if !ok { + if strings.Contains(raw, "://") || strings.ContainsAny(raw, "/?#") { + return "", baseFlagErrorf("unsupported --base-token URL %q: expected a /base/ or /wiki/ URL, or pass a raw base token", raw) + } + return raw, nil + } + switch ref.Type { + case "bitable": + return ref.Token, nil + case "wiki": + data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": ref.Token}, nil) + if err != nil { + return "", err + } + node := common.GetMap(data, "node") + objType := common.GetString(node, "obj_type") + objToken := common.GetString(node, "obj_token") + if objType != "bitable" || strings.TrimSpace(objToken) == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--base-token wiki URL resolves to %s, not bitable", objType).WithParam("--base-token") + } + return objToken, nil + default: + return "", baseFlagErrorf("--base-token URL must point to /base/ or /wiki/ bitable, got %q", ref.Type) + } +} + +func baseTokenOrRaw(runtime *common.RuntimeContext) string { + token, err := baseToken(runtime) + if err != nil { + return strings.TrimSpace(runtime.Str("base-token")) + } + return token +} + func baseTableID(runtime *common.RuntimeContext) string { return strings.TrimSpace(runtime.Str("table-id")) } diff --git a/shortcuts/base/dashboard_arrange.go b/shortcuts/base/dashboard_arrange.go index ab031add0..895dcfe3d 100644 --- a/shortcuts/base/dashboard_arrange.go +++ b/shortcuts/base/dashboard_arrange.go @@ -10,13 +10,14 @@ import ( ) var BaseDashboardArrange = common.Shortcut{ - Service: "base", - Command: "+dashboard-arrange", - Description: "Auto-arrange dashboard blocks layout (server-side smart layout)", - Risk: "write", - Scopes: []string{"base:dashboard:update"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-arrange", + Description: "Auto-arrange dashboard blocks layout (server-side smart layout)", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index 98f44ea49..a14681e3d 100644 --- a/shortcuts/base/dashboard_block_create.go +++ b/shortcuts/base/dashboard_block_create.go @@ -13,13 +13,14 @@ import ( ) var BaseDashboardBlockCreate = common.Shortcut{ - Service: "base", - Command: "+dashboard-block-create", - Description: "Create a block in a dashboard", - Risk: "write", - Scopes: []string{"base:dashboard:create"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-block-create", + Description: "Create a block in a dashboard", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:create"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -86,7 +87,7 @@ var BaseDashboardBlockCreate = common.Shortcut{ POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). Params(params). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/dashboard_block_delete.go b/shortcuts/base/dashboard_block_delete.go index 2e78a5995..c3626fd00 100644 --- a/shortcuts/base/dashboard_block_delete.go +++ b/shortcuts/base/dashboard_block_delete.go @@ -10,13 +10,14 @@ import ( ) var BaseDashboardBlockDelete = common.Shortcut{ - Service: "base", - Command: "+dashboard-block-delete", - Description: "Delete a dashboard block", - Risk: "high-risk-write", - Scopes: []string{"base:dashboard:delete"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-block-delete", + Description: "Delete a dashboard block", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:delete"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -29,7 +30,7 @@ var BaseDashboardBlockDelete = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")). Set("block_id", runtime.Str("block-id")) }, diff --git a/shortcuts/base/dashboard_block_get.go b/shortcuts/base/dashboard_block_get.go index 5f6d62631..1f0af89f6 100644 --- a/shortcuts/base/dashboard_block_get.go +++ b/shortcuts/base/dashboard_block_get.go @@ -11,13 +11,14 @@ import ( ) var BaseDashboardBlockGet = common.Shortcut{ - Service: "base", - Command: "+dashboard-block-get", - Description: "Get a dashboard block by ID", - Risk: "read", - Scopes: []string{"base:dashboard:read"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-block-get", + Description: "Get a dashboard block by ID", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -37,7 +38,7 @@ var BaseDashboardBlockGet = common.Shortcut{ return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). Params(params). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")). Set("block_id", runtime.Str("block-id")) }, diff --git a/shortcuts/base/dashboard_block_get_data.go b/shortcuts/base/dashboard_block_get_data.go index 0074860f7..1aa24951e 100644 --- a/shortcuts/base/dashboard_block_get_data.go +++ b/shortcuts/base/dashboard_block_get_data.go @@ -10,13 +10,14 @@ import ( ) var BaseDashboardBlockGetData = common.Shortcut{ - Service: "base", - Command: "+dashboard-block-get-data", - Description: "Get computed data for a dashboard chart block", - Risk: "read", - Scopes: []string{"base:dashboard:read"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-block-get-data", + Description: "Get computed data for a dashboard chart block", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), {Name: "dashboard-id", Hidden: true}, diff --git a/shortcuts/base/dashboard_block_list.go b/shortcuts/base/dashboard_block_list.go index 2f4d7461e..181b4c773 100644 --- a/shortcuts/base/dashboard_block_list.go +++ b/shortcuts/base/dashboard_block_list.go @@ -11,13 +11,14 @@ import ( ) var BaseDashboardBlockList = common.Shortcut{ - Service: "base", - Command: "+dashboard-block-list", - Description: "List blocks in a dashboard", - Risk: "read", - Scopes: []string{"base:dashboard:read"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-block-list", + Description: "List blocks in a dashboard", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -39,7 +40,7 @@ var BaseDashboardBlockList = common.Shortcut{ return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). Params(params). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go index de02ecf33..b30805644 100644 --- a/shortcuts/base/dashboard_block_update.go +++ b/shortcuts/base/dashboard_block_update.go @@ -12,13 +12,14 @@ import ( ) var BaseDashboardBlockUpdate = common.Shortcut{ - Service: "base", - Command: "+dashboard-block-update", - Description: "Update a dashboard block", - Risk: "write", - Scopes: []string{"base:dashboard:update"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-block-update", + Description: "Update a dashboard block", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -74,7 +75,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{ PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). Params(params). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")). Set("block_id", runtime.Str("block-id")) }, diff --git a/shortcuts/base/dashboard_create.go b/shortcuts/base/dashboard_create.go index d0ac6ab29..64f180f28 100644 --- a/shortcuts/base/dashboard_create.go +++ b/shortcuts/base/dashboard_create.go @@ -10,13 +10,14 @@ import ( ) var BaseDashboardCreate = common.Shortcut{ - Service: "base", - Command: "+dashboard-create", - Description: "Create a dashboard in a base", - Risk: "write", - Scopes: []string{"base:dashboard:create"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-create", + Description: "Create a dashboard in a base", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:create"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), {Name: "name", Desc: "dashboard name", Required: true}, @@ -36,7 +37,7 @@ var BaseDashboardCreate = common.Shortcut{ return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/dashboards"). Body(body). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeDashboardCreate(runtime) diff --git a/shortcuts/base/dashboard_delete.go b/shortcuts/base/dashboard_delete.go index 587d5f8a4..0d9bf02b7 100644 --- a/shortcuts/base/dashboard_delete.go +++ b/shortcuts/base/dashboard_delete.go @@ -10,13 +10,14 @@ import ( ) var BaseDashboardDelete = common.Shortcut{ - Service: "base", - Command: "+dashboard-delete", - Description: "Delete a dashboard", - Risk: "high-risk-write", - Scopes: []string{"base:dashboard:delete"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-delete", + Description: "Delete a dashboard", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:delete"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -29,7 +30,7 @@ var BaseDashboardDelete = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/dashboard_get.go b/shortcuts/base/dashboard_get.go index a42642790..b46f0ed82 100644 --- a/shortcuts/base/dashboard_get.go +++ b/shortcuts/base/dashboard_get.go @@ -10,13 +10,14 @@ import ( ) var BaseDashboardGet = common.Shortcut{ - Service: "base", - Command: "+dashboard-get", - Description: "Get a dashboard by ID", - Risk: "read", - Scopes: []string{"base:dashboard:read"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-get", + Description: "Get a dashboard by ID", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -27,7 +28,7 @@ var BaseDashboardGet = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/dashboard_list.go b/shortcuts/base/dashboard_list.go index c1eca4902..f44bb4d63 100644 --- a/shortcuts/base/dashboard_list.go +++ b/shortcuts/base/dashboard_list.go @@ -11,13 +11,14 @@ import ( ) var BaseDashboardList = common.Shortcut{ - Service: "base", - Command: "+dashboard-list", - Description: "List dashboards in a base", - Risk: "read", - Scopes: []string{"base:dashboard:read"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-list", + Description: "List dashboards in a base", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), {Name: "page-size", Desc: "page size, max 100"}, @@ -37,7 +38,7 @@ var BaseDashboardList = common.Shortcut{ return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/dashboards"). Params(params). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeDashboardList(runtime) diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go index 3d32099f0..46d8497a9 100644 --- a/shortcuts/base/dashboard_ops.go +++ b/shortcuts/base/dashboard_ops.go @@ -23,7 +23,7 @@ func blockIDFlag(required bool) common.Flag { // dryRunDashboardBase returns a base DryRunAPI with common dashboard parameters set. func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")). Set("block_id", runtime.Str("block-id")) } @@ -108,7 +108,7 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext) func dryRunDashboardBlockGetData(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/dashboards/blocks/:block_id/data"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("block_id", runtime.Str("block-id")) } @@ -177,7 +177,7 @@ func executeDashboardList(runtime *common.RuntimeContext) error { if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { params["page_token"] = pageToken } - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards"), params, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards"), params, nil) if err != nil { return err } @@ -187,7 +187,7 @@ func executeDashboardList(runtime *common.RuntimeContext) error { // executeDashboardGet retrieves a dashboard by ID. func executeDashboardGet(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id")), nil, nil) if err != nil { return err } @@ -201,7 +201,7 @@ func executeDashboardCreate(runtime *common.RuntimeContext) error { if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { body["theme"] = map[string]interface{}{"theme_style": themeStyle} } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards"), nil, body) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards"), nil, body) if err != nil { return err } @@ -218,7 +218,7 @@ func executeDashboardUpdate(runtime *common.RuntimeContext) error { if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { body["theme"] = map[string]interface{}{"theme_style": themeStyle} } - data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, body) + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id")), nil, body) if err != nil { return err } @@ -228,7 +228,7 @@ func executeDashboardUpdate(runtime *common.RuntimeContext) error { // executeDashboardDelete deletes a dashboard by ID. func executeDashboardDelete(runtime *common.RuntimeContext) error { - _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil) + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id")), nil, nil) if err != nil { return err } @@ -247,7 +247,7 @@ func executeDashboardBlockList(runtime *common.RuntimeContext) error { if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { params["page_token"] = pageToken } - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, nil) if err != nil { return err } @@ -261,7 +261,7 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error { if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { params["user_id_type"] = userIDType } - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, nil) if err != nil { return err } @@ -271,7 +271,7 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error { // executeDashboardBlockGetData retrieves computed data for a dashboard chart block. func executeDashboardBlockGetData(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", "blocks", runtime.Str("block-id"), "data"), nil, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", "blocks", runtime.Str("block-id"), "data"), nil, nil) if err != nil { return err } @@ -302,7 +302,7 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { params["user_id_type"] = userIDType } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, body) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, body) if err != nil { return err } @@ -329,7 +329,7 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error { params["user_id_type"] = userIDType } - data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, body) + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, body) if err != nil { return err } @@ -339,7 +339,7 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error { // executeDashboardBlockDelete deletes a dashboard block by ID. func executeDashboardBlockDelete(runtime *common.RuntimeContext) error { - _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), nil, nil) + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), nil, nil) if err != nil { return err } @@ -368,7 +368,7 @@ func executeDashboardArrange(runtime *common.RuntimeContext) error { params["user_id_type"] = userIDType } // 请求体为空对象,由服务端智能重排 - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "arrange"), params, map[string]interface{}{}) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "dashboards", runtime.Str("dashboard-id"), "arrange"), params, map[string]interface{}{}) if err != nil { return err } diff --git a/shortcuts/base/dashboard_update.go b/shortcuts/base/dashboard_update.go index f9dda81a4..560a7004b 100644 --- a/shortcuts/base/dashboard_update.go +++ b/shortcuts/base/dashboard_update.go @@ -10,13 +10,14 @@ import ( ) var BaseDashboardUpdate = common.Shortcut{ - Service: "base", - Command: "+dashboard-update", - Description: "Update a dashboard", - Risk: "write", - Scopes: []string{"base:dashboard:update"}, - AuthTypes: authTypes(), - HasFormat: true, + Service: "base", + Command: "+dashboard-update", + Description: "Update a dashboard", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), @@ -34,7 +35,7 @@ var BaseDashboardUpdate = common.Shortcut{ return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("dashboard_id", runtime.Str("dashboard-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go index 121117627..241fd1b14 100644 --- a/shortcuts/base/field_create.go +++ b/shortcuts/base/field_create.go @@ -10,12 +10,13 @@ import ( ) var BaseFieldCreate = common.Shortcut{ - Service: "base", - Command: "+field-create", - Description: "Create a field", - Risk: "write", - Scopes: []string{"base:field:create"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+field-create", + Description: "Create a field", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:field:create"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/field_delete.go b/shortcuts/base/field_delete.go index 8b6a0a793..e7ef810c1 100644 --- a/shortcuts/base/field_delete.go +++ b/shortcuts/base/field_delete.go @@ -10,13 +10,14 @@ import ( ) var BaseFieldDelete = common.Shortcut{ - Service: "base", - Command: "+field-delete", - Description: "Delete a field by ID or name", - Risk: "high-risk-write", - Scopes: []string{"base:field:delete"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, + Service: "base", + Command: "+field-delete", + Description: "Delete a field by ID or name", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:field:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, Tips: []string{ baseHighRiskYesTip, `Example: lark-cli base +field-delete --base-token --table-id --field-id "Status" --yes`, diff --git a/shortcuts/base/field_get.go b/shortcuts/base/field_get.go index a272b54c5..1303946ec 100644 --- a/shortcuts/base/field_get.go +++ b/shortcuts/base/field_get.go @@ -10,13 +10,14 @@ import ( ) var BaseFieldGet = common.Shortcut{ - Service: "base", - Command: "+field-get", - Description: "Get a field by ID or name", - Risk: "read", - Scopes: []string{"base:field:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, + Service: "base", + Command: "+field-get", + Description: "Get a field by ID or name", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, Tips: []string{ `Example: lark-cli base +field-get --base-token --table-id --field-id "Status"`, "field-id accepts a field ID (fld...) or the field name from the current table.", diff --git a/shortcuts/base/field_list.go b/shortcuts/base/field_list.go index 5b135d4f1..10a4f3f39 100644 --- a/shortcuts/base/field_list.go +++ b/shortcuts/base/field_list.go @@ -10,12 +10,13 @@ import ( ) var BaseFieldList = common.Shortcut{ - Service: "base", - Command: "+field-list", - Description: "List fields in a table", - Risk: "read", - Scopes: []string{"base:field:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+field-list", + Description: "List fields in a table", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 84815da6e..e81c0a032 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -25,7 +25,7 @@ func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common. return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). Params(map[string]interface{}{"offset": offset, "limit": limit}). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -37,9 +37,9 @@ func dryRunFieldListBatch(_ context.Context, runtime *common.RuntimeContext) *co limit := common.ParseIntBounded(runtime, "limit", 1, 200) dry := common.NewDryRunAPI() for _, tableIDValue := range runtime.StrArray("table-id") { - dry.GET(baseV3Path("bases", runtime.Str("base-token"), "tables", tableIDValue, "fields")). + dry.GET(baseV3Path("bases", baseTokenOrRaw(runtime), "tables", tableIDValue, "fields")). Params(map[string]interface{}{"offset": offset, "limit": limit}). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", tableIDValue) } return dry @@ -48,7 +48,7 @@ func dryRunFieldListBatch(_ context.Context, runtime *common.RuntimeContext) *co func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). Set("field_id", runtime.Str("field-id")) } @@ -59,7 +59,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -69,7 +69,7 @@ func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *commo return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). Set("field_id", runtime.Str("field-id")) } @@ -77,7 +77,7 @@ func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *commo func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). Set("field_id", runtime.Str("field-id")) } @@ -95,9 +95,9 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) params["query"] = keyword } return common.NewDryRunAPI(). - GET(baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields", fieldRef, "options")). + GET(baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "fields", fieldRef, "options")). Params(params). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). Set("field_id", fieldRef) } @@ -141,7 +141,7 @@ func executeFieldList(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableRef, err := resolveFieldListTableRefs(runtime, baseToken, []string{baseTableID(runtime)}) if err != nil { return err @@ -166,7 +166,7 @@ func executeFieldListBatch(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableRefs, err := resolveFieldListTableRefs(runtime, baseToken, runtime.StrArray("table-id")) if err != nil { return err @@ -278,7 +278,7 @@ func compactFields(fields []map[string]interface{}) []map[string]interface{} { } func executeFieldGet(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) fieldRef := runtime.Str("field-id") data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) @@ -295,7 +295,7 @@ func executeFieldCreate(runtime *common.RuntimeContext) error { if err != nil { return err } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "fields"), nil, body) if err != nil { return err } @@ -305,7 +305,7 @@ func executeFieldCreate(runtime *common.RuntimeContext) error { func executeFieldUpdate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) body, err := parseJSONObject(pc, runtime.Str("json"), "json") if err != nil { @@ -321,7 +321,7 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error { } func executeFieldDelete(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) fieldRef := runtime.Str("field-id") _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) @@ -348,7 +348,7 @@ func fieldSearchOptionsKeyword(runtime *common.RuntimeContext) string { } func executeFieldSearchOptions(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) fieldRef := fieldSearchOptionsRef(runtime) params := map[string]interface{}{ diff --git a/shortcuts/base/field_search_options.go b/shortcuts/base/field_search_options.go index 6d2775c45..84d1ea03f 100644 --- a/shortcuts/base/field_search_options.go +++ b/shortcuts/base/field_search_options.go @@ -11,12 +11,13 @@ import ( ) var BaseFieldSearchOptions = common.Shortcut{ - Service: "base", - Command: "+field-search-options", - Description: "Search select options of a field", - Risk: "read", - Scopes: []string{"base:field:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+field-search-options", + Description: "Search select options of a field", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go index 177ad7e9a..d0fb7a818 100644 --- a/shortcuts/base/field_update.go +++ b/shortcuts/base/field_update.go @@ -10,12 +10,13 @@ import ( ) var BaseFieldUpdate = common.Shortcut{ - Service: "base", - Command: "+field-update", - Description: "Update a field by ID or name", - Risk: "high-risk-write", - Scopes: []string{"base:field:update"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+field-update", + Description: "Update a field by ID or name", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:field:update"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go index f75dd028f..2942ab365 100644 --- a/shortcuts/base/helpers.go +++ b/shortcuts/base/helpers.go @@ -386,6 +386,14 @@ func baseV3Path(parts ...string) string { } func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + // Surface --base-token resolution failures (e.g. a /wiki/ URL that does not + // point to a bitable) with a clear message before issuing the request, + // instead of letting an unresolved token reach the API as an opaque error. + if strings.TrimSpace(runtime.Str("base-token")) != "" { + if _, err := baseToken(runtime); err != nil { + return nil, err + } + } queryParams := make(larkcore.QueryParams) for k, v := range params { switch val := v.(type) { diff --git a/shortcuts/base/record_batch_create.go b/shortcuts/base/record_batch_create.go index 50cde35fa..708d2fe6f 100644 --- a/shortcuts/base/record_batch_create.go +++ b/shortcuts/base/record_batch_create.go @@ -10,12 +10,13 @@ import ( ) var BaseRecordBatchCreate = common.Shortcut{ - Service: "base", - Command: "+record-batch-create", - Description: "Batch create records", - Risk: "write", - Scopes: []string{"base:record:create"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-batch-create", + Description: "Batch create records", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:create"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/record_batch_update.go b/shortcuts/base/record_batch_update.go index 74a52cea8..734666a4d 100644 --- a/shortcuts/base/record_batch_update.go +++ b/shortcuts/base/record_batch_update.go @@ -10,12 +10,13 @@ import ( ) var BaseRecordBatchUpdate = common.Shortcut{ - Service: "base", - Command: "+record-batch-update", - Description: "Batch update records", - Risk: "write", - Scopes: []string{"base:record:update"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-batch-update", + Description: "Batch update records", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:update"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/record_delete.go b/shortcuts/base/record_delete.go index 281376d5e..fa8293563 100644 --- a/shortcuts/base/record_delete.go +++ b/shortcuts/base/record_delete.go @@ -10,12 +10,13 @@ import ( ) var BaseRecordDelete = common.Shortcut{ - Service: "base", - Command: "+record-delete", - Description: "Delete one or more records by ID", - Risk: "high-risk-write", - Scopes: []string{"base:record:delete"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-delete", + Description: "Delete one or more records by ID", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:delete"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go index 9b0e3e554..dc5ad0361 100644 --- a/shortcuts/base/record_get.go +++ b/shortcuts/base/record_get.go @@ -11,12 +11,13 @@ import ( ) var BaseRecordGet = common.Shortcut{ - Service: "base", - Command: "+record-get", - Description: "Get one or more records by ID", - Risk: "read", - Scopes: []string{"base:record:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-get", + Description: "Get one or more records by ID", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/record_history_list.go b/shortcuts/base/record_history_list.go index 896de0d42..a9d9c6ec5 100644 --- a/shortcuts/base/record_history_list.go +++ b/shortcuts/base/record_history_list.go @@ -10,12 +10,13 @@ import ( ) var BaseRecordHistoryList = common.Shortcut{ - Service: "base", - Command: "+record-history-list", - Description: "List record change history", - Risk: "read", - Scopes: []string{"base:history:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-history-list", + Description: "List record change history", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:history:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), @@ -37,7 +38,7 @@ var BaseRecordHistoryList = common.Shortcut{ if value := runtime.Int("max-version"); value > 0 { params["max_version"] = value } - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "record_history"), params, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime), "record_history"), params, nil) if err != nil { return err } diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index d32a14100..f78ea2545 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -12,12 +12,13 @@ import ( ) var BaseRecordList = common.Shortcut{ - Service: "base", - Command: "+record-list", - Description: "List records in a table", - Risk: "read", - Scopes: []string{"base:record:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-list", + Description: "List records in a table", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 05173b68e..8dffde01a 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -291,7 +291,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode() return common.NewDryRunAPI(). GET(path). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -303,7 +303,7 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common. return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_get"). Body(recordGetBatchBody(selection)). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -317,7 +317,7 @@ func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *comm return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -328,14 +328,14 @@ func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *comm return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). Set("record_id", recordID) } return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -345,7 +345,7 @@ func dryRunRecordBatchCreate(_ context.Context, runtime *common.RuntimeContext) return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_create"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -355,7 +355,7 @@ func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext) return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_update"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -367,7 +367,7 @@ func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *comm return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete"). Body(map[string]interface{}{"record_id_list": selection.recordIDs}). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -383,7 +383,7 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/record_history"). Params(params). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) } func dryRunRecordShareBatch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -391,7 +391,7 @@ func dryRunRecordShareBatch(_ context.Context, runtime *common.RuntimeContext) * return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/share_links/batch"). Body(map[string]interface{}{"record_ids": recordIDs}). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -427,7 +427,7 @@ func executeRecordShareBatch(runtime *common.RuntimeContext) error { "record_ids": recordIDs, } data, err := baseV3Call(runtime, "POST", - baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "share_links", "batch"), + baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "records", "share_links", "batch"), nil, body) if err != nil { return err @@ -466,7 +466,7 @@ func executeRecordList(runtime *common.RuntimeContext) error { if err := applyRecordQueryToParams(runtime, params); err != nil { return err } - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil) + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "records"), params, nil) if err != nil { return err } @@ -485,7 +485,7 @@ func executeRecordGet(runtime *common.RuntimeContext) error { if err != nil { return err } - result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection)) + result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection)) data, err := handleBaseAPIResult(result, err, "batch get records") if err != nil { return err @@ -508,7 +508,7 @@ func executeRecordSearch(runtime *common.RuntimeContext) error { if err != nil { return err } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "search"), nil, body) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "records", "search"), nil, body) if err != nil { return err } @@ -525,7 +525,7 @@ func executeRecordUpsert(runtime *common.RuntimeContext) error { if err != nil { return err } - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) if recordID := runtime.Str("record-id"); recordID != "" { data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, body) @@ -549,7 +549,7 @@ func executeRecordBatchCreate(runtime *common.RuntimeContext) error { if err != nil { return err } - result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_create"), nil, body) + result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "records", "batch_create"), nil, body) data, err := handleBaseAPIResult(result, err, "batch create records") if err != nil { return err @@ -564,7 +564,7 @@ func executeRecordBatchUpdate(runtime *common.RuntimeContext) error { if err != nil { return err } - result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_update"), nil, body) + result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "records", "batch_update"), nil, body) data, err := handleBaseAPIResult(result, err, "batch update records") if err != nil { return err @@ -578,7 +578,7 @@ func executeRecordDelete(runtime *common.RuntimeContext) error { if err != nil { return err } - result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{ + result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{ "record_id_list": selection.recordIDs, }) data, err := handleBaseAPIResult(result, err, "batch delete records") diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index 2c2561d13..6cb14ae2a 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -11,12 +11,13 @@ import ( ) var BaseRecordSearch = common.Shortcut{ - Service: "base", - Command: "+record-search", - Description: "Search records in a table", - Risk: "read", - Scopes: []string{"base:record:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-search", + Description: "Search records in a table", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/record_share_link_create.go b/shortcuts/base/record_share_link_create.go index 522369fcb..e7aef391a 100644 --- a/shortcuts/base/record_share_link_create.go +++ b/shortcuts/base/record_share_link_create.go @@ -10,12 +10,13 @@ import ( ) var BaseRecordShareLinkCreate = common.Shortcut{ - Service: "base", - Command: "+record-share-link-create", - Description: "Generate share links for one or more records (max 100 per request)", - Risk: "read", - Scopes: []string{"base:record:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-share-link-create", + Description: "Generate share links for one or more records (max 100 per request)", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index c469194df..3d03a9216 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -43,12 +43,13 @@ type baseAttachmentUploadTarget struct { } var BaseRecordUploadAttachment = common.Shortcut{ - Service: "base", - Command: "+record-upload-attachment", - Description: "Upload one or more local files and append the returned file_token values to a Base attachment cell", - Risk: "write", - Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-upload-attachment", + Description: "Upload one or more local files and append the returned file_token values to a Base attachment cell", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), @@ -72,12 +73,13 @@ var BaseRecordUploadAttachment = common.Shortcut{ } var BaseRecordDownloadAttachment = common.Shortcut{ - Service: "base", - Command: "+record-download-attachment", - Description: "Download Base record attachments by record-id, optionally filtering by file-token", - Risk: "read", - Scopes: []string{"base:record:read", "docs:document.media:download"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-download-attachment", + Description: "Download Base record attachments by record-id, optionally filtering by file-token", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:read", "docs:document.media:download"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), @@ -102,12 +104,13 @@ var BaseRecordDownloadAttachment = common.Shortcut{ } var BaseRecordRemoveAttachment = common.Shortcut{ - Service: "base", - Command: "+record-remove-attachment", - Description: "Remove one or more file_token values from a Base record attachment cell", - Risk: "high-risk-write", - Scopes: []string{"base:record:update", "base:field:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-remove-attachment", + Description: "Remove one or more file_token values from a Base record attachment cell", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:update", "base:field:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), @@ -142,7 +145,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont Desc("3-step orchestration: validate attachment field → upload local file(s) to Base → append uploaded file token(s) to the attachment cell"). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). Desc("[1] Read target field and ensure it is an attachment field"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). Set("field_id", runtime.Str("field-id")) if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) { @@ -151,7 +154,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont Body(map[string]interface{}{ "file_name": fileName, "parent_type": baseAttachmentParentType, - "parent_node": runtime.Str("base-token"), + "parent_node": baseTokenOrRaw(runtime), "size": "", }). POST("/open-apis/drive/v1/medias/upload_part"). @@ -174,7 +177,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont Body(map[string]interface{}{ "file_name": fileName, "parent_type": baseAttachmentParentType, - "parent_node": runtime.Str("base-token"), + "parent_node": baseTokenOrRaw(runtime), "file": "@" + filePath, "size": "", }) @@ -203,7 +206,7 @@ func dryRunRecordDownloadAttachment(_ context.Context, runtime *common.RuntimeCo POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/get_attachments"). Desc("[1] Read attachment metadata for the record"). Body(map[string]interface{}{"record_id_list": []string{runtime.Str("record-id")}}). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). GET("/open-apis/drive/v1/medias/:file_token/download"). Desc("[2] Download attachment media through the Base attachment flow"). @@ -218,7 +221,7 @@ func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeCont POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/remove_attachments"). Desc("Remove attachment file token(s) from the target attachment cell"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)) } @@ -270,7 +273,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { return err } - field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id")) + field, err := fetchBaseField(runtime, baseTokenOrRaw(runtime), baseTableID(runtime), runtime.Str("field-id")) if err != nil { return err } @@ -295,7 +298,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { } attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{ ParentType: baseAttachmentParentType, - ParentNode: runtime.Str("base-token"), + ParentNode: baseTokenOrRaw(runtime), }) if err != nil { return err @@ -304,7 +307,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { } body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, appendItems) - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "append_attachments"), nil, body) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "append_attachments"), nil, body) if err != nil { return err } @@ -317,7 +320,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error { if err != nil { return err } - field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id")) + field, err := fetchBaseField(runtime, baseTokenOrRaw(runtime), baseTableID(runtime), runtime.Str("field-id")) if err != nil { return err } @@ -329,7 +332,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error { resolvedFieldID = runtime.Str("field-id") } body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, fileTokenPatchItems(tokens)) - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "remove_attachments"), nil, body) + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseTokenOrRaw(runtime), "tables", baseTableID(runtime), "remove_attachments"), nil, body) if err != nil { return err } @@ -342,7 +345,7 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim if err != nil { return err } - attachments, err := fetchBaseAttachments(runtime, runtime.Str("base-token"), baseTableID(runtime), []string{runtime.Str("record-id")}) + attachments, err := fetchBaseAttachments(runtime, baseTokenOrRaw(runtime), baseTableID(runtime), []string{runtime.Str("record-id")}) if err != nil { return err } diff --git a/shortcuts/base/record_upsert.go b/shortcuts/base/record_upsert.go index abcea7e9f..b8bfb1aa2 100644 --- a/shortcuts/base/record_upsert.go +++ b/shortcuts/base/record_upsert.go @@ -10,12 +10,13 @@ import ( ) var BaseRecordUpsert = common.Shortcut{ - Service: "base", - Command: "+record-upsert", - Description: "Create or update a record", - Risk: "write", - Scopes: []string{"base:record:create", "base:record:update"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+record-upsert", + Description: "Create or update a record", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:record:create", "base:record:update"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/table_create.go b/shortcuts/base/table_create.go index 2175080e1..0366b427d 100644 --- a/shortcuts/base/table_create.go +++ b/shortcuts/base/table_create.go @@ -10,12 +10,13 @@ import ( ) var BaseTableCreate = common.Shortcut{ - Service: "base", - Command: "+table-create", - Description: "Create a table and optional fields/views", - Risk: "write", - Scopes: []string{"base:table:create", "base:field:read", "base:field:create", "base:field:update", "base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+table-create", + Description: "Create a table and optional fields/views", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:table:create", "base:field:read", "base:field:create", "base:field:update", "base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), {Name: "name", Desc: "table name", Required: true}, diff --git a/shortcuts/base/table_delete.go b/shortcuts/base/table_delete.go index 0426d5e3f..97039d928 100644 --- a/shortcuts/base/table_delete.go +++ b/shortcuts/base/table_delete.go @@ -10,13 +10,14 @@ import ( ) var BaseTableDelete = common.Shortcut{ - Service: "base", - Command: "+table-delete", - Description: "Delete a table by ID or name", - Risk: "high-risk-write", - Scopes: []string{"base:table:delete"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, + Service: "base", + Command: "+table-delete", + Description: "Delete a table by ID or name", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:table:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, Tips: []string{ `Example: lark-cli base +table-delete --base-token --table-id "Old Tasks" --yes`, "table-id accepts a table ID (tbl...) or the table name in the current Base.", diff --git a/shortcuts/base/table_get.go b/shortcuts/base/table_get.go index ac29a8ee3..5d044e064 100644 --- a/shortcuts/base/table_get.go +++ b/shortcuts/base/table_get.go @@ -10,13 +10,14 @@ import ( ) var BaseTableGet = common.Shortcut{ - Service: "base", - Command: "+table-get", - Description: "Get a table by ID or name", - Risk: "read", - Scopes: []string{"base:table:read", "base:field:read", "base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, + Service: "base", + Command: "+table-get", + Description: "Get a table by ID or name", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:table:read", "base:field:read", "base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, Tips: []string{ `Example: lark-cli base +table-get --base-token --table-id "Tasks"`, "table-id accepts a table ID (tbl...) or the table name in the current Base.", diff --git a/shortcuts/base/table_list.go b/shortcuts/base/table_list.go index 6c5e58520..58999a397 100644 --- a/shortcuts/base/table_list.go +++ b/shortcuts/base/table_list.go @@ -10,12 +10,13 @@ import ( ) var BaseTableList = common.Shortcut{ - Service: "base", - Command: "+table-list", - Description: "List tables in a base", - Risk: "read", - Scopes: []string{"base:table:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+table-list", + Description: "List tables in a base", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:table:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index f6a6c20d2..eeceed7eb 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -19,13 +19,13 @@ func dryRunTableList(_ context.Context, runtime *common.RuntimeContext) *common. return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables"). Params(map[string]interface{}{"offset": offset, "limit": limit}). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) } func dryRunTableGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")) } @@ -34,7 +34,7 @@ func dryRunTableCreate(_ context.Context, runtime *common.RuntimeContext) *commo d := common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables"). Body(body). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) return d } @@ -42,14 +42,14 @@ func dryRunTableUpdate(_ context.Context, runtime *common.RuntimeContext) *commo return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id"). Body(map[string]interface{}{"name": runtime.Str("name")}). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")) } func dryRunTableDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", runtime.Str("table-id")) } @@ -63,7 +63,7 @@ func executeTableList(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 100) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tables, total, err := listAllTables(runtime, baseToken, offset, limit) if err != nil { return err @@ -76,7 +76,7 @@ func executeTableList(runtime *common.RuntimeContext) error { } func executeTableGet(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := runtime.Str("table-id") table, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, nil) if err != nil { @@ -99,7 +99,7 @@ func executeTableGet(runtime *common.RuntimeContext) error { } func executeTableCreate(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) pc := newParseCtx(runtime) body, err := buildTableCreateBody(runtime, pc, runtime.Str("name")) if err != nil { @@ -224,7 +224,7 @@ func listEveryView(runtime *common.RuntimeContext, baseToken, tableID string) ([ } func executeTableUpdate(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := runtime.Str("table-id") data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, map[string]interface{}{"name": runtime.Str("name")}) if err != nil { @@ -235,7 +235,7 @@ func executeTableUpdate(runtime *common.RuntimeContext) error { } func executeTableDelete(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := runtime.Str("table-id") _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, nil) if err != nil { diff --git a/shortcuts/base/table_update.go b/shortcuts/base/table_update.go index 12d453e47..8c72a191f 100644 --- a/shortcuts/base/table_update.go +++ b/shortcuts/base/table_update.go @@ -10,12 +10,13 @@ import ( ) var BaseTableUpdate = common.Shortcut{ - Service: "base", - Command: "+table-update", - Description: "Rename a table by ID or name", - Risk: "write", - Scopes: []string{"base:table:update"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+table-update", + Description: "Rename a table by ID or name", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:table:update"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_create.go b/shortcuts/base/view_create.go index 9f803a361..808d52a2e 100644 --- a/shortcuts/base/view_create.go +++ b/shortcuts/base/view_create.go @@ -10,12 +10,13 @@ import ( ) var BaseViewCreate = common.Shortcut{ - Service: "base", - Command: "+view-create", - Description: "Create one or more views", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-create", + Description: "Create one or more views", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_delete.go b/shortcuts/base/view_delete.go index 493e726be..7bf225dd3 100644 --- a/shortcuts/base/view_delete.go +++ b/shortcuts/base/view_delete.go @@ -10,13 +10,14 @@ import ( ) var BaseViewDelete = common.Shortcut{ - Service: "base", - Command: "+view-delete", - Description: "Delete a view by ID or name", - Risk: "high-risk-write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + Service: "base", + Command: "+view-delete", + Description: "Delete a view by ID or name", + Risk: "high-risk-write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, Tips: []string{ baseHighRiskYesTip, `Example: lark-cli base +view-delete --base-token --table-id --view-id "Old View" --yes`, diff --git a/shortcuts/base/view_get.go b/shortcuts/base/view_get.go index 635c57cbd..3f5b1229d 100644 --- a/shortcuts/base/view_get.go +++ b/shortcuts/base/view_get.go @@ -10,14 +10,15 @@ import ( ) var BaseViewGet = common.Shortcut{ - Service: "base", - Command: "+view-get", - Description: "Get a view by ID or name", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewGet, + Service: "base", + Command: "+view-get", + Description: "Get a view by ID or name", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGet, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewGet(runtime) }, diff --git a/shortcuts/base/view_get_card.go b/shortcuts/base/view_get_card.go index 10fa43c70..206a6e978 100644 --- a/shortcuts/base/view_get_card.go +++ b/shortcuts/base/view_get_card.go @@ -10,14 +10,15 @@ import ( ) var BaseViewGetCard = common.Shortcut{ - Service: "base", - Command: "+view-get-card", - Description: "Get view card configuration", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewGetCard, + Service: "base", + Command: "+view-get-card", + Description: "Get view card configuration", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetCard, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewGetProperty(runtime, "card", "card") }, diff --git a/shortcuts/base/view_get_filter.go b/shortcuts/base/view_get_filter.go index 60ef0efeb..14b08e63f 100644 --- a/shortcuts/base/view_get_filter.go +++ b/shortcuts/base/view_get_filter.go @@ -10,14 +10,15 @@ import ( ) var BaseViewGetFilter = common.Shortcut{ - Service: "base", - Command: "+view-get-filter", - Description: "Get view filter configuration", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewGetFilter, + Service: "base", + Command: "+view-get-filter", + Description: "Get view filter configuration", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetFilter, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewGetProperty(runtime, "filter", "filter") }, diff --git a/shortcuts/base/view_get_group.go b/shortcuts/base/view_get_group.go index c201786f0..f25be7a47 100644 --- a/shortcuts/base/view_get_group.go +++ b/shortcuts/base/view_get_group.go @@ -10,14 +10,15 @@ import ( ) var BaseViewGetGroup = common.Shortcut{ - Service: "base", - Command: "+view-get-group", - Description: "Get view group configuration", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewGetGroup, + Service: "base", + Command: "+view-get-group", + Description: "Get view group configuration", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetGroup, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewGetProperty(runtime, "group", "group") }, diff --git a/shortcuts/base/view_get_sort.go b/shortcuts/base/view_get_sort.go index 99c7797dd..8e709f32d 100644 --- a/shortcuts/base/view_get_sort.go +++ b/shortcuts/base/view_get_sort.go @@ -10,14 +10,15 @@ import ( ) var BaseViewGetSort = common.Shortcut{ - Service: "base", - Command: "+view-get-sort", - Description: "Get view sort configuration", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewGetSort, + Service: "base", + Command: "+view-get-sort", + Description: "Get view sort configuration", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetSort, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewGetProperty(runtime, "sort", "sort") }, diff --git a/shortcuts/base/view_get_timebar.go b/shortcuts/base/view_get_timebar.go index 7575b50f2..1de3497a1 100644 --- a/shortcuts/base/view_get_timebar.go +++ b/shortcuts/base/view_get_timebar.go @@ -10,14 +10,15 @@ import ( ) var BaseViewGetTimebar = common.Shortcut{ - Service: "base", - Command: "+view-get-timebar", - Description: "Get view timebar configuration", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewGetTimebar, + Service: "base", + Command: "+view-get-timebar", + Description: "Get view timebar configuration", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetTimebar, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewGetProperty(runtime, "timebar", "timebar") }, diff --git a/shortcuts/base/view_get_visible_fields.go b/shortcuts/base/view_get_visible_fields.go index 3e97c633d..32f6bb71f 100644 --- a/shortcuts/base/view_get_visible_fields.go +++ b/shortcuts/base/view_get_visible_fields.go @@ -10,14 +10,15 @@ import ( ) var BaseViewGetVisibleFields = common.Shortcut{ - Service: "base", - Command: "+view-get-visible-fields", - Description: "Get view visible fields configuration", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewGetVisibleFields, + Service: "base", + Command: "+view-get-visible-fields", + Description: "Get view visible fields configuration", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetVisibleFields, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewGetProperty(runtime, "visible_fields", "visible_fields") }, diff --git a/shortcuts/base/view_list.go b/shortcuts/base/view_list.go index 141b26152..a65dcaf6c 100644 --- a/shortcuts/base/view_list.go +++ b/shortcuts/base/view_list.go @@ -10,12 +10,13 @@ import ( ) var BaseViewList = common.Shortcut{ - Service: "base", - Command: "+view-list", - Description: "List views in a table", - Risk: "read", - Scopes: []string{"base:view:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-list", + Description: "List views in a table", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_ops.go b/shortcuts/base/view_ops.go index df3b3ccac..83e0c4cac 100644 --- a/shortcuts/base/view_ops.go +++ b/shortcuts/base/view_ops.go @@ -13,7 +13,7 @@ import ( func dryRunViewBase(runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("table_id", baseTableID(runtime)). Set("view_id", runtime.Str("view-id")) } @@ -155,7 +155,7 @@ func executeViewList(runtime *common.RuntimeContext) error { offset = 0 } limit := common.ParseIntBounded(runtime, "limit", 1, 200) - views, total, err := listAllViews(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) + views, total, err := listAllViews(runtime, baseTokenOrRaw(runtime), baseTableID(runtime), offset, limit) if err != nil { return err } @@ -167,7 +167,7 @@ func executeViewList(runtime *common.RuntimeContext) error { } func executeViewGet(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, nil) @@ -180,7 +180,7 @@ func executeViewGet(runtime *common.RuntimeContext) error { func executeViewCreate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewItems, err := parseObjectList(pc, runtime.Str("json"), "json") if err != nil { @@ -199,7 +199,7 @@ func executeViewCreate(runtime *common.RuntimeContext) error { } func executeViewDelete(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, nil) @@ -211,7 +211,7 @@ func executeViewDelete(runtime *common.RuntimeContext) error { } func executeViewGetProperty(runtime *common.RuntimeContext, segment string, key string) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") data, err := baseV3CallAny(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, segment), nil, nil) @@ -224,7 +224,7 @@ func executeViewGetProperty(runtime *common.RuntimeContext, segment string, key func executeViewSetJSONObject(runtime *common.RuntimeContext, segment string, key string) error { pc := newParseCtx(runtime) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") body, err := parseJSONObject(pc, runtime.Str("json"), "json") @@ -241,7 +241,7 @@ func executeViewSetJSONObject(runtime *common.RuntimeContext, segment string, ke func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string, key string) error { pc := newParseCtx(runtime) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") raw, err := parseJSONValue(pc, runtime.Str("json"), "json") @@ -259,7 +259,7 @@ func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapp func executeViewSetVisibleFields(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") body, err := parseJSONObject(pc, runtime.Str("json"), "json") @@ -275,7 +275,7 @@ func executeViewSetVisibleFields(runtime *common.RuntimeContext) error { } func executeViewRename(runtime *common.RuntimeContext) error { - baseToken := runtime.Str("base-token") + baseToken := baseTokenOrRaw(runtime) tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, map[string]interface{}{"name": runtime.Str("name")}) diff --git a/shortcuts/base/view_rename.go b/shortcuts/base/view_rename.go index 22ab08a4b..994bf6edd 100644 --- a/shortcuts/base/view_rename.go +++ b/shortcuts/base/view_rename.go @@ -10,12 +10,13 @@ import ( ) var BaseViewRename = common.Shortcut{ - Service: "base", - Command: "+view-rename", - Description: "Rename a view by ID or name", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-rename", + Description: "Rename a view by ID or name", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_set_card.go b/shortcuts/base/view_set_card.go index 3a0a46d8a..494e3130e 100644 --- a/shortcuts/base/view_set_card.go +++ b/shortcuts/base/view_set_card.go @@ -10,12 +10,13 @@ import ( ) var BaseViewSetCard = common.Shortcut{ - Service: "base", - Command: "+view-set-card", - Description: "Set view card configuration", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-set-card", + Description: "Set view card configuration", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_set_filter.go b/shortcuts/base/view_set_filter.go index c3a97762b..69550403a 100644 --- a/shortcuts/base/view_set_filter.go +++ b/shortcuts/base/view_set_filter.go @@ -10,12 +10,13 @@ import ( ) var BaseViewSetFilter = common.Shortcut{ - Service: "base", - Command: "+view-set-filter", - Description: "Set view filter configuration", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-set-filter", + Description: "Set view filter configuration", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_set_group.go b/shortcuts/base/view_set_group.go index 5247ca947..c0dfeecd2 100644 --- a/shortcuts/base/view_set_group.go +++ b/shortcuts/base/view_set_group.go @@ -10,12 +10,13 @@ import ( ) var BaseViewSetGroup = common.Shortcut{ - Service: "base", - Command: "+view-set-group", - Description: "Set view group configuration", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-set-group", + Description: "Set view group configuration", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_set_sort.go b/shortcuts/base/view_set_sort.go index 743e722c3..cdd252ea5 100644 --- a/shortcuts/base/view_set_sort.go +++ b/shortcuts/base/view_set_sort.go @@ -10,12 +10,13 @@ import ( ) var BaseViewSetSort = common.Shortcut{ - Service: "base", - Command: "+view-set-sort", - Description: "Set view sort configuration", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-set-sort", + Description: "Set view sort configuration", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_set_timebar.go b/shortcuts/base/view_set_timebar.go index f037aacc8..52de77766 100644 --- a/shortcuts/base/view_set_timebar.go +++ b/shortcuts/base/view_set_timebar.go @@ -10,12 +10,13 @@ import ( ) var BaseViewSetTimebar = common.Shortcut{ - Service: "base", - Command: "+view-set-timebar", - Description: "Set view timebar configuration", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-set-timebar", + Description: "Set view timebar configuration", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/view_set_visible_fields.go b/shortcuts/base/view_set_visible_fields.go index e1b54b121..04e44004b 100644 --- a/shortcuts/base/view_set_visible_fields.go +++ b/shortcuts/base/view_set_visible_fields.go @@ -10,12 +10,13 @@ import ( ) var BaseViewSetVisibleFields = common.Shortcut{ - Service: "base", - Command: "+view-set-visible-fields", - Description: "Set view visible fields", - Risk: "write", - Scopes: []string{"base:view:write_only"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+view-set-visible-fields", + Description: "Set view visible fields", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 9bf9fe562..efd4ac08d 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -11,12 +11,13 @@ import ( ) var BaseWorkflowCreate = common.Shortcut{ - Service: "base", - Command: "+workflow-create", - Description: "Create a new workflow in a base", - Risk: "write", - Scopes: []string{"base:workflow:create"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+workflow-create", + Description: "Create a new workflow in a base", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:workflow:create"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before constructing steps", Required: true}, @@ -30,7 +31,7 @@ var BaseWorkflowCreate = common.Shortcut{ "Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } pc := newParseCtx(runtime) @@ -52,7 +53,7 @@ var BaseWorkflowCreate = common.Shortcut{ return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/workflows"). Body(body). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) @@ -65,7 +66,7 @@ var BaseWorkflowCreate = common.Shortcut{ return err } data, err := baseV3Call(runtime, "POST", - baseV3Path("bases", runtime.Str("base-token"), "workflows"), + baseV3Path("bases", baseTokenOrRaw(runtime), "workflows"), nil, body, ) diff --git a/shortcuts/base/workflow_disable.go b/shortcuts/base/workflow_disable.go index 945114ffe..d445bad9c 100644 --- a/shortcuts/base/workflow_disable.go +++ b/shortcuts/base/workflow_disable.go @@ -11,12 +11,13 @@ import ( ) var BaseWorkflowDisable = common.Shortcut{ - Service: "base", - Command: "+workflow-disable", - Description: "Disable a workflow in a base", - Risk: "write", - Scopes: []string{"base:workflow:update"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+workflow-disable", + Description: "Disable a workflow in a base", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, @@ -26,7 +27,7 @@ var BaseWorkflowDisable = common.Shortcut{ "Disable only changes workflow state; it does not delete the workflow or its steps.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } if strings.TrimSpace(runtime.Str("workflow-id")) == "" { @@ -37,12 +38,12 @@ var BaseWorkflowDisable = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id/disable"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { data, err := baseV3Call(runtime, "PATCH", - baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id"), "disable"), + baseV3Path("bases", baseTokenOrRaw(runtime), "workflows", runtime.Str("workflow-id"), "disable"), nil, map[string]interface{}{}, ) diff --git a/shortcuts/base/workflow_enable.go b/shortcuts/base/workflow_enable.go index 3cca469c3..ca862235e 100644 --- a/shortcuts/base/workflow_enable.go +++ b/shortcuts/base/workflow_enable.go @@ -11,12 +11,13 @@ import ( ) var BaseWorkflowEnable = common.Shortcut{ - Service: "base", - Command: "+workflow-enable", - Description: "Enable a workflow in a base", - Risk: "write", - Scopes: []string{"base:workflow:update"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+workflow-enable", + Description: "Enable a workflow in a base", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, @@ -27,7 +28,7 @@ var BaseWorkflowEnable = common.Shortcut{ "New workflows are created disabled; enable after creation only when the user wants it active.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } if strings.TrimSpace(runtime.Str("workflow-id")) == "" { @@ -38,12 +39,12 @@ var BaseWorkflowEnable = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id/enable"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { data, err := baseV3Call(runtime, "PATCH", - baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id"), "enable"), + baseV3Path("bases", baseTokenOrRaw(runtime), "workflows", runtime.Str("workflow-id"), "enable"), nil, map[string]interface{}{}, ) diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index ebb82f5da..bfa2d61ad 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -63,6 +63,44 @@ func TestBaseWorkflowExecuteGetValidate(t *testing.T) { }) } +func TestBaseWorkflowExecuteListCompact(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows/list", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "workflow_id": "wkf_1", + "title": "My Workflow", + "status": "enabled", + "trigger_type": "TimerTrigger", + "creator_id": "ou_creator", + "update_time": 123, + }, + }, + "has_more": false, + }, + }, + }) + if err := runShortcut(t, BaseWorkflowList, []string{"+workflow-list", "--base-token", "app_x", "--compact"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"workflow_id"`, `"wkf_1"`, `"title"`, `"status"`, `"trigger_type"`} { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %s: %s", want, got) + } + } + for _, notWant := range []string{`"creator_id"`, `"update_time"`} { + if strings.Contains(got, notWant) { + t.Fatalf("stdout should be compact, found %s: %s", notWant, got) + } + } +} + func TestBaseWorkflowExecuteCreate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/workflow_get.go b/shortcuts/base/workflow_get.go index 5e5abbb17..dc17d6fa3 100644 --- a/shortcuts/base/workflow_get.go +++ b/shortcuts/base/workflow_get.go @@ -11,12 +11,13 @@ import ( ) var BaseWorkflowGet = common.Shortcut{ - Service: "base", - Command: "+workflow-get", - Description: "Get a single workflow definition (including steps) from a base", - Risk: "read", - Scopes: []string{"base:workflow:read"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+workflow-get", + Description: "Get a single workflow definition (including steps) from a base", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:workflow:read"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, @@ -29,7 +30,7 @@ var BaseWorkflowGet = common.Shortcut{ "Read lark-base-workflow-schema.md when interpreting or reusing returned steps.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } if strings.TrimSpace(runtime.Str("workflow-id")) == "" { @@ -40,7 +41,7 @@ var BaseWorkflowGet = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { api := common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("workflow_id", runtime.Str("workflow-id")) if t := runtime.Str("user-id-type"); t != "" { api = api.Params(map[string]interface{}{"user_id_type": t}) @@ -53,7 +54,7 @@ var BaseWorkflowGet = common.Shortcut{ params = map[string]interface{}{"user_id_type": t} } data, err := baseV3Call(runtime, "GET", - baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id")), + baseV3Path("bases", baseTokenOrRaw(runtime), "workflows", runtime.Str("workflow-id")), params, nil, ) diff --git a/shortcuts/base/workflow_list.go b/shortcuts/base/workflow_list.go index 4277bc6bd..f58774778 100644 --- a/shortcuts/base/workflow_list.go +++ b/shortcuts/base/workflow_list.go @@ -11,23 +11,25 @@ import ( ) var BaseWorkflowList = common.Shortcut{ - Service: "base", - Command: "+workflow-list", - Description: "List all workflows in a base (auto-paginated)", - Risk: "read", - Scopes: []string{"base:workflow:read"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+workflow-list", + Description: "List all workflows in a base (auto-paginated)", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:workflow:read"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}}, {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"}, + {Name: "compact", Type: "bool", Desc: "return compact workflow_id/title/status/trigger_type fields for lower context cost; default returns full workflow metadata"}, }, Tips: []string{ "Returns workflow_id values with wkf prefix; pass those IDs to +workflow-get/enable/disable/update.", "This shortcut auto-paginates and returns all matched workflows.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } return nil @@ -42,7 +44,7 @@ var BaseWorkflowList = common.Shortcut{ return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/workflows/list"). Body(body). - Set("base_token", runtime.Str("base-token")) + Set("base_token", baseTokenOrRaw(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { var allItems []interface{} @@ -58,7 +60,7 @@ var BaseWorkflowList = common.Shortcut{ body["status"] = s } data, err := baseV3Call(runtime, "POST", - baseV3Path("bases", runtime.Str("base-token"), "workflows", "list"), + baseV3Path("bases", baseTokenOrRaw(runtime), "workflows", "list"), nil, body, ) @@ -78,9 +80,32 @@ var BaseWorkflowList = common.Shortcut{ pageToken = nextToken } runtime.Out(map[string]interface{}{ - "items": allItems, + "items": workflowListItems(runtime, allItems), "total": len(allItems), }, nil) return nil }, } + +func workflowListItems(runtime *common.RuntimeContext, items []interface{}) []interface{} { + if !runtime.Bool("compact") { + return items + } + keep := []string{"workflow_id", "title", "status", "trigger_type"} + out := make([]interface{}, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + out = append(out, item) + continue + } + compact := map[string]interface{}{} + for _, key := range keep { + if value, ok := m[key]; ok { + compact[key] = value + } + } + out = append(out, compact) + } + return out +} diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index ea13a3e17..1371b8059 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -11,12 +11,13 @@ import ( ) var BaseWorkflowUpdate = common.Shortcut{ - Service: "base", - Command: "+workflow-update", - Description: "Replace a workflow's full definition (title and/or steps) in a base", - Risk: "write", - Scopes: []string{"base:workflow:update"}, - AuthTypes: []string{"user", "bot"}, + Service: "base", + Command: "+workflow-update", + Description: "Replace a workflow's full definition (title and/or steps) in a base", + Risk: "write", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, @@ -32,7 +33,7 @@ var BaseWorkflowUpdate = common.Shortcut{ "Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("base-token")) == "" { + if strings.TrimSpace(baseTokenOrRaw(runtime)) == "" { return baseFlagErrorf("--base-token must not be blank") } if strings.TrimSpace(runtime.Str("workflow-id")) == "" { @@ -51,7 +52,7 @@ var BaseWorkflowUpdate = common.Shortcut{ return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). - Set("base_token", runtime.Str("base-token")). + Set("base_token", baseTokenOrRaw(runtime)). Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -61,7 +62,7 @@ var BaseWorkflowUpdate = common.Shortcut{ return err } data, err := baseV3Call(runtime, "PUT", - baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id")), + baseV3Path("bases", baseTokenOrRaw(runtime), "workflows", runtime.Str("workflow-id")), nil, body, ) From 20b3702ae035d2a2fd632c0bc890e7beb9161ec3 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Fri, 19 Jun 2026 20:51:33 +0800 Subject: [PATCH 13/17] fix(base): address PR #1426 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop overfitted comma-splitting and prefix heuristics, fix workflow trigger schema mismatches, restore deleted high-value workflow content, and clean up dead doc anchors flagged in the PR review. - Remove `normalizePluralReferenceValues` and the hidden `--record-ids` / `--field-names` / `--fields` aliases on `+record-get/list/search`; CSV-style values that contain user data (e.g. "收入,支出对比") are no longer silently split. Negative-path tests assert the aliases are rejected with `unknown flag`. - Remove `isBaseTableID`. `+field-list` / `+field-list-batch` now always resolve refs through `resolveTableRef`, so user table names like `tbl_orders` are no longer mistaken for table IDs. - `executeFieldSearchOptions` now emits only the key the user actually supplied (`field_id` or `field_name`) instead of stuffing the same value into both. Two new tests cover the by-id and by-name paths. - Workflow trigger docs: `trigger-change-record.md` example uses `condition_list` (was `condition`); all four record/reminder triggers call out that `condition_list: []` is rejected by the API and require `null` or omission. `common-types-and-refs.md` uses lowercase `receive_scene = "chat"`. - Workflow guide common-error table now headers itself as a frequency-ordered Top list so agents treat it as the first stop, and picks up four high-frequency rows that were dropped during the PR's doc split (`condition_list: []`, missing `client_token`, `Undefined Step Type`, select-with-text). - Restore the end-to-end IfElseBranch example (trigger → branch → branch actions → join → AI text) into `branch-if-else.md` so agents have a concrete model for `children.links` + `next` wiring. - Test fixtures use the real ID prefixes: dashboard IDs are `blk_*`, chart `block_id`s are `cht_*`. Fixes the dsh_/dash_/blk_a confusion flagged in the review. - Doc cleanup: re-link the orphan 717-line `lark-base-dashboard-block-get-data.md` from `lark-base-dashboard.md`; fix the dead "执行要点" anchor in `SKILL.md` (now points to `lark-base-dashboard-write.md`); add language tags to fenced code blocks (markdownlint MD040) and drop the duplicate `---` rule in `system-loop.md`. Change-Id: I9217be2ee0bc16f53c9462c8a9bd2d69b26f9ea1 --- shortcuts/base/base_dashboard_execute_test.go | 176 +++++++++--------- shortcuts/base/base_dryrun_ops_test.go | 36 ++-- shortcuts/base/base_execute_test.go | 92 ++++++--- shortcuts/base/field_ops.go | 33 ++-- shortcuts/base/helpers_test.go | 42 ----- shortcuts/base/record_get.go | 3 - shortcuts/base/record_list.go | 2 - shortcuts/base/record_ops.go | 76 +------- shortcuts/base/record_search.go | 2 - skills/lark-base/SKILL.md | 2 +- .../lark-base/references/formula-examples.md | 6 +- .../references/lark-base-dashboard.md | 4 +- .../references/lark-base-workflow-guide.md | 6 + .../workflow-steps/branch-if-else.md | 91 +++++++++ .../workflow-steps/common-types-and-refs.md | 6 +- .../references/workflow-steps/system-loop.md | 2 - .../workflow-steps/trigger-add-record.md | 4 +- .../workflow-steps/trigger-change-record.md | 4 +- .../workflow-steps/trigger-reminder.md | 2 +- .../workflow-steps/trigger-set-record.md | 2 +- 20 files changed, 304 insertions(+), 287 deletions(-) diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go index 62f56d0d9..bdf30e705 100644 --- a/shortcuts/base/base_dashboard_execute_test.go +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -25,8 +25,8 @@ func TestBaseDashboardExecuteList(t *testing.T) { "has_more": false, "total": 2, "items": []interface{}{ - map[string]interface{}{"dashboard_id": "dsh_001", "name": "销售报表"}, - map[string]interface{}{"dashboard_id": "dsh_002", "name": "运营看板"}, + map[string]interface{}{"dashboard_id": "blk_001", "name": "销售报表"}, + map[string]interface{}{"dashboard_id": "blk_002", "name": "运营看板"}, }, }, }, @@ -35,7 +35,7 @@ func TestBaseDashboardExecuteList(t *testing.T) { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"dsh_001"`) || !strings.Contains(got, `"dsh_002"`) { + if !strings.Contains(got, `"blk_001"`) || !strings.Contains(got, `"blk_002"`) { t.Fatalf("stdout=%s", got) } }) @@ -47,24 +47,24 @@ func TestBaseDashboardExecuteGet(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "dashboard_id": "dsh_001", + "dashboard_id": "blk_001", "name": "销售报表", "theme": map[string]interface{}{"theme_style": "default"}, "blocks": []interface{}{ - map[string]interface{}{"block_id": "blk_a", "block_name": "柱状图", "block_type": "column"}, + map[string]interface{}{"block_id": "cht_a", "block_name": "柱状图", "block_type": "column"}, }, }, }, }) - if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_001"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "blk_001"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"dsh_001"`) || !strings.Contains(got, `"销售报表"`) || !strings.Contains(got, `"dashboard"`) { + if !strings.Contains(got, `"blk_001"`) || !strings.Contains(got, `"销售报表"`) || !strings.Contains(got, `"dashboard"`) { t.Fatalf("stdout=%s", got) } } @@ -79,7 +79,7 @@ func TestBaseDashboardExecuteCreate(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "dashboard_id": "dsh_new", + "dashboard_id": "blk_new", "name": "新报表", }, }, @@ -88,7 +88,7 @@ func TestBaseDashboardExecuteCreate(t *testing.T) { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"dsh_new"`) || !strings.Contains(got, `"created": true`) { + if !strings.Contains(got, `"blk_new"`) || !strings.Contains(got, `"created": true`) { t.Fatalf("stdout=%s", got) } }) @@ -101,7 +101,7 @@ func TestBaseDashboardExecuteCreate(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "dashboard_id": "dsh_themed", + "dashboard_id": "blk_themed", "name": "主题报表", "theme": map[string]interface{}{"theme_style": "SimpleBlue"}, }, @@ -111,7 +111,7 @@ func TestBaseDashboardExecuteCreate(t *testing.T) { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"dsh_themed"`) || !strings.Contains(got, `"SimpleBlue"`) { + if !strings.Contains(got, `"blk_themed"`) || !strings.Contains(got, `"SimpleBlue"`) { t.Fatalf("stdout=%s", got) } }) @@ -123,16 +123,16 @@ func TestBaseDashboardExecuteUpdate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "PATCH", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "dashboard_id": "dsh_001", + "dashboard_id": "blk_001", "name": "更新后的名称", }, }, }) - if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--name", "更新后的名称"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "blk_001", "--name", "更新后的名称"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() @@ -145,17 +145,17 @@ func TestBaseDashboardExecuteUpdate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "PATCH", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "dashboard_id": "dsh_001", + "dashboard_id": "blk_001", "name": "报表", "theme": map[string]interface{}{"theme_style": "deepDark"}, }, }, }) - if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--theme-style", "deepDark"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "blk_001", "--theme-style", "deepDark"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() @@ -170,14 +170,14 @@ func TestBaseDashboardExecuteDelete(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "DELETE", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, }) - if err := runShortcut(t, BaseDashboardDelete, []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--yes"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardDelete, []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "blk_001", "--yes"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"dashboard_id": "dsh_001"`) { + if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"dashboard_id": "blk_001"`) { t.Fatalf("stdout=%s", got) } } @@ -190,24 +190,24 @@ func TestBaseDashboardBlockExecuteList(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ "has_more": false, "total": 2, "items": []interface{}{ - map[string]interface{}{"block_id": "blk_a", "name": "柱状图", "type": "column"}, - map[string]interface{}{"block_id": "blk_b", "name": "指标卡", "type": "statistics"}, + map[string]interface{}{"block_id": "cht_a", "name": "柱状图", "type": "column"}, + map[string]interface{}{"block_id": "cht_b", "name": "指标卡", "type": "statistics"}, }, }, }, }) - if err := runShortcut(t, BaseDashboardBlockList, []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_001"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardBlockList, []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "blk_001"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"blk_a"`) || !strings.Contains(got, `"blk_b"`) { + if !strings.Contains(got, `"cht_a"`) || !strings.Contains(got, `"cht_b"`) { t.Fatalf("stdout=%s", got) } }) @@ -220,11 +220,11 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks/cht_a", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "block_id": "blk_a", + "block_id": "cht_a", "name": "订单趋势", "type": "column", "layout": map[string]interface{}{"x": 0, "y": 0, "w": 12, "h": 6}, @@ -235,11 +235,11 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) { }, }, }) - if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "cht_a"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"blk_a"`) || !strings.Contains(got, `"block"`) || !strings.Contains(got, `"订单趋势"`) { + if !strings.Contains(got, `"cht_a"`) || !strings.Contains(got, `"block"`) || !strings.Contains(got, `"订单趋势"`) { t.Fatalf("stdout=%s", got) } }) @@ -252,17 +252,17 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "block_id": "blk_a", + "block_id": "cht_a", "name": "人员图表", "type": "pie", }, }, }) - if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", "--user-id-type", "union_id"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "cht_a", "--user-id-type", "union_id"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"blk_a"`) { + if !strings.Contains(got, `"cht_a"`) { t.Fatalf("stdout=%s", got) } }) @@ -307,11 +307,11 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "block_id": "blk_new", + "block_id": "cht_new", "name": "订单趋势", "type": "column", "layout": map[string]interface{}{"x": 0, "y": 0, "w": 12, "h": 6}, @@ -322,14 +322,14 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { }, }, }) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_001", "--name", "订单趋势", "--type", "column", "--data-config", `{"table_name":"订单表","count_all":true}`} if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"blk_new"`) || !strings.Contains(got, `"created": true`) { + if !strings.Contains(got, `"cht_new"`) || !strings.Contains(got, `"created": true`) { t.Fatalf("stdout=%s", got) } }) @@ -338,7 +338,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -348,7 +348,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { }, }, }) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_001", "--name", "销售总额", "--type", "statistics", "--data-config", `{"table_name":"数据表","series":[{"field_name":"数字","rollup":"SUM"}]}`} if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { @@ -364,7 +364,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -374,7 +374,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { }, }, }) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_001", "--name", "空图表", "--type", "line"} if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) @@ -387,7 +387,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { t.Run("invalid data-config json", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_001", "--name", "Test", "--type", "column", "--data-config", "not-json"} if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err == nil { t.Fatalf("expected error for invalid data-config JSON") @@ -401,11 +401,11 @@ func TestBaseDashboardBlockExecuteUpdate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "PATCH", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks/cht_a", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "block_id": "blk_a", + "block_id": "cht_a", "name": "订单趋势v2", "type": "column", "data_config": map[string]interface{}{ @@ -415,7 +415,7 @@ func TestBaseDashboardBlockExecuteUpdate(t *testing.T) { }, }, }) - args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "cht_a", "--name", "订单趋势v2", "--data-config", `{"table_name":"订单表2","count_all":true}`} if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { @@ -431,17 +431,17 @@ func TestBaseDashboardBlockExecuteUpdate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "PATCH", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks/cht_a", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "block_id": "blk_a", + "block_id": "cht_a", "name": "仅改名", "type": "column", }, }, }) - args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "cht_a", "--name", "仅改名"} if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) @@ -454,7 +454,7 @@ func TestBaseDashboardBlockExecuteUpdate(t *testing.T) { t.Run("invalid data-config json", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "cht_a", "--data-config", "bad-json"} if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err == nil { t.Fatalf("expected error for invalid data-config JSON") @@ -467,14 +467,14 @@ func TestBaseDashboardBlockExecuteDelete(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "DELETE", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks/cht_a", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, }) - if err := runShortcut(t, BaseDashboardBlockDelete, []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", "--yes"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardBlockDelete, []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "cht_a", "--yes"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"block_id": "blk_a"`) { + if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"block_id": "cht_a"`) { t.Fatalf("stdout=%s", got) } } @@ -496,11 +496,11 @@ func TestBaseDashboardDryRun_List(t *testing.T) { // TestBaseDashboardDryRun_Get tests the +dashboard-get --dry-run flag. func TestBaseDashboardDryRun_Get(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil { + if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "blk_1", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "dsh_1") { + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blk_1") || !strings.Contains(got, "blk_1") { t.Fatalf("stdout=%s", got) } } @@ -521,12 +521,12 @@ func TestBaseDashboardDryRun_Create(t *testing.T) { // TestBaseDashboardDryRun_Update tests the +dashboard-update --dry-run flag. func TestBaseDashboardDryRun_Update(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "更新名", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "blk_1", "--name", "更新名", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardUpdate, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "\"name\":\"更新名\"") { + if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/blk_1") || !strings.Contains(got, "\"name\":\"更新名\"") { t.Fatalf("stdout=%s", got) } } @@ -534,12 +534,12 @@ func TestBaseDashboardDryRun_Update(t *testing.T) { // TestBaseDashboardDryRun_Delete tests the +dashboard-delete --dry-run flag. func TestBaseDashboardDryRun_Delete(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "blk_1", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardDelete, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "dsh_1") { + if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/blk_1") || !strings.Contains(got, "blk_1") { t.Fatalf("stdout=%s", got) } } @@ -547,12 +547,12 @@ func TestBaseDashboardDryRun_Delete(t *testing.T) { // TestBaseDashboardBlockDryRun_List tests the +dashboard-block-list --dry-run flag. func TestBaseDashboardBlockDryRun_List(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--page-size", "10", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "blk_1", "--page-size", "10", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardBlockList, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") || !strings.Contains(got, "page_size=10") { + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks") || !strings.Contains(got, "page_size=10") { t.Fatalf("stdout=%s", got) } } @@ -560,12 +560,12 @@ func TestBaseDashboardBlockDryRun_List(t *testing.T) { // TestBaseDashboardBlockDryRun_Get tests the +dashboard-block-get --dry-run flag. func TestBaseDashboardBlockDryRun_Get(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "blk_1", "--block-id", "cht_a", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardBlockGet, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "union_id") || !strings.Contains(got, "blk_a") { + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks/cht_a") || !strings.Contains(got, "union_id") || !strings.Contains(got, "cht_a") { t.Fatalf("stdout=%s", got) } } @@ -573,12 +573,12 @@ func TestBaseDashboardBlockDryRun_Get(t *testing.T) { // TestBaseDashboardBlockDryRun_GetData tests the +dashboard-block-get-data --dry-run flag. func TestBaseDashboardBlockDryRun_GetData(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_a", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "cht_a", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardBlockGetData, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blocks/blk_a/data") || !strings.Contains(got, "blk_a") { + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blocks/cht_a/data") || !strings.Contains(got, "cht_a") { t.Fatalf("stdout=%s", got) } } @@ -586,12 +586,12 @@ func TestBaseDashboardBlockDryRun_GetData(t *testing.T) { // TestBaseDashboardBlockDryRun_Create tests the +dashboard-block-create --dry-run flag. func TestBaseDashboardBlockDryRun_Create(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "订单趋势", "--type", "column", "--data-config", `{"table_name":"订单表","count_all":true}`, "--user-id-type", "open_id", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_1", "--name", "订单趋势", "--type", "column", "--data-config", `{"table_name":"订单表","count_all":true}`, "--user-id-type", "open_id", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") || !strings.Contains(got, "\"name\":\"订单趋势\"") || !strings.Contains(got, "table_name") || !strings.Contains(got, "open_id") { + if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks") || !strings.Contains(got, "\"name\":\"订单趋势\"") || !strings.Contains(got, "table_name") || !strings.Contains(got, "open_id") { t.Fatalf("stdout=%s", got) } } @@ -599,12 +599,12 @@ func TestBaseDashboardBlockDryRun_Create(t *testing.T) { // TestBaseDashboardBlockDryRun_Update tests the +dashboard-block-update --dry-run flag. func TestBaseDashboardBlockDryRun_Update(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--name", "订单趋势v2", "--data-config", `{"table_name":"订单表2","count_all":true}`, "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "blk_1", "--block-id", "cht_a", "--name", "订单趋势v2", "--data-config", `{"table_name":"订单表2","count_all":true}`, "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "订单趋势v2") || !strings.Contains(got, "订单表2") { + if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks/cht_a") || !strings.Contains(got, "订单趋势v2") || !strings.Contains(got, "订单表2") { t.Fatalf("stdout=%s", got) } } @@ -612,12 +612,12 @@ func TestBaseDashboardBlockDryRun_Update(t *testing.T) { // TestBaseDashboardBlockDryRun_Delete tests the +dashboard-block-delete --dry-run flag. func TestBaseDashboardBlockDryRun_Delete(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "blk_1", "--block-id", "cht_a", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardBlockDelete, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "blk_a") { + if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks/cht_a") || !strings.Contains(got, "cht_a") { t.Fatalf("stdout=%s", got) } } @@ -628,7 +628,7 @@ func TestBaseDashboardBlockDryRun_Delete(t *testing.T) { func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) // 缺 table_name 且 series 与 count_all 同时存在 - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_1", "--name", "Bad", "--type", "column", "--data-config", `{"series":[{"field_name":"金额","rollup":"sum"}],"count_all":true}`, } @@ -644,10 +644,10 @@ func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) { // TestBaseDashboardBlockCreate_NoValidateFlagAllocs tests that --no-validate flag skips client-side validation. func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks", + reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_ok", "name": "OK", "type": "column"}}, }) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_1", "--name", "OK", "--type", "column", "--no-validate", "--data-config", `{"series":[{"field_name":"金额","rollup":"sum"}],"count_all":true}`, } @@ -663,7 +663,7 @@ func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) { func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) // 合法 JSON,但 rollup=COUNTA(不支持) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_1", "--name", "Bad", "--type", "column", "--data-config", `{"table_name":"T","series":[{"field_name":"金额","rollup":"COUNTA"}]}`, } @@ -684,7 +684,7 @@ func TestBaseDashboardBlockExecuteCreate_TextType(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -697,7 +697,7 @@ func TestBaseDashboardBlockExecuteCreate_TextType(t *testing.T) { }, }, }) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_001", "--name", "说明文字", "--type", "text", "--data-config", `{"text":"# 标题\n**加粗**"}`, } @@ -712,7 +712,7 @@ func TestBaseDashboardBlockExecuteCreate_TextType(t *testing.T) { t.Run("text block missing text field", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "blk_001", "--name", "Bad", "--type", "text", "--data-config", `{}`, } @@ -732,7 +732,7 @@ func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "PATCH", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks/blk_text", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -745,7 +745,7 @@ func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) { }, }, }) - args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text", + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "blk_text", "--name", "更新后的标题", "--data-config", `{"text":"# 新内容"}`, } @@ -763,7 +763,7 @@ func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) { // update 不传 type,不做强类型校验,直接透传给后端 reg.Register(&httpmock.Stub{ Method: "PATCH", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/blocks/blk_text", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -772,7 +772,7 @@ func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) { }, }, }) - args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text", + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "blk_001", "--block-id", "blk_text", "--data-config", `{"content":"xxx"}`, } // 不传 type,本地不做强校验,让后端处理 @@ -794,11 +794,11 @@ func TestBaseDashboardExecuteArrange(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/arrange", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blk_001/arrange", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "dashboard_id": "dsh_001", + "dashboard_id": "blk_001", "name": "测试仪表盘", "blocks": []interface{}{ map[string]interface{}{ @@ -813,7 +813,7 @@ func TestBaseDashboardExecuteArrange(t *testing.T) { }, }, }) - args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001"} + args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "blk_001"} if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } @@ -831,12 +831,12 @@ func TestBaseDashboardExecuteArrange(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "dashboard_id": "dsh_001", + "dashboard_id": "blk_001", "blocks": []interface{}{}, }, }, }) - args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--user-id-type", "union_id"} + args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "blk_001", "--user-id-type", "union_id"} if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } @@ -849,12 +849,12 @@ func TestBaseDashboardExecuteArrange(t *testing.T) { // TestBaseDashboardDryRun_Arrange tests the +dashboard-arrange --dry-run flag includes empty body. func TestBaseDashboardDryRun_Arrange(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"} + args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "blk_001", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"} if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/dsh_001/arrange") || !strings.Contains(got, "union_id") || !strings.Contains(got, "{}") { + if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/blk_001/arrange") || !strings.Contains(got, "union_id") || !strings.Contains(got, "{}") { t.Fatalf("stdout=%s", got) } } diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index cae725d8e..9599b8b04 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -52,15 +52,15 @@ func TestDryRunBaseBlockOps(t *testing.T) { createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil) assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`) - moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil) - assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`) + moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "cht_1"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/cht_1/move", `"parent_id":null`) - moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil) - assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`) + moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "cht_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/cht_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`) - renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil) - assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`) - assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1") + renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "cht_1", "name": "New name"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/cht_1/rename", `"name":"New name"`) + assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/cht_1") } func TestDryRunFieldOps(t *testing.T) { @@ -333,8 +333,8 @@ func TestDryRunDashboardOps(t *testing.T) { rt := newBaseTestRuntime( map[string]string{ "base-token": "app_x", - "dashboard-id": "dash_1", - "block-id": "blk_1", + "dashboard-id": "blk_1", + "block-id": "cht_1", "name": "Main", "theme-style": "light", "type": "bar", @@ -348,16 +348,16 @@ func TestDryRunDashboardOps(t *testing.T) { ) assertDryRunContains(t, dryRunDashboardList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards", "page_size=50", "page_token=pt_1") - assertDryRunContains(t, dryRunDashboardGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1") + assertDryRunContains(t, dryRunDashboardGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/blk_1") assertDryRunContains(t, dryRunDashboardCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/dashboards") - assertDryRunContains(t, dryRunDashboardUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/dash_1") - assertDryRunContains(t, dryRunDashboardDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/dash_1") - - assertDryRunContains(t, dryRunDashboardBlockList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks", "page_size=50", "page_token=pt_1") - assertDryRunContains(t, dryRunDashboardBlockGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1", "user_id_type=open_id") - assertDryRunContains(t, dryRunDashboardBlockCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks", "user_id_type=open_id") - assertDryRunContains(t, dryRunDashboardBlockUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1", "user_id_type=open_id") - assertDryRunContains(t, dryRunDashboardBlockDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1") + assertDryRunContains(t, dryRunDashboardUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/blk_1") + assertDryRunContains(t, dryRunDashboardDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/blk_1") + + assertDryRunContains(t, dryRunDashboardBlockList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks", "page_size=50", "page_token=pt_1") + assertDryRunContains(t, dryRunDashboardBlockGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks/cht_1", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks/cht_1", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/blk_1/blocks/cht_1") } func TestDryRunViewOps(t *testing.T) { diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index baf402c83..30fd682cb 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1048,6 +1048,16 @@ func TestBaseViewExecutePropertyActions(t *testing.T) { func TestBaseFieldExecuteCRUD(t *testing.T) { t.Run("list", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_x", "name": "Tasks"}, + }, "total": 1}, + }, + }) reg.Register(&httpmock.Stub{ Method: "GET", URL: "limit=1&offset=0", @@ -1098,6 +1108,17 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { t.Run("list batch multiple tables", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_a", "name": "Customers"}, + map[string]interface{}{"id": "tbl_b", "name": "Tasks"}, + }, "total": 2}, + }, + }) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields", @@ -1135,8 +1156,9 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_a", "name": "Customers"}, map[string]interface{}{"id": "tbl_orders", "name": "Orders"}, - }, "total": 1}, + }, "total": 2}, }, }) reg.Register(&httpmock.Stub{ @@ -1170,6 +1192,16 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { t.Run("list batch default keeps full fields", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_b", "name": "Tasks"}, + }, "total": 1}, + }, + }) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields", @@ -1690,28 +1722,21 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) - t.Run("list fields alias projects columns", func(t *testing.T) { - factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=Name&limit=100&offset=0", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"items": []interface{}{}, "has_more": false}, - }, - }) - if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout); err != nil { - t.Fatalf("err=%v", err) - } - }) - - t.Run("list fields alias works in dry-run", func(t *testing.T) { + t.Run("plural alias flags rejected", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) - if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout); err != nil { - t.Fatalf("err=%v", err) + listErr := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout) + if listErr == nil || !strings.Contains(listErr.Error(), "unknown flag: --fields") { + t.Fatalf("expected --fields rejected on +record-list, got err=%v", listErr) } - if got := stdout.String(); !strings.Contains(got, "field_id=Name") { - t.Fatalf("stdout=%s", got) + factory2, stdout2, _ := newExecuteFactory(t) + searchErr := runShortcut(t, BaseRecordSearch, []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--keyword", "x", "--search-field", "Name", "--field-names", "Name"}, factory2, stdout2) + if searchErr == nil || !strings.Contains(searchErr.Error(), "unknown flag: --field-names") { + t.Fatalf("expected --field-names rejected on +record-search, got err=%v", searchErr) + } + factory3, stdout3, _ := newExecuteFactory(t) + getErr := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-ids", "rec_1"}, factory3, stdout3) + if getErr == nil || !strings.Contains(getErr.Error(), "unknown flag: --record-ids") { + t.Fatalf("expected --record-ids rejected on +record-get, got err=%v", getErr) } }) @@ -3244,9 +3269,32 @@ func TestBaseFieldExecuteSearchOptions(t *testing.T) { if err := runShortcut(t, BaseFieldSearchOptions, []string{"+field-search-options", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_amount", "--keyword", "已", "--limit", "10"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"options"`) || !strings.Contains(got, `"已完成"`) { + got := stdout.String() + if !strings.Contains(got, `"options"`) || !strings.Contains(got, `"已完成"`) { t.Fatalf("stdout=%s", got) } + if !strings.Contains(got, `"field_id": "fld_amount"`) || strings.Contains(got, `"field_name"`) { + t.Fatalf("expected field_id only when --field-id given; stdout=%s", got) + } +} + +func TestBaseFieldExecuteSearchOptionsByName(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/Status/options", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"options": []interface{}{map[string]interface{}{"id": "opt_1", "name": "Done"}}, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldSearchOptions, []string{"+field-search-options", "--base-token", "app_x", "--table-id", "tbl_x", "--field-name", "Status"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"field_name": "Status"`) || strings.Contains(got, `"field_id"`) { + t.Fatalf("expected field_name only when --field-name given; stdout=%s", got) + } } func TestBaseViewExecutePropertyGettersAndExtendedSetters(t *testing.T) { diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index e81c0a032..952d58abb 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -205,28 +205,18 @@ func resolveFieldListTableRefs(runtime *common.RuntimeContext, baseToken string, return nil, baseValidationErrorf("--table-id is required") } resolved := make([]fieldListTableRef, 0, len(refs)) - needsTableList := false for _, raw := range refs { ref := strings.TrimSpace(raw) if ref == "" { return nil, baseValidationErrorf("--table-id must not be empty") } - if !isBaseTableID(ref) { - needsTableList = true - } resolved = append(resolved, fieldListTableRef{input: ref, id: ref}) } - if !needsTableList { - return resolved, nil - } tables, err := listEveryTable(runtime, baseToken) if err != nil { return nil, err } for i, tableRef := range resolved { - if isBaseTableID(tableRef.input) { - continue - } table, err := resolveTableRef(tables, tableRef.input) if err != nil { return nil, baseValidationErrorf("table %q not found; run +table-list to verify the table name or pass the tbl... ID", tableRef.input) @@ -241,10 +231,6 @@ func resolveFieldListTableRefs(runtime *common.RuntimeContext, baseToken string, return resolved, nil } -func isBaseTableID(ref string) bool { - return strings.HasPrefix(strings.TrimSpace(ref), "tbl") -} - // compactFields projects each field to the keys an agent needs for selection // (id / name / type / style, plus select option names), dropping formula // expressions and lookup internals that bloat agent context. Opt-in via @@ -370,12 +356,17 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error { if total == 0 { total = len(options) } - runtime.Out(map[string]interface{}{ - "field_id": fieldRef, - "field_name": fieldRef, - "keyword": fieldSearchOptionsKeyword(runtime), - "options": options, - "total": total, - }, nil) + out := map[string]interface{}{ + "keyword": fieldSearchOptionsKeyword(runtime), + "options": options, + "total": total, + } + if v := strings.TrimSpace(runtime.Str("field-id")); v != "" { + out["field_id"] = v + } + if v := strings.TrimSpace(runtime.Str("field-name")); v != "" { + out["field_name"] = v + } + runtime.Out(out, nil) return nil } diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index 86833916c..586ec9ebc 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -498,48 +498,6 @@ func TestCanonicalSelectAndCompareHelpers(t *testing.T) { } } -func TestNormalizePluralReferenceValues(t *testing.T) { - cases := []struct { - name string - in []string - want []string - }{ - {"repeated single values", []string{"fldA", "fldB"}, []string{"fldA", "fldB"}}, - {"json array", []string{`["fldA","fldB"]`}, []string{"fldA", "fldB"}}, - {"comma separated ids", []string{"fldA, fldB"}, []string{"fldA", "fldB"}}, - {"comma separated names", []string{"商品名称,SKU,单价"}, []string{"商品名称", "SKU", "单价"}}, - {"trailing comma ignored", []string{"recA,recB,"}, []string{"recA", "recB"}}, - {"fullwidth comma kept whole", []string{"销售额,单价"}, []string{"销售额,单价"}}, - {"mixed forms", []string{`["fldA"]`, "fldB,fldC", "Name"}, []string{"fldA", "fldB", "fldC", "Name"}}, - {"invalid json kept literal", []string{`[fldA`}, []string{`[fldA`}}, - {"blank dropped", []string{" ", "fldA"}, []string{"fldA"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := normalizePluralReferenceValues(tc.in); !reflect.DeepEqual(got, tc.want) { - t.Fatalf("got=%v want=%v", got, tc.want) - } - }) - } -} - -func TestRecordFlagAliasMergeAndDedupe(t *testing.T) { - fieldRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{ - "field-id": {"fldA"}, - "fields": {"fldA,fldB"}, - }, nil, nil) - if got := recordFieldFlags(fieldRT); !reflect.DeepEqual(got, []string{"fldA", "fldB"}) { - t.Fatalf("field flags=%v", got) - } - recordRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{ - "record-id": {"recA"}, - "record-ids": {`["recA","recB"]`}, - }, nil, nil) - if got := recordIDFlags(recordRT); !reflect.DeepEqual(got, []string{"recA", "recB"}) { - t.Fatalf("record flags=%v", got) - } -} - func TestFieldSearchOptionsKeywordQueryAlias(t *testing.T) { ctx := context.Background() if err := BaseFieldSearchOptions.Validate(ctx, newBaseTestRuntime( diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go index dc5ad0361..a9b766ace 100644 --- a/shortcuts/base/record_get.go +++ b/shortcuts/base/record_get.go @@ -22,10 +22,7 @@ var BaseRecordGet = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, - {Name: "record-ids", Type: "string_array", Hidden: true}, {Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"}, - {Name: "field-names", Type: "string_array", Hidden: true}, - {Name: "fields", Type: "string_array", Hidden: true}, {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, recordReadFormatFlag(), }, diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index f78ea2545..a0fdc5eea 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -23,8 +23,6 @@ var BaseRecordList = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), recordListFieldRefFlag(), - {Name: "field-names", Type: "string_array", Hidden: true}, - {Name: "fields", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), recordFilterAliasFlag(), diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 8dffde01a..8a344831a 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -5,7 +5,6 @@ package base import ( "context" - "encoding/json" "net/url" "strconv" "strings" @@ -46,11 +45,11 @@ func validateRecordSelection(runtime *common.RuntimeContext) error { } func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) { - recordIDs := recordIDFlags(runtime) - fieldIDs := recordFieldFlags(runtime) + recordIDs := runtime.StrArray("record-id") + fieldIDs := runtime.StrArray("field-id") jsonRaw := strings.TrimSpace(runtime.Str("json")) if len(recordIDs) > 0 && jsonRaw != "" { - return recordSelection{}, baseFlagErrorf("--record-id/--record-ids and --json are mutually exclusive") + return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive") } if jsonRaw != "" { pc := newParseCtx(runtime) @@ -146,73 +145,6 @@ func normalizeRecordGetSelectFields(values interface{}) ([]string, error) { }) } -func recordIDFlags(runtime *common.RuntimeContext) []string { - return mergeReferenceSources( - runtime.StrArray("record-id"), - normalizePluralReferenceValues(runtime.StrArray("record-ids")), - ) -} - -func recordFieldFlags(runtime *common.RuntimeContext) []string { - return mergeReferenceSources( - runtime.StrArray("field-id"), - normalizePluralReferenceValues(runtime.StrArray("field-names")), - normalizePluralReferenceValues(runtime.StrArray("fields")), - ) -} - -// mergeReferenceSources concatenates flag sources, dropping values from later -// sources that an earlier source already provided — so the same reference -// passed through both a canonical flag and its plural alias is sent only once. -// Duplicates inside a single source are kept on purpose: repeating a value on -// one flag is a user mistake that downstream validation should keep rejecting. -func mergeReferenceSources(sources ...[]string) []string { - var out []string - seenBefore := map[string]struct{}{} - for _, source := range sources { - for _, value := range source { - if _, ok := seenBefore[value]; ok { - continue - } - out = append(out, value) - } - for _, value := range source { - seenBefore[value] = struct{}{} - } - } - return out -} - -// normalizePluralReferenceValues expands each raw value of a plural alias flag -// (--field-names / --fields / --record-ids) into individual references. Plural -// flags carry list semantics, so an ASCII comma is always a separator (eval -// traces show comma-joined values are exclusively lists, mostly field names); -// a JSON string array is also accepted. Names that contain a literal ASCII -// comma must use the singular flag (--field-id), which never splits. Fullwidth -// "," and "、" are untouched, so ordinary Chinese names are safe here too. -func normalizePluralReferenceValues(values []string) []string { - var out []string - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - if strings.HasPrefix(value, "[") { - var parsed []string - if err := json.Unmarshal([]byte(value), &parsed); err == nil { - out = append(out, parsed...) - continue - } - } - for _, part := range strings.Split(value, ",") { - if part = strings.TrimSpace(part); part != "" { - out = append(out, part) - } - } - } - return out -} - func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) { var rawItems []interface{} switch typed := values.(type) { @@ -443,7 +375,7 @@ func validateRecordJSON(runtime *common.RuntimeContext) error { } func recordListFields(runtime *common.RuntimeContext) []string { - return recordFieldFlags(runtime) + return runtime.StrArray("field-id") } func executeRecordList(runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index 6cb14ae2a..b1f8f5d68 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -26,8 +26,6 @@ var BaseRecordSearch = common.Shortcut{ {Name: "query", Desc: "deprecated alias for --keyword", Hidden: true}, {Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"}, recordListFieldRefFlag(), - {Name: "field-names", Type: "string_array", Hidden: true}, - {Name: "fields", Type: "string_array", Hidden: true}, recordListViewRefFlag(), recordFilterFlag(), recordFilterAliasFlag(), diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 1eff55c39..4ca64813b 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -141,7 +141,7 @@ done ### Dashboard / Workflow / Role -- Dashboard 的复杂点是 block 的 `data_config`:创建/更新 block 前读 [lark-base-dashboard-block-data-config.md](references/lark-base-dashboard-block-data-config.md),组件串行创建;布局/换图表类型/删除具名图表等操作要点见 [lark-base-dashboard.md](references/lark-base-dashboard.md) 的「执行要点」。`+dashboard-block-get-data` 只返回图表数据,元数据用 `+dashboard-block-get`。 +- Dashboard 的复杂点是 block 的 `data_config`:创建/更新 block 前读 [lark-base-dashboard-block-data-config.md](references/lark-base-dashboard-block-data-config.md),组件串行创建;布局/换图表类型/删除具名图表等写操作要点见 [lark-base-dashboard-write.md](references/lark-base-dashboard-write.md)。`+dashboard-block-get-data` 只返回图表数据,元数据用 `+dashboard-block-get`。 - Workflow 的复杂点是 `steps`:先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),用其中的最短路径和场景表完成查询/启停/常见创建修改;需要具体 step 字段再按需读 schema 小文件;创建后 `+workflow-get` 回读验证。 - Role 的复杂点是权限 JSON:先读 [lark-base-role-guide.md](references/lark-base-role-guide.md)(含安全边界),权限 JSON SSOT 读 [role-config.md](references/role-config.md);删除角色、关闭高级权限前确认目标和影响。 diff --git a/skills/lark-base/references/formula-examples.md b/skills/lark-base/references/formula-examples.md index 508b799a6..9de0d701a 100644 --- a/skills/lark-base/references/formula-examples.md +++ b/skills/lark-base/references/formula-examples.md @@ -17,7 +17,7 @@ **Formula**: -``` +```text IF( [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1, "Sold " & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & " orders", @@ -51,7 +51,7 @@ IF( **Formula**: -``` +```text [OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(",") ``` @@ -80,7 +80,7 @@ IF( **Formula**: -``` +```text FIRST( [Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName] ) diff --git a/skills/lark-base/references/lark-base-dashboard.md b/skills/lark-base/references/lark-base-dashboard.md index d0e1c5dc6..047a219a8 100644 --- a/skills/lark-base/references/lark-base-dashboard.md +++ b/skills/lark-base/references/lark-base-dashboard.md @@ -16,7 +16,7 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成组 | 查看仪表盘整体 | `+dashboard-get` | 返回 dashboard 元数据和组件列表 | | 列出组件 | `+dashboard-block-list` | 只要组件清单时优先用它 | | 查看组件元数据 | `+dashboard-block-get` | 返回组件 name/type/data_config/layout 等 | -| 读取图表最终数据 | `+dashboard-block-get-data` | 返回图表计算结果;不需要 `--dashboard-id`,也不返回 name/type/data_config | +| 读取图表最终数据 | `+dashboard-block-get-data` | 返回图表计算结果;不需要 `--dashboard-id`,也不返回 name/type/data_config;返回字段含义详见 [lark-base-dashboard-block-get-data.md](lark-base-dashboard-block-get-data.md) | ## 读取路径 @@ -38,5 +38,5 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成组 ## 注意 - dashboard 的 ID 是 Base 资源目录层的 `blk...`;dashboard 内组件的 `block_id` 通常是 `cht...`,不要混用。 -- `+dashboard-block-get-data` 只适合 chart/statistics 等有计算结果的组件;需要元数据先用 `+dashboard-block-get`。 +- `+dashboard-block-get-data` 只适合 chart/statistics 等有计算结果的组件;需要元数据先用 `+dashboard-block-get`;解析返回结构时按需读 [lark-base-dashboard-block-get-data.md](lark-base-dashboard-block-get-data.md)。 - 创建/更新/删除/重排等写操作读 [lark-base-dashboard-write.md](lark-base-dashboard-write.md)。 diff --git a/skills/lark-base/references/lark-base-workflow-guide.md b/skills/lark-base/references/lark-base-workflow-guide.md index 1cf0f0959..336bafcd0 100644 --- a/skills/lark-base/references/lark-base-workflow-guide.md +++ b/skills/lark-base/references/lark-base-workflow-guide.md @@ -142,6 +142,8 @@ Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。 ## 常见错误 +下表是构造 workflow 时最常踩的坑,按出现频率排序,按错误信息或场景就近匹配;任何一条命中都先停下来按右列处理,不要继续往下加节点。 + | 错误 | 处理 | |---|---| | 查询/启停也读 schema | 停下,直接用 `+workflow-list/get/enable/disable` | @@ -151,6 +153,10 @@ Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。 | SetRecordAction/FindRecordAction 缺定位条件 | 提供 `filter_info` 或 `ref_info` | | HTTPClientAction 后续节点引用不到字段 | `response_type: "json"` 时填写 `response_value` 声明输出字段 | | Loop 内引用错路径 | 用 `$.{loopStepId}.item.{fieldId}` 和 `$.{loopStepId}.index` | +| `recordInfo.conditions must be non-empty`(`condition_list` 为 `[]`)| 改用 `null` 或省略该字段;空数组会被 API 拒绝 | +| `client token is empty` | 每次请求传入唯一 `client_token`(时间戳或随机字符串)| +| `Undefined Step Type` | 用 schema 路由表里的真实 type,例如 `AddRecordTrigger` 而非 `CreateRecordTrigger` | +| `valueType 'text' not allowed for fieldType '3'` | select 字段值用 `value_type: "option"`,不要用 `text` | ## 参考 diff --git a/skills/lark-base/references/workflow-steps/branch-if-else.md b/skills/lark-base/references/workflow-steps/branch-if-else.md index 31bd43892..3d7b74c10 100644 --- a/skills/lark-base/references/workflow-steps/branch-if-else.md +++ b/skills/lark-base/references/workflow-steps/branch-if-else.md @@ -28,6 +28,97 @@ |------|------|------| | `condition` | 是 | OrGroup 判断条件,结构为 `(A and B) or (C and D)` | +## 端到端示例 + +完整 workflow 示例:触发器 → IfElseBranch(金额大于 1000)→ 两个分支 action → 汇合后 AI 生成日报。重点看 `children.links` 与 `next` 怎么配合。 + +```json +{ + "title": "新订单自动通知", + "steps": [ + { + "id": "step_1", + "type": "AddRecordTrigger", + "title": "当「订单表」新增记录时触发", + "next": "step_2", + "data": { "table_name": "订单表", "watched_field_name": "订单编号" } + }, + { + "id": "step_2", + "type": "IfElseBranch", + "title": "判断订单金额是否大于 1000", + "children": { + "links": [ + { "kind": "if_true", "to": "step_3" }, + { "kind": "if_false", "to": "step_4" } + ] + }, + "next": "step_5", + "data": { + "condition": { + "conjunction": "or", + "conditions": [{ + "conjunction": "and", + "conditions": [{ + "left_value": { "value_type": "ref", "value": "$.step_1.fldXXX" }, + "operator": "isGreater", + "right_value": [{ "value_type": "number", "value": 1000 }] + }] + }] + } + } + }, + { + "id": "step_3", + "type": "LarkMessageAction", + "title": "通知主管审批大额订单", + "next": null, + "data": { + "receiver": [{ "value_type": "ref", "value": "$.step_1.fldOwner" }], + "send_to_everyone": false, + "title": [{ "value_type": "text", "value": "大额订单提醒" }], + "content": [ + { "value_type": "text", "value": "新订单金额为:" }, + { "value_type": "ref", "value": "$.step_1.fldAmount" }, + { "value_type": "text", "value": "元,请及时审批。" } + ], + "btn_list": [] + } + }, + { + "id": "step_4", + "type": "SetRecordAction", + "title": "自动标记小额订单为已通过", + "next": null, + "data": { + "table_name": "订单表", + "ref_info": { "step_id": "step_1" }, + "field_values": [ + { "field_name": "审批状态", "value": [{ "value_type": "text", "value": "已通过" }] } + ] + } + }, + { + "id": "step_5", + "type": "GenerateAiTextAction", + "title": "AI 生成订单处理日报", + "next": null, + "data": { + "prompt": [ + { "value_type": "text", "value": "请根据以下订单信息生成一份简要的处理日报:" }, + { "value_type": "ref", "value": "$.step_1.fldXXX" } + ] + } + } + ] +} +``` + +接线要点: + +- 分支节点用 `children.links` 写跳转关系(`if_true` / `if_false`),用 `next` 写"两个分支汇合后"的后继节点;分支内的 action 节点用 `next: null` 表明该分支到此结束、回到 `step_2.next` 汇合点。 +- Action 节点(step_3 / step_4 / step_5)只写 `next`,不写 `children`。 + --- ## 相关 diff --git a/skills/lark-base/references/workflow-steps/common-types-and-refs.md b/skills/lark-base/references/workflow-steps/common-types-and-refs.md index 5b038bd15..570955a54 100644 --- a/skills/lark-base/references/workflow-steps/common-types-and-refs.md +++ b/skills/lark-base/references/workflow-steps/common-types-and-refs.md @@ -25,7 +25,7 @@ #### 引用路径格式 -``` +```text $.{stepId} $.{stepId}.{pathId} $.{stepId}.{pathId}.{childPathId} @@ -118,7 +118,7 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId} 条件限制: -- 若场景为单聊(`receive_scene = "Chat"`),则 `SenderGroup` 和 `MessageLink` 不可用 +- 若场景为单聊(`receive_scene = "chat"`),则 `SenderGroup` 和 `MessageLink` 不可用 --- @@ -280,7 +280,7 @@ HTTPClientAction 的输出取决于 `response_type`: 下钻引用示例: -``` +```text $.{stepId}.{fieldId} → 字段值本身 $.{stepId}.{fieldId}.fieldId → 字段 ID(string) $.{stepId}.{fieldId}.fieldName → 字段名称(string) diff --git a/skills/lark-base/references/workflow-steps/system-loop.md b/skills/lark-base/references/workflow-steps/system-loop.md index 93ee75444..0bb5493da 100644 --- a/skills/lark-base/references/workflow-steps/system-loop.md +++ b/skills/lark-base/references/workflow-steps/system-loop.md @@ -18,8 +18,6 @@ --- ---- - ## 相关 - 返回 [Workflow schema index](../lark-base-workflow-schema.md) diff --git a/skills/lark-base/references/workflow-steps/trigger-add-record.md b/skills/lark-base/references/workflow-steps/trigger-add-record.md index 272e4b6c8..3760f5879 100644 --- a/skills/lark-base/references/workflow-steps/trigger-add-record.md +++ b/skills/lark-base/references/workflow-steps/trigger-add-record.md @@ -5,7 +5,7 @@ "table_name": "订单表", "watched_field_name": "状态", "trigger_control_list": ["pasteUpdate", "automationBatchUpdate"], - "condition_list": [] /* AndCondition 数组 */ + "condition_list": null /* 不需要过滤时填 null 或省略;不要传 [] */ } ``` @@ -14,7 +14,7 @@ | `table_name` | 是 | 监控的数据表名 | | `watched_field_name` | 是 | 监控的字段名 | | `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` / `openAPIBatchUpdate` | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系;不需要过滤时填 `null` 或省略,**不要传 `[]`**——空数组会被 API 拒绝(`recordInfo.conditions must be non-empty`) | --- diff --git a/skills/lark-base/references/workflow-steps/trigger-change-record.md b/skills/lark-base/references/workflow-steps/trigger-change-record.md index 4ca0847b9..d906ba927 100644 --- a/skills/lark-base/references/workflow-steps/trigger-change-record.md +++ b/skills/lark-base/references/workflow-steps/trigger-change-record.md @@ -6,7 +6,7 @@ { "table_name": "任务表", "trigger_control_list": [], - "condition": null + "condition_list": null } ``` @@ -14,7 +14,7 @@ |------|------|------| | `table_name` | 是 | 监控的数据表名 | | `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系;不需要过滤时填 `null` 或省略,**不要传 `[]`**——空数组会被 API 拒绝(`recordInfo.conditions must be non-empty`) | --- diff --git a/skills/lark-base/references/workflow-steps/trigger-reminder.md b/skills/lark-base/references/workflow-steps/trigger-reminder.md index 96fa4324e..b7da72601 100644 --- a/skills/lark-base/references/workflow-steps/trigger-reminder.md +++ b/skills/lark-base/references/workflow-steps/trigger-reminder.md @@ -20,7 +20,7 @@ | `offset` | 是 | 提前/延后的偏移量(正数=提前,负数=延后;范围由 `unit` 决定):`MINUTE` ∈ {0, 5, 15, 30, -5, -15, -30};`HOUR` ∈ [-6, -1] ∪ [1, 6];`DAY` ∈ [-7, 7];`WEEK` ∈ [-7, -1] ∪ [1, 7];`MONTH` ∈ [-7, -1] ∪ [1, 7] | | `hour` | 是 | 触发小时 (0-23),默认 9 | | `minute` | 是 | 触发分钟 (0-59),默认 0 | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系;不需要过滤时填 `null` 或省略,**不要传 `[]`**——空数组会被 API 拒绝(`recordInfo.conditions must be non-empty`) | --- diff --git a/skills/lark-base/references/workflow-steps/trigger-set-record.md b/skills/lark-base/references/workflow-steps/trigger-set-record.md index 6b0bc3bf9..8965d5ed5 100644 --- a/skills/lark-base/references/workflow-steps/trigger-set-record.md +++ b/skills/lark-base/references/workflow-steps/trigger-set-record.md @@ -20,7 +20,7 @@ | `record_watch_info` | 否 | 记录级过滤条件(修改前值匹配),为空则监听全部 | | `field_watch_info` | 是 | 字段级监控条件列表,至少一个 | | `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` | -| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 | +| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系;不需要过滤时填 `null` 或省略,**不要传 `[]`**——空数组会被 API 拒绝(`recordInfo.conditions must be non-empty`) | `FieldWatchItem`: From 58857956533e532b7fab8b2886fc580768d94562 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Fri, 19 Jun 2026 21:51:07 +0800 Subject: [PATCH 14/17] refactor(base): drop client-side table ref resolution in field-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend already accepts both table IDs and table names on `/bases/:token/tables/:table_id/fields`, so the extra `tables/list` round-trip the client did to translate names to IDs is dead weight — remove `resolveFieldListTableRefs` and the `fieldListTableRef` struct. `+field-list` and `+field-list-batch` now pass `--table-id` straight through, and `+field-list-batch` no longer emits the redundant `table_ref` / `table_name` keys. Tests updated to reflect the simpler request shape (no more tables/list stub) while still covering both ID-only and mixed ID/name inputs. Change-Id: I9abb806144cd23decd0f3453a27e09899f9a491f --- shortcuts/base/base_execute_test.go | 62 ++--------------------- shortcuts/base/field_ops.go | 78 ++++++++--------------------- 2 files changed, 26 insertions(+), 114 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 30fd682cb..e11618a56 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1048,16 +1048,6 @@ func TestBaseViewExecutePropertyActions(t *testing.T) { func TestBaseFieldExecuteCRUD(t *testing.T) { t.Run("list", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"tables": []interface{}{ - map[string]interface{}{"id": "tbl_x", "name": "Tasks"}, - }, "total": 1}, - }, - }) reg.Register(&httpmock.Stub{ Method: "GET", URL: "limit=1&offset=0", @@ -1076,21 +1066,11 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) - t.Run("list resolves table name", func(t *testing.T) { + t.Run("list passes table name through to API", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"tables": []interface{}{ - map[string]interface{}{"id": "tbl_orders", "name": "Orders"}, - }, "total": 1}, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields", + URL: "/open-apis/base/v3/bases/app_x/tables/Orders/fields", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{"fields": []interface{}{ @@ -1108,17 +1088,6 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { t.Run("list batch multiple tables", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"tables": []interface{}{ - map[string]interface{}{"id": "tbl_a", "name": "Customers"}, - map[string]interface{}{"id": "tbl_b", "name": "Tasks"}, - }, "total": 2}, - }, - }) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields", @@ -1148,19 +1117,8 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) - t.Run("list batch resolves table names", func(t *testing.T) { + t.Run("list batch passes mixed ids and names", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"tables": []interface{}{ - map[string]interface{}{"id": "tbl_a", "name": "Customers"}, - map[string]interface{}{"id": "tbl_orders", "name": "Orders"}, - }, "total": 2}, - }, - }) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields", @@ -1173,7 +1131,7 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { }) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields", + URL: "/open-apis/base/v3/bases/app_x/tables/Orders/fields", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{"fields": []interface{}{ @@ -1185,23 +1143,13 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { t.Fatalf("err=%v", err) } got := stdout.String() - if !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_orders"`) || !strings.Contains(got, `"table_ref": "Orders"`) || !strings.Contains(got, `"table_name": "Orders"`) { + if !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "Orders"`) { t.Fatalf("stdout=%s", got) } }) t.Run("list batch default keeps full fields", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"tables": []interface{}{ - map[string]interface{}{"id": "tbl_b", "name": "Tasks"}, - }, "total": 1}, - }, - }) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields", diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 952d58abb..3954e0c97 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -10,12 +10,6 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -type fieldListTableRef struct { - input string - id string - name string -} - func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") if offset < 0 { @@ -142,11 +136,11 @@ func executeFieldList(runtime *common.RuntimeContext) error { } limit := common.ParseIntBounded(runtime, "limit", 1, 200) baseToken := baseTokenOrRaw(runtime) - tableRef, err := resolveFieldListTableRefs(runtime, baseToken, []string{baseTableID(runtime)}) - if err != nil { - return err + tableID := strings.TrimSpace(baseTableID(runtime)) + if tableID == "" { + return baseValidationErrorf("--table-id is required") } - fields, total, err := listAllFields(runtime, baseToken, tableRef[0].id, offset, limit) + fields, total, err := listAllFields(runtime, baseToken, tableID, offset, limit) if err != nil { return err } @@ -167,13 +161,21 @@ func executeFieldListBatch(runtime *common.RuntimeContext) error { } limit := common.ParseIntBounded(runtime, "limit", 1, 200) baseToken := baseTokenOrRaw(runtime) - tableRefs, err := resolveFieldListTableRefs(runtime, baseToken, runtime.StrArray("table-id")) - if err != nil { - return err + rawRefs := runtime.StrArray("table-id") + if len(rawRefs) == 0 { + return baseValidationErrorf("--table-id is required") } - results := make([]map[string]interface{}, 0, len(tableRefs)) - for _, tableRef := range tableRefs { - fields, total, err := listAllFields(runtime, baseToken, tableRef.id, offset, limit) + tableIDs := make([]string, 0, len(rawRefs)) + for _, raw := range rawRefs { + ref := strings.TrimSpace(raw) + if ref == "" { + return baseValidationErrorf("--table-id must not be empty") + } + tableIDs = append(tableIDs, ref) + } + results := make([]map[string]interface{}, 0, len(tableIDs)) + for _, tableID := range tableIDs { + fields, total, err := listAllFields(runtime, baseToken, tableID, offset, limit) if err != nil { return err } @@ -183,54 +185,16 @@ func executeFieldListBatch(runtime *common.RuntimeContext) error { if runtime.Bool("compact") { fields = compactFields(fields) } - result := map[string]interface{}{ - "table_id": tableRef.id, + results = append(results, map[string]interface{}{ + "table_id": tableID, "fields": fields, "total": total, - } - if tableRef.input != tableRef.id { - result["table_ref"] = tableRef.input - } - if tableRef.name != "" { - result["table_name"] = tableRef.name - } - results = append(results, result) + }) } runtime.Out(map[string]interface{}{"tables": results, "total": len(results)}, nil) return nil } -func resolveFieldListTableRefs(runtime *common.RuntimeContext, baseToken string, refs []string) ([]fieldListTableRef, error) { - if len(refs) == 0 { - return nil, baseValidationErrorf("--table-id is required") - } - resolved := make([]fieldListTableRef, 0, len(refs)) - for _, raw := range refs { - ref := strings.TrimSpace(raw) - if ref == "" { - return nil, baseValidationErrorf("--table-id must not be empty") - } - resolved = append(resolved, fieldListTableRef{input: ref, id: ref}) - } - tables, err := listEveryTable(runtime, baseToken) - if err != nil { - return nil, err - } - for i, tableRef := range resolved { - table, err := resolveTableRef(tables, tableRef.input) - if err != nil { - return nil, baseValidationErrorf("table %q not found; run +table-list to verify the table name or pass the tbl... ID", tableRef.input) - } - tableIDValue := tableID(table) - if tableIDValue == "" { - return nil, baseValidationErrorf("table %q resolved without a table ID; run +table-list and pass the tbl... ID", tableRef.input) - } - resolved[i].id = tableIDValue - resolved[i].name = tableNameFromMap(table) - } - return resolved, nil -} - // compactFields projects each field to the keys an agent needs for selection // (id / name / type / style, plus select option names), dropping formula // expressions and lookup internals that bloat agent context. Opt-in via From a7e25b5a630e1bf8befb033eb8888320812f4988 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Sat, 20 Jun 2026 10:57:21 +0800 Subject: [PATCH 15/17] fix(base): remove unused table helper Remove the unreferenced listEveryTable helper so lint and deadcode gates no longer report unused code. Change-Id: Ieb3e4d103fa37894aa942baa7f8f53e63445feae --- shortcuts/base/table_ops.go | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index d95731deb..6445faa73 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -187,24 +187,6 @@ func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) ( return items, nil } -func listEveryTable(runtime *common.RuntimeContext, baseToken string) ([]map[string]interface{}, error) { - const pageLimit = 100 - offset := 0 - items := []map[string]interface{}{} - for { - batch, total, err := listAllTables(runtime, baseToken, offset, pageLimit) - if err != nil { - return nil, err - } - items = append(items, batch...) - if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) { - break - } - offset += len(batch) - } - return items, nil -} - func listEveryView(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { const pageLimit = 100 offset := 0 From 5b415d1bf427e66a1ffb11580225eb8483e208e3 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Sat, 20 Jun 2026 22:47:52 +0800 Subject: [PATCH 16/17] fix(base): address review gate issues Add wiki conditional scope for field-list-batch, strengthen compact and typed-error coverage, and restore a complex workflow guide example. Change-Id: Ie02ec227d6a3b6eefc4853165022f251c8272326 --- shortcuts/base/base_execute_test.go | 34 ++++ shortcuts/base/base_shortcuts_test.go | 62 +++++-- shortcuts/base/field_list_batch.go | 13 +- shortcuts/base/record_query.go | 2 +- .../references/lark-base-workflow-guide.md | 163 ++++++++++++++++++ 5 files changed, 256 insertions(+), 18 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index e11618a56..72155af99 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1066,6 +1066,40 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("list compact projects single table fields", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{ + "id": "fld_status", + "name": "Status", + "type": "select", + "formula": "SHOULD_DROP", + "options": []interface{}{ + map[string]interface{}{"name": "Todo", "color": "red"}, + }, + }, + }, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_x", "--compact"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"fld_status"`) || !strings.Contains(got, `"Status"`) || !strings.Contains(got, `"options"`) || !strings.Contains(got, `"Todo"`) { + t.Fatalf("stdout=%s", got) + } + for _, notWant := range []string{`"color"`, `"formula"`, "SHOULD_DROP"} { + if strings.Contains(got, notWant) { + t.Fatalf("stdout should be compact, found %s: %s", notWant, got) + } + } + }) + t.Run("list passes table name through to API", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 25acef44f..17b23f1e1 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -81,6 +81,27 @@ func assertBasePaginationValidation(t *testing.T, err error, param string) { } } +func assertValidationProblem(t *testing.T, err error, param string) { + t.Helper() + if err == nil { + t.Fatal("expected validation error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if validationErr.Param != param { + t.Fatalf("param=%q, want %q", validationErr.Param, param) + } +} + func TestFieldSearchOptionsAlias(t *testing.T) { runtime := newBaseTestRuntime(map[string]string{"field-name": "Status"}, nil, nil) if got := fieldSearchOptionsRef(runtime); got != "Status" { @@ -198,6 +219,15 @@ func TestShortcutsCatalog(t *testing.T) { } } +func TestFieldListBatchIncludesWikiConditionalScope(t *testing.T) { + for _, scope := range BaseFieldListBatch.ConditionalScopes { + if scope == "wiki:node:retrieve" { + return + } + } + t.Fatalf("BaseFieldListBatch conditional scopes = %#v, want wiki:node:retrieve", BaseFieldListBatch.ConditionalScopes) +} + func TestShortcutsDryRunCoverage(t *testing.T) { for _, shortcut := range Shortcuts() { if shortcut.DryRun == nil { @@ -1184,19 +1214,25 @@ func TestBaseRecordValidate(t *testing.T) { map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`}, nil, nil, - )); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") { - t.Fatalf("err=%v", err) + )); err != nil { + assertValidationProblem(t, err, "--filter-json") + } else { + t.Fatal("expected validation error, got nil") } if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays( map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`}, nil, nil, nil, - )); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") { - t.Fatalf("err=%v", err) + )); err != nil { + assertValidationProblem(t, err, "--sort-json") + } else { + t.Fatal("expected validation error, got nil") } - if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") { - t.Fatalf("err=%v", err) + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err != nil { + assertValidationProblem(t, err, "--keyword") + } else { + t.Fatal("expected validation error, got nil") } if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays( map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"}, @@ -1219,8 +1255,10 @@ func TestBaseRecordValidate(t *testing.T) { map[string][]string{"search-field": {"Name"}}, nil, nil, - )); err == nil || !strings.Contains(err.Error(), "use only one") { - t.Fatalf("err=%v", err) + )); err != nil { + assertValidationProblem(t, err, "--query") + } else { + t.Fatal("expected validation error, got nil") } if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime( map[string]string{ @@ -1238,8 +1276,10 @@ func TestBaseRecordValidate(t *testing.T) { map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"}, nil, nil, - )); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") { - t.Fatalf("err=%v", err) + )); err != nil { + assertValidationProblem(t, err, "--json") + } else { + t.Fatal("expected validation error, got nil") } } @@ -1360,7 +1400,7 @@ func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) { { name: "dashboard block list", shortcut: BaseDashboardBlockList, - runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "dashboard-id": "dash_1", "page-size": "101"}, nil, nil), + runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "dashboard-id": "blk_1", "page-size": "101"}, nil, nil), param: "--page-size", }, } diff --git a/shortcuts/base/field_list_batch.go b/shortcuts/base/field_list_batch.go index 0b1e62f8c..702fcd222 100644 --- a/shortcuts/base/field_list_batch.go +++ b/shortcuts/base/field_list_batch.go @@ -10,12 +10,13 @@ import ( ) var BaseFieldListBatch = common.Shortcut{ - Service: "base", - Command: "+field-list-batch", - Description: "List fields for multiple tables in one call", - Risk: "read", - Scopes: []string{"base:field:read"}, - AuthTypes: authTypes(), + Service: "base", + Command: "+field-list-batch", + Description: "List fields for multiple tables in one call", + Risk: "read", + ConditionalScopes: []string{"wiki:node:retrieve"}, + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), {Name: "table-id", Type: "string_array", Desc: tableRefFlag(true).Desc + "; repeat to list fields for multiple tables", Required: true}, diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go index df4ff2815..b2947e1e1 100644 --- a/shortcuts/base/record_query.go +++ b/shortcuts/base/record_query.go @@ -134,7 +134,7 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label) } if len(sortConfig) > recordSortMaxCount { - return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig)) + return nil, baseFlagErrorf("%s supports at most %d sort conditions; got %d", label, recordSortMaxCount, len(sortConfig)) } return sortConfig, nil } diff --git a/skills/lark-base/references/lark-base-workflow-guide.md b/skills/lark-base/references/lark-base-workflow-guide.md index 336bafcd0..d53918d83 100644 --- a/skills/lark-base/references/lark-base-workflow-guide.md +++ b/skills/lark-base/references/lark-base-workflow-guide.md @@ -132,6 +132,169 @@ Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。 } ``` +## 复杂例子:定时查找、循环分支并发送消息 + +需要组合多类节点时,保留一个端到端草图对齐 `children.links` 与 `next`。构造前按本例先画出完整节点集合,再一次性读取 `trigger-timer.md`、`action-find-record.md`、`system-loop.md`、`branch-switch.md`、`action-lark-message.md`、`action-generate-ai-text.md` 和 common refs。 + +```json +{ + "client_token": "wf-daily-order-notify", + "title": "每日订单分级通知", + "steps": [ + { + "id": "step_timer", + "type": "TimerTrigger", + "title": "每天早上9点触发", + "next": "step_find_orders", + "data": { + "rule": "DAILY", + "start_time": "2025-01-01 09:00", + "is_never_end": true + } + }, + { + "id": "step_find_orders", + "type": "FindRecordAction", + "title": "查找待处理订单", + "next": "step_loop", + "data": { + "table_name": "订单表", + "field_names": ["订单号", "客户名称", "金额", "销售负责人"], + "should_proceed_when_no_results": false, + "filter_info": { + "conjunction": "and", + "conditions": [ + { + "field_name": "状态", + "operator": "is", + "value": [{ "value_type": "option", "value": { "name": "待处理" } }] + } + ] + } + } + }, + { + "id": "step_loop", + "type": "Loop", + "title": "遍历每个订单", + "children": { + "links": [{ "kind": "loop_start", "to": "step_classify" }] + }, + "next": "step_summary", + "data": { + "loop_mode": "continue", + "max_loop_times": 500, + "data": [{ "value_type": "ref", "value": "$.step_find_orders.fieldRecords" }] + } + }, + { + "id": "step_classify", + "type": "SwitchBranch", + "title": "按金额分类", + "children": { + "links": [ + { "kind": "case", "to": "step_vip_notify", "label": "vip", "desc": "金额大于等于10万" }, + { "kind": "case", "to": "step_normal_notify", "label": "normal", "desc": "金额小于10万" } + ] + }, + "next": null, + "data": { + "mode": "exclusive", + "no_match_action": "fail", + "child_branch_list": [ + { + "name": "VIP订单", + "condition": { + "conjunction": "or", + "conditions": [ + { + "conjunction": "and", + "conditions": [ + { + "left_value": { "value_type": "ref", "value": "$.step_loop.item.fldAmount" }, + "operator": "isGreaterEqual", + "right_value": [{ "value_type": "number", "value": 100000 }] + } + ] + } + ] + } + }, + { + "name": "普通订单", + "condition": { + "conjunction": "or", + "conditions": [ + { + "conjunction": "and", + "conditions": [ + { + "left_value": { "value_type": "ref", "value": "$.step_loop.item.fldAmount" }, + "operator": "isLess", + "right_value": [{ "value_type": "number", "value": 100000 }] + } + ] + } + ] + } + } + ] + } + }, + { + "id": "step_vip_notify", + "type": "LarkMessageAction", + "title": "VIP订单通知", + "next": null, + "data": { + "receiver": [{ "value_type": "ref", "value": "$.step_loop.item.fldSales" }], + "send_to_everyone": false, + "title": [{ "value_type": "text", "value": "VIP大额订单" }], + "content": [ + { "value_type": "text", "value": "您有一笔 VIP 订单,金额:" }, + { "value_type": "ref", "value": "$.step_loop.item.fldAmount" }, + { "value_type": "text", "value": ",客户:" }, + { "value_type": "ref", "value": "$.step_loop.item.fldCustomer" } + ], + "btn_list": [] + } + }, + { + "id": "step_normal_notify", + "type": "LarkMessageAction", + "title": "普通订单通知", + "next": null, + "data": { + "receiver": [{ "value_type": "ref", "value": "$.step_loop.item.fldSales" }], + "send_to_everyone": false, + "title": [{ "value_type": "text", "value": "新订单通知" }], + "content": [ + { "value_type": "text", "value": "您有一笔新订单,金额:" }, + { "value_type": "ref", "value": "$.step_loop.item.fldAmount" } + ], + "btn_list": [] + } + }, + { + "id": "step_summary", + "type": "GenerateAiTextAction", + "title": "生成日报", + "next": null, + "data": { + "prompt": [{ "value_type": "text", "value": "请生成今日订单处理日报" }] + } + } + ] +} +``` + +接线要点: + +- `step_loop.children.links` 用 `loop_start` 指向循环体入口,`step_loop.next` 表示循环全部结束后的后继。 +- 循环体内使用 `$.step_loop.item.` 引用当前记录字段,字段 ID 先用 `+field-list --compact` 确认。 +- `SwitchBranch.children.links` 的 `case` 目标与 `child_branch_list` 顺序保持一致;两路以内也可改用 `IfElseBranch`。 +- 分支内 action 的 `next:null` 表示该分支结束后回到所属 branch/loop 语义,不要给 action 再写 `children`。 + ## 修改现有 workflow 1. `+workflow-list` 后按标题定位 `workflow_id`。 From 1cca939881b8ffaf8571877076ab534705c4de3b Mon Sep 17 00:00:00 2001 From: mobaijie Date: Sat, 20 Jun 2026 23:04:38 +0800 Subject: [PATCH 17/17] docs(lark-base): clarify workflow guide step ref reading Reword the "complex example" intro to emphasize on-demand reading based on the sketched node set rather than reading all listed files in one go. Convert bare filenames in both example sections to markdown links pointing at workflow-steps/ so readers can navigate to the actual ref files. Change-Id: I085f857aa232d5d7ab215310a78f2d3315dbe5de --- skills/lark-base/references/lark-base-workflow-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/lark-base/references/lark-base-workflow-guide.md b/skills/lark-base/references/lark-base-workflow-guide.md index d53918d83..63b8e5e02 100644 --- a/skills/lark-base/references/lark-base-workflow-guide.md +++ b/skills/lark-base/references/lark-base-workflow-guide.md @@ -98,7 +98,7 @@ Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。 ## 最小例子:新增记录后发送消息 -只读 `trigger-add-record.md` 和 `action-lark-message.md` 即可。 +只读 [workflow-steps/trigger-add-record.md](workflow-steps/trigger-add-record.md) 和 [workflow-steps/action-lark-message.md](workflow-steps/action-lark-message.md) 即可。 ```json { @@ -134,7 +134,7 @@ Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。 ## 复杂例子:定时查找、循环分支并发送消息 -需要组合多类节点时,保留一个端到端草图对齐 `children.links` 与 `next`。构造前按本例先画出完整节点集合,再一次性读取 `trigger-timer.md`、`action-find-record.md`、`system-loop.md`、`branch-switch.md`、`action-lark-message.md`、`action-generate-ai-text.md` 和 common refs。 +需要组合多类节点时,先画一个端到端草图,明确 `children.links` 与 `next` 的衔接,再按草图涉及的节点类型读取对应的 ref(例如本例用到 [workflow-steps/trigger-timer.md](workflow-steps/trigger-timer.md)、[workflow-steps/action-find-record.md](workflow-steps/action-find-record.md)、[workflow-steps/system-loop.md](workflow-steps/system-loop.md)、[workflow-steps/branch-switch.md](workflow-steps/branch-switch.md)、[workflow-steps/action-lark-message.md](workflow-steps/action-lark-message.md)、[workflow-steps/action-generate-ai-text.md](workflow-steps/action-generate-ai-text.md) 以及 [workflow-steps/common-types-and-refs.md](workflow-steps/common-types-and-refs.md))。 ```json {