From b4381371094365ac83bfcfbb3195ff272d602a9f Mon Sep 17 00:00:00 2001 From: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:47:00 +0800 Subject: [PATCH] feat(base): add +dashboard-block-batch-create shortcut for creating multiple dashboard blocks in one call Introduces BaseDashboardBlockBatchCreate shortcut that accepts a JSON array of block specs and creates them sequentially via a single CLI invocation. Includes local data_config validation, compact output summary, dry-run support, unit tests, e2e dry-run test, and updated skill docs to prefer batch over repeated single-block creates when two or more blocks are needed. --- shortcuts/base/base_dashboard_execute_test.go | 81 ++++++++ shortcuts/base/base_shortcuts_test.go | 15 +- .../base/dashboard_block_batch_create.go | 189 ++++++++++++++++++ shortcuts/base/shortcuts.go | 1 + skills/lark-base/SKILL.md | 4 +- .../references/dashboard-block-data-config.md | 2 + .../references/lark-base-dashboard.md | 37 ++-- ...ashboard_block_batch_create_dryrun_test.go | 49 +++++ tests/cli_e2e/base/coverage.md | 1 + 9 files changed, 357 insertions(+), 22 deletions(-) create mode 100644 shortcuts/base/dashboard_block_batch_create.go create mode 100644 tests/cli_e2e/base/base_dashboard_block_batch_create_dryrun_test.go diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go index 62f56d0d..e9f1cd50 100644 --- a/shortcuts/base/base_dashboard_execute_test.go +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -395,6 +395,73 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) { }) } +// TestBaseDashboardBlockExecuteBatchCreate tests the +dashboard-block-batch-create command. +func TestBaseDashboardBlockExecuteBatchCreate(t *testing.T) { + t.Run("creates blocks sequentially with compact output", func(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", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_stat", + "name": "订单数", + "type": "statistics", + "data_config": map[string]interface{}{ + "table_name": "订单表", + "count_all": true, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_line", + "name": "月度趋势", + "type": "line", + "data_config": map[string]interface{}{ + "table_name": "订单表", + }, + }, + }, + }) + blocks := `[ + {"name":"订单数","type":"statistics","data_config":{"table_name":"订单表","count_all":true}}, + {"name":"月度趋势","type":"line","data_config":{"table_name":"订单表","series":[{"field_name":"金额","rollup":"sum"}],"group_by":[{"field_name":"月份","mode":"integrated","sort":{"type":"group","order":"asc"}}]}} + ]` + args := []string{"+dashboard-block-batch-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--blocks", blocks} + if err := runShortcut(t, BaseDashboardBlockBatchCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"created_count": 2`) || !strings.Contains(got, `"blk_stat"`) || !strings.Contains(got, `"blk_line"`) { + t.Fatalf("stdout=%s", got) + } + if strings.Contains(got, "data_config") { + t.Fatalf("batch output should stay compact, stdout=%s", got) + } + }) + + t.Run("rejects invalid block data config", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-batch-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--blocks", `[{"name":"Bad","type":"column","data_config":{"series":[{"field_name":"金额","rollup":"COUNTA"}]}}]`, + } + err := runShortcut(t, BaseDashboardBlockBatchCreate, args, factory, stdout) + if err == nil { + t.Fatalf("expected validation error") + } + if got := err.Error(); !strings.Contains(got, "blocks[0].data_config") || !strings.Contains(got, "data_config 校验失败") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + // TestBaseDashboardBlockExecuteUpdate tests the +dashboard-block-update command. func TestBaseDashboardBlockExecuteUpdate(t *testing.T) { t.Run("update name and data-config", func(t *testing.T) { @@ -596,6 +663,20 @@ func TestBaseDashboardBlockDryRun_Create(t *testing.T) { } } +// TestBaseDashboardBlockDryRun_BatchCreate tests the +dashboard-block-batch-create --dry-run flag. +func TestBaseDashboardBlockDryRun_BatchCreate(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + blocks := `[{"name":"订单数","type":"statistics","data_config":{"table_name":"订单表","count_all":true}},{"name":"月度趋势","type":"line","data_config":{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated","sort":{"type":"group","order":"asc"}}]}}]` + args := []string{"+dashboard-block-batch-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--blocks", blocks, "--user-id-type", "open_id", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockBatchCreate, 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, "\"blocks\"") || !strings.Contains(got, "\"sequential\":true") || !strings.Contains(got, "open_id") { + t.Fatalf("stdout=%s", got) + } +} + // TestBaseDashboardBlockDryRun_Update tests the +dashboard-block-update --dry-run flag. func TestBaseDashboardBlockDryRun_Update(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 4dd2769c..554172fa 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -170,7 +170,7 @@ func TestShortcutsCatalog(t *testing.T) { "+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list", "+form-submit", "+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange", - "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete", + "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-batch-create", "+dashboard-block-update", "+dashboard-block-delete", } if len(shortcuts) != len(want) { t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want)) @@ -548,6 +548,19 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) { "sequentially", }, }, + { + name: "dashboard block batch create", + shortcut: BaseDashboardBlockBatchCreate, + wantTips: []string{ + "lark-cli base +dashboard-block-batch-create --base-token --dashboard-id --blocks", + "two or more blocks", + "sequentially", + "+table-list and +field-list", + "not table_id or field_id", + "dashboard-block-data-config.md as the SSOT", + "intentionally compact", + }, + }, { name: "dashboard block update", shortcut: BaseDashboardBlockUpdate, diff --git a/shortcuts/base/dashboard_block_batch_create.go b/shortcuts/base/dashboard_block_batch_create.go new file mode 100644 index 00000000..e930c259 --- /dev/null +++ b/shortcuts/base/dashboard_block_batch_create.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +type dashboardBlockCreateSpec struct { + Index int + Body map[string]interface{} +} + +var BaseDashboardBlockBatchCreate = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-batch-create", + Description: "Create multiple blocks in a dashboard sequentially", + Risk: "write", + Scopes: []string{"base:dashboard:create"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "blocks", Desc: "JSON array of block objects: [{\"name\":\"...\",\"type\":\"statistics\",\"data_config\":{...}}]. Use @file.json for long dashboards.", Required: true}, + {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-batch-create --base-token --dashboard-id --blocks '[{"name":"Order Count","type":"statistics","data_config":{"table_name":"Orders","count_all":true}},{"name":"Monthly Sales","type":"line","data_config":{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}],"group_by":[{"field_name":"Month","mode":"integrated","sort":{"type":"group","order":"asc"}}]}}]'`, + "Use this when creating two or more blocks in the same dashboard; the CLI calls the platform sequentially for you.", + "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.", + "The output is intentionally compact: created_count plus each created block's index, block_id, name, and type.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := parseDashboardBlockCreateSpecs(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + specs, _ := parseDashboardBlockCreateSpecs(runtime) + blocks := make([]interface{}, 0, len(specs)) + for _, spec := range specs { + blocks = append(blocks, spec.Body) + } + params := map[string]interface{}{} + if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" { + params["user_id_type"] = uid + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params). + Body(map[string]interface{}{"blocks": blocks, "sequential": true}). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockBatchCreate(runtime) + }, +} + +func parseDashboardBlockCreateSpecs(runtime *common.RuntimeContext) ([]dashboardBlockCreateSpec, error) { + pc := newParseCtx(runtime) + items, err := parseJSONArray(pc, runtime.Str("blocks"), "blocks") + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks must contain at least one block").WithParam("--blocks") + } + specs := make([]dashboardBlockCreateSpec, 0, len(items)) + for index, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks[%d] must be a JSON object", index).WithParam("--blocks") + } + body, err := buildDashboardBlockCreateBody(runtime, obj, index) + if err != nil { + return nil, err + } + specs = append(specs, dashboardBlockCreateSpec{Index: index, Body: body}) + } + return specs, nil +} + +func buildDashboardBlockCreateBody(runtime *common.RuntimeContext, obj map[string]interface{}, index int) (map[string]interface{}, error) { + name, _ := obj["name"].(string) + name = strings.TrimSpace(name) + if name == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks[%d].name is required", index).WithParam("--blocks") + } + blockType, _ := obj["type"].(string) + blockType = strings.TrimSpace(blockType) + if blockType == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks[%d].type is required", index).WithParam("--blocks") + } + body := map[string]interface{}{ + "name": name, + "type": blockType, + } + if rawConfig, ok := obj["data-config"]; ok { + obj["data_config"] = rawConfig + } + if rawConfig, ok := obj["data_config"]; ok { + cfg, err := dashboardBlockDataConfigFromSpec(rawConfig, index) + if err != nil { + return nil, err + } + if !runtime.Bool("no-validate") { + cfg = normalizeDataConfig(cfg) + if problems := validateBlockDataConfig(blockType, cfg); len(problems) > 0 { + return nil, formatDataConfigErrors(prefixDataConfigProblems(index, problems)) + } + } + body["data_config"] = cfg + } else if strings.EqualFold(blockType, "text") && !runtime.Bool("no-validate") { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks[%d].data_config.text is required for text blocks", index).WithParam("--blocks") + } + return body, nil +} + +func dashboardBlockDataConfigFromSpec(raw interface{}, index int) (map[string]interface{}, error) { + switch val := raw.(type) { + case map[string]interface{}: + return val, nil + case string: + var cfg map[string]interface{} + if err := common.ParseJSON([]byte(strings.TrimSpace(val)), &cfg); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks[%d].data_config must be a JSON object or object-encoded string", index).WithParam("--blocks").WithCause(err) + } + if cfg == nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks[%d].data_config must be a JSON object", index).WithParam("--blocks") + } + return cfg, nil + default: + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--blocks[%d].data_config must be a JSON object", index).WithParam("--blocks") + } +} + +func prefixDataConfigProblems(index int, problems []string) []string { + prefixed := make([]string, 0, len(problems)) + for _, problem := range problems { + prefixed = append(prefixed, "blocks["+strconv.Itoa(index)+"].data_config: "+problem) + } + return prefixed +} + +func executeDashboardBlockBatchCreate(runtime *common.RuntimeContext) error { + specs, err := parseDashboardBlockCreateSpecs(runtime) + if err != nil { + return err + } + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + created := make([]interface{}, 0, len(specs)) + for _, spec := range specs { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, spec.Body) + if err != nil { + return err + } + created = append(created, compactCreatedDashboardBlock(spec.Index, data)) + } + runtime.Out(map[string]interface{}{ + "created": true, + "created_count": len(created), + "dashboard_id": runtime.Str("dashboard-id"), + "blocks": created, + }, nil) + return nil +} + +func compactCreatedDashboardBlock(index int, data map[string]interface{}) map[string]interface{} { + item := map[string]interface{}{"index": index} + for _, key := range []string{"block_id", "id", "name", "type"} { + if value, ok := data[key]; ok { + item[key] = value + } + } + return item +} diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index 61b7a8aa..1dedddf9 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -93,6 +93,7 @@ func Shortcuts() []common.Shortcut { BaseDashboardBlockGet, BaseDashboardBlockGetData, BaseDashboardBlockCreate, + BaseDashboardBlockBatchCreate, BaseDashboardBlockUpdate, BaseDashboardBlockDelete, } diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 3399146d..307bd35c 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -64,7 +64,7 @@ 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-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);新建 2 个及以上组件优先用 `+dashboard-block-batch-create`;组件 `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 与启停状态 | | 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 | @@ -122,7 +122,7 @@ 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`。 +- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md)。需要新建 2 个及以上组件时优先用 `+dashboard-block-batch-create --blocks '[...]'`,由 CLI 内部串行创建并返回紧凑摘要;只建 1 个组件、调试单个配置或修复某个失败组件时再用 `+dashboard-block-create`。`+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` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 diff --git a/skills/lark-base/references/dashboard-block-data-config.md b/skills/lark-base/references/dashboard-block-data-config.md index 92e27478..cbeeb8f4 100644 --- a/skills/lark-base/references/dashboard-block-data-config.md +++ b/skills/lark-base/references/dashboard-block-data-config.md @@ -36,6 +36,8 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty ## data_config 通用结构 +创建多个组件时,把每个组件写成 `{"name":"...","type":"...","data_config":{...}}`,整体放入 `+dashboard-block-batch-create --blocks '[...]'`。只创建或修复单个组件时,才把本页模板直接传给 `+dashboard-block-create --data-config '{...}'`。 + | 字段 | 类型 | 说明 | |------|------|------| | `table_name` | string | 关联数据表名称 | diff --git a/skills/lark-base/references/lark-base-dashboard.md b/skills/lark-base/references/lark-base-dashboard.md index 87aaaff9..91b20235 100644 --- a/skills/lark-base/references/lark-base-dashboard.md +++ b/skills/lark-base/references/lark-base-dashboard.md @@ -15,7 +15,7 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成** | 你想做什么 | 用这些命令 | 关键文档 | |------|-----------|---------| | 创建/删除/改名称 | `+dashboard-create/delete/update` | 本页下方「仪表盘管理」 | -| 在仪表盘里添加组件 | `+dashboard-block-create` | 先定位 dashboard、表和字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` | +| 在仪表盘里添加组件 | `+dashboard-block-batch-create` / `+dashboard-block-create` | 新建 2 个及以上组件优先用 batch;只建 1 个组件或修复单个组件时用 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` | @@ -39,27 +39,18 @@ lark-cli base +field-list --base-token xxx --table-id # 第 3 步:规划应该创建哪些组件(根据用户需求确定组件类型和数量) # 例如:总销售额(指标卡)、月度趋势(折线图)、品类占比(饼图) -# 第 4 步:顺序创建每个组件(必须串行执行,不能并发) +# 第 4 步:批量创建多个组件(CLI 内部会串行执行,不能并发) # 重要:创建组件前,先确定 dashboard_id、组件 name/type 和真实表字段 # 再阅读 dashboard-block-data-config.md 了解 data_config 结构、组件类型和 filter 规则 -# 第 1 个组件 -lark-cli base +dashboard-block-create \ +# 创建 2 个及以上组件时优先用 batch,避免多轮工具调用和重复返回 +lark-cli base +dashboard-block-batch-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"}]}' - -# 继续创建其他组件... + --blocks '[ + {"name":"总销售额","type":"statistics","data_config":{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}]}}, + {"name":"月度趋势","type":"line","data_config":{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated","sort":{"type":"group","order":"asc"}}]}} + ]' # 第 5 步:组件创建完成后,使用 arrange 命令智能重排布局(可选但推荐) # 默认布局可能不够美观,arrange 会根据组件数量和类型自动优化布局 @@ -83,9 +74,17 @@ lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx lark-cli base +table-list --base-token xxx lark-cli base +field-list --base-token xxx --table-id -# 第 4 步:顺序创建每个新组件(必须串行执行,不能并发) +# 第 4 步:创建新组件 # 重要:先确定 dashboard_id、组件 name/type 和真实表字段 # 再阅读 dashboard-block-data-config.md 了解 data_config 结构 + +# 新建 2 个及以上组件时优先用 batch +lark-cli base +dashboard-block-batch-create \ + --base-token xxx \ + --dashboard-id blk_xxx \ + --blocks '[{"name":"新组件名","type":"column","data_config":{...}}]' + +# 只新建 1 个组件时可用单组件命令 lark-cli base +dashboard-block-create \ --base-token xxx \ --dashboard-id blk_xxx \ @@ -207,7 +206,7 @@ A: 常见原因: - 组件创建并发执行(必须串行,等上一个完成再执行下一个) **Q: 可以一次创建多个组件吗?** -A: 不可以,必须串行执行。等上一个 `+dashboard-block-create` 完成后再执行下一个。 +A: 可以。使用 `+dashboard-block-batch-create --blocks '[...]'`,CLI 会在一个命令内按顺序串行创建多个组件,并返回每个新组件的 `block_id/name/type` 摘要。不要并发调用多个 `+dashboard-block-create` 写同一个 dashboard;只创建 1 个组件或修复单个失败组件时再用 `+dashboard-block-create`。 **Q: 组件的 `type` 创建后能改吗?** A: 不能。`+dashboard-block-update` 只能修改 `name` 和 `data_config`,不能修改 `type`。 diff --git a/tests/cli_e2e/base/base_dashboard_block_batch_create_dryrun_test.go b/tests/cli_e2e/base/base_dashboard_block_batch_create_dryrun_test.go new file mode 100644 index 00000000..18d481d4 --- /dev/null +++ b/tests/cli_e2e/base/base_dashboard_block_batch_create_dryrun_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBaseDashboardBlockBatchCreateDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + blocks := `[{"name":"订单数","type":"statistics","data_config":{"table_name":"订单表","count_all":true}},{"name":"月度趋势","type":"line","data_config":{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated","sort":{"type":"group","order":"asc"}}]}}]` + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+dashboard-block-batch-create", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--blocks", blocks, + "--user-id-type", "open_id", + "--dry-run", + }, + BinaryPath: "../../../lark-cli", + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") + assert.Contains(t, output, `"method": "POST"`) + assert.Contains(t, output, `"base_token": "app_x"`) + assert.Contains(t, output, `"dashboard_id": "dsh_1"`) + assert.Contains(t, output, `"user_id_type": "open_id"`) + assert.Contains(t, output, `"blocks"`) + assert.Contains(t, output, `"sequential": true`) + assert.Contains(t, output, `"name": "订单数"`) + assert.Contains(t, output, `"type": "line"`) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index b3b2435b..dacca72c 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -27,6 +27,7 @@ | ✓ | base +base-block-move | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/move root,move after | `--base-token`; `--block-id`; optional `--parent-id`; `--after-id`; dry-run only | request shape only | | ✓ | base +base-block-rename | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/rename | `--base-token`; `--block-id`; `--name`; dry-run only | request shape only | | ✕ | base +dashboard-arrange | shortcut | | none | dashboard workflows not covered | +| ✓ | base +dashboard-block-batch-create | shortcut | base_dashboard_block_batch_create_dryrun_test.go::TestBaseDashboardBlockBatchCreateDryRun | `--base-token`; `--dashboard-id`; `--blocks`; dry-run only | request shape only | | ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |