Add +dashboard-arrange command for auto-arranging dashboard blocks layout and introduce text block type with Markdown support for dashboard visualization. (#341)

- Add `+dashboard-arrange` command that triggers server-side smart layout optimization via POST /open-apis/base/v3/bases/{token}/dashboards/{id}/arrange
- Add `text` block type support for dashboard blocks with Markdown syntax (headers, bold, italic, strikethrough, lists)
- Update `validateBlockDataConfig()` to handle text-specific validation rules
- Update documentation (SKILL.md, lark-base-dashboard.md, dashboard-block-data-config.md, lark-base-dashboard-arrange.md)
- Add comprehensive unit tests for new commands and block type
- [x] Unit tests pass (`go test ./shortcuts/base/...`)
- [x] All dashboard-related tests pass including new `TestBaseDashboardExecuteArrange`
- [x] Text block type validation tests pass
- None
This commit is contained in:
huangxincola
2026-04-10 14:34:10 +08:00
committed by GitHub
parent 6ec19cbc84
commit b8fa2b3f80
13 changed files with 481 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ import (
// ── Dashboard CRUD ──────────────────────────────────────────────────
// TestBaseDashboardExecuteList tests the +dashboard-list command.
func TestBaseDashboardExecuteList(t *testing.T) {
t.Run("single page", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -41,6 +42,7 @@ func TestBaseDashboardExecuteList(t *testing.T) {
}
// TestBaseDashboardExecuteGet tests the +dashboard-get command.
func TestBaseDashboardExecuteGet(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -67,6 +69,7 @@ func TestBaseDashboardExecuteGet(t *testing.T) {
}
}
// TestBaseDashboardExecuteCreate tests the +dashboard-create command.
func TestBaseDashboardExecuteCreate(t *testing.T) {
t.Run("name only", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -114,6 +117,7 @@ func TestBaseDashboardExecuteCreate(t *testing.T) {
})
}
// TestBaseDashboardExecuteUpdate tests the +dashboard-update command.
func TestBaseDashboardExecuteUpdate(t *testing.T) {
t.Run("update name", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -161,6 +165,7 @@ func TestBaseDashboardExecuteUpdate(t *testing.T) {
})
}
// TestBaseDashboardExecuteDelete tests the +dashboard-delete command.
func TestBaseDashboardExecuteDelete(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -179,6 +184,7 @@ func TestBaseDashboardExecuteDelete(t *testing.T) {
// ── Dashboard Block CRUD ────────────────────────────────────────────
// TestBaseDashboardBlockExecuteList tests the +dashboard-block-list command.
func TestBaseDashboardBlockExecuteList(t *testing.T) {
t.Run("single page", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -208,6 +214,7 @@ func TestBaseDashboardBlockExecuteList(t *testing.T) {
}
// TestBaseDashboardBlockExecuteGet tests the +dashboard-block-get command.
func TestBaseDashboardBlockExecuteGet(t *testing.T) {
t.Run("basic", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -261,6 +268,7 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {
})
}
// TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command.
func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
t.Run("with data-config", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -354,6 +362,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
})
}
// TestBaseDashboardBlockExecuteUpdate tests the +dashboard-block-update command.
func TestBaseDashboardBlockExecuteUpdate(t *testing.T) {
t.Run("update name and data-config", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -420,6 +429,7 @@ func TestBaseDashboardBlockExecuteUpdate(t *testing.T) {
})
}
// TestBaseDashboardBlockExecuteDelete tests the +dashboard-block-delete command.
func TestBaseDashboardBlockExecuteDelete(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -438,6 +448,7 @@ func TestBaseDashboardBlockExecuteDelete(t *testing.T) {
// ── Dry Run: Dashboard & Blocks ──────────────────────────────────────
// TestBaseDashboardDryRun_List tests the +dashboard-list --dry-run flag.
func TestBaseDashboardDryRun_List(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
if err := runShortcut(t, BaseDashboardList, []string{"+dashboard-list", "--base-token", "app_x", "--page-size", "50", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil {
@@ -449,6 +460,7 @@ 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 {
@@ -460,6 +472,7 @@ func TestBaseDashboardDryRun_Get(t *testing.T) {
}
}
// TestBaseDashboardDryRun_Create tests the +dashboard-create --dry-run flag.
func TestBaseDashboardDryRun_Create(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-create", "--base-token", "app_x", "--name", "新报表", "--theme-style", "default", "--dry-run", "--format", "pretty"}
@@ -472,6 +485,7 @@ 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"}
@@ -484,6 +498,7 @@ 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"}
@@ -496,6 +511,7 @@ 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"}
@@ -508,6 +524,7 @@ 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"}
@@ -520,6 +537,7 @@ func TestBaseDashboardBlockDryRun_Get(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"}
@@ -532,6 +550,7 @@ 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"}
@@ -544,6 +563,7 @@ 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"}
@@ -558,6 +578,7 @@ func TestBaseDashboardBlockDryRun_Delete(t *testing.T) {
// ── Validator: data_config ───────────────────────────────────────────
// TestBaseDashboardBlockCreate_ValidateFails tests that data_config validation catches missing table_name.
func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
// 缺 table_name 且 series 与 count_all 同时存在
@@ -574,6 +595,7 @@ 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",
@@ -591,6 +613,7 @@ func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) {
}
}
// TestBaseDashboardBlockCreate_InvalidRollup tests that invalid rollup values are rejected during validation.
func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
// 合法 JSON但 rollup=COUNTA不支持
@@ -606,3 +629,186 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
// ── Text Block Tests ────────────────────────────────────────────────
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.
func TestBaseDashboardBlockExecuteCreate_TextType(t *testing.T) {
t.Run("valid text block", 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_text",
"name": "说明文字",
"type": "text",
"data_config": map[string]interface{}{
"text": "# 标题\n**加粗**",
},
},
},
})
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001",
"--name", "说明文字", "--type", "text",
"--data-config", `{"text":"# 标题\n**加粗**"}`,
}
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"blk_text"`) || !strings.Contains(got, `"created": true`) {
t.Fatalf("stdout=%s", got)
}
})
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",
"--name", "Bad", "--type", "text",
"--data-config", `{}`,
}
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for missing text field")
}
if got := err.Error(); !strings.Contains(got, "text") || !strings.Contains(got, "data_config 校验失败") {
t.Fatalf("unexpected error: %v", err)
}
})
}
// TestBaseDashboardBlockExecuteUpdate_TextType tests updating text block content and name.
func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) {
t.Run("update text content", func(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",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"block_id": "blk_text",
"name": "更新后的标题",
"type": "text",
"data_config": map[string]interface{}{
"text": "# 新内容",
},
},
},
})
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text",
"--name", "更新后的标题",
"--data-config", `{"text":"# 新内容"}`,
}
if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, "新内容") {
t.Fatalf("stdout=%s", got)
}
})
t.Run("update without type skips strict validation", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
// update 不传 type不做强类型校验直接透传给后端
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"block_id": "blk_text",
"type": "text",
},
},
})
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text",
"--data-config", `{"content":"xxx"}`,
}
// 不传 type本地不做强校验让后端处理
err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
t.Fatalf("stdout=%s", got)
}
})
}
// ── Dashboard Arrange ────────────────────────────────────────────────
// TestBaseDashboardExecuteArrange tests the +dashboard-arrange command for auto-arranging dashboard blocks.
func TestBaseDashboardExecuteArrange(t *testing.T) {
t.Run("arrange dashboard blocks", 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/arrange",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dashboard_id": "dsh_001",
"name": "测试仪表盘",
"blocks": []interface{}{
map[string]interface{}{
"block_id": "cht_xxx",
"block_name": "组件1",
"block_type": "column",
"layout": map[string]interface{}{
"x": 0, "y": 0, "w": 500, "h": 400,
},
},
},
},
},
})
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001"}
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"arranged": true`) || !strings.Contains(got, `"dashboard_id"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("arrange with user-id-type", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "user_id_type=union_id",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dashboard_id": "dsh_001",
"blocks": []interface{}{},
},
},
})
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--user-id-type", "union_id"}
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"arranged": true`) || !strings.Contains(got, `"dashboard_id"`) {
t.Fatalf("stdout=%s", got)
}
})
}
// 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"}
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, "{}") {
t.Fatalf("stdout=%s", got)
}
}

View File

@@ -122,7 +122,7 @@ func TestShortcutsCatalog(t *testing.T) {
"+data-query",
"+form-create", "+form-delete", "+form-list", "+form-update", "+form-get",
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
}
if len(shortcuts) != len(want) {

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
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,
Flags: []common.Flag{
baseTokenFlag(true),
dashboardIDFlag(true),
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
DryRun: dryRunDashboardArrange,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeDashboardArrange(runtime)
},
}

View File

@@ -6,6 +6,7 @@ package base
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -23,7 +24,7 @@ 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(指标卡). Read dashboard-block-data-config.md before creating.", 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 (table_name, series, count_all, group_by, filter, etc.)"},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
@@ -35,7 +36,11 @@ var BaseDashboardBlockCreate = common.Shortcut{
}
raw := runtime.Str("data-config")
if strings.TrimSpace(raw) == "" {
return nil // 允许无 data_config 的创建(某些类型可先创建后配置
// text 类型必须提供 data-config(含 text 内容
if strings.ToLower(runtime.Str("type")) == "text" {
return fmt.Errorf("text 类型组件必须提供 data-config包含必填字段 text")
}
return nil
}
cfg, err := parseJSONObject(pc, raw, "data-config")
if err != nil {

View File

@@ -24,7 +24,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
dashboardIDFlag(true),
blockIDFlag(true),
{Name: "name", Desc: "new block name"},
{Name: "data-config", Desc: "data config JSON: table_name, series|count_all (mutually exclusive), group_by, filter. See dashboard-block-data-config.md for details."},
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
},
@@ -42,9 +42,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
return err
}
norm := normalizeDataConfig(cfg)
if errs := validateBlockDataConfig("", norm); len(errs) > 0 { // update 时不强校验类型特性
return formatDataConfigErrors(errs)
}
// update 时不做强类型校验(不传 type让后端验证具体字段
b, _ := json.Marshal(norm)
_ = runtime.Cmd.Flags().Set("data-config", string(b))
return nil

View File

@@ -10,14 +10,17 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// dashboardIDFlag returns a Flag for dashboard ID.
func dashboardIDFlag(required bool) common.Flag {
return common.Flag{Name: "dashboard-id", Desc: "dashboard ID", Required: required}
}
// blockIDFlag returns a Flag for dashboard block ID.
func blockIDFlag(required bool) common.Flag {
return common.Flag{Name: "block-id", Desc: "dashboard block ID", Required: required}
}
// 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")).
@@ -25,6 +28,7 @@ func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI {
Set("block_id", runtime.Str("block-id"))
}
// dryRunDashboardList returns a DryRunAPI for listing dashboards.
func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
@@ -38,11 +42,13 @@ func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *com
Params(params)
}
// dryRunDashboardGet returns a DryRunAPI for getting a dashboard.
func dryRunDashboardGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunDashboardBase(runtime).
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id")
}
// dryRunDashboardCreate returns a DryRunAPI for creating a dashboard.
func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{"name": runtime.Str("name")}
if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" {
@@ -53,6 +59,7 @@ func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *c
Body(body)
}
// dryRunDashboardUpdate returns a DryRunAPI for updating a dashboard.
func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
@@ -66,11 +73,13 @@ func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *c
Body(body)
}
// dryRunDashboardDelete returns a DryRunAPI for deleting a dashboard.
func dryRunDashboardDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunDashboardBase(runtime).
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id")
}
// dryRunDashboardBlockList returns a DryRunAPI for listing dashboard blocks.
func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
@@ -84,6 +93,7 @@ func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext)
Params(params)
}
// dryRunDashboardBlockGet returns a DryRunAPI for getting a dashboard block.
func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
@@ -94,6 +104,7 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext)
Params(params)
}
// dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block.
func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
@@ -119,6 +130,7 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex
Body(body)
}
// dryRunDashboardBlockUpdate returns a DryRunAPI for updating a dashboard block.
func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
@@ -140,6 +152,7 @@ func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContex
Body(body)
}
// dryRunDashboardBlockDelete returns a DryRunAPI for deleting a dashboard block.
func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunDashboardBase(runtime).
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id")
@@ -147,6 +160,7 @@ func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContex
// ── Dashboard CRUD ──────────────────────────────────────────────────
// executeDashboardList lists all dashboards in a base.
func executeDashboardList(runtime *common.RuntimeContext) error {
params := map[string]interface{}{}
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
@@ -163,6 +177,7 @@ func executeDashboardList(runtime *common.RuntimeContext) error {
return nil
}
// 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)
if err != nil {
@@ -172,6 +187,7 @@ func executeDashboardGet(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardCreate creates a new dashboard.
func executeDashboardCreate(runtime *common.RuntimeContext) error {
body := map[string]interface{}{"name": runtime.Str("name")}
if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" {
@@ -185,6 +201,7 @@ func executeDashboardCreate(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardUpdate updates an existing dashboard.
func executeDashboardUpdate(runtime *common.RuntimeContext) error {
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
@@ -201,6 +218,7 @@ func executeDashboardUpdate(runtime *common.RuntimeContext) error {
return nil
}
// 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)
if err != nil {
@@ -212,6 +230,7 @@ func executeDashboardDelete(runtime *common.RuntimeContext) error {
// ── Dashboard Block CRUD ────────────────────────────────────────────
// executeDashboardBlockList lists all blocks in a dashboard.
func executeDashboardBlockList(runtime *common.RuntimeContext) error {
params := map[string]interface{}{}
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
@@ -228,6 +247,7 @@ func executeDashboardBlockList(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardBlockGet retrieves a dashboard block by ID.
func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
params := map[string]interface{}{}
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
@@ -241,6 +261,7 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardBlockCreate creates a new dashboard block.
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
@@ -271,6 +292,7 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardBlockUpdate updates an existing dashboard block.
func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
@@ -297,6 +319,7 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
return nil
}
// 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)
if err != nil {
@@ -305,3 +328,36 @@ func executeDashboardBlockDelete(runtime *common.RuntimeContext) error {
runtime.Out(map[string]interface{}{"deleted": true, "block_id": runtime.Str("block-id")}, nil)
return nil
}
// ── Dashboard Arrange ────────────────────────────────────────────────
// dryRunDashboardArrange returns a DryRunAPI for the dashboard arrange endpoint.
func dryRunDashboardArrange(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
params["user_id_type"] = userIDType
}
return dryRunDashboardBase(runtime).
POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/arrange").
Params(params).
Body(map[string]interface{}{})
}
// executeDashboardArrange sends a POST request to auto-arrange dashboard blocks layout.
func executeDashboardArrange(runtime *common.RuntimeContext) error {
params := map[string]interface{}{}
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
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{}{})
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
data["arranged"] = true
runtime.Out(data, nil)
return nil
}

View File

@@ -945,6 +945,8 @@ func sleepBetweenBatches(index int, total int) {
// ── Dashboard Block data_config normalization & validation ───────────
// normalizeDataConfig normalizes data_config fields for dashboard blocks.
// It converts series[].rollup to uppercase and group_by[].sort fields to lowercase.
func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
if cfg == nil {
return nil
@@ -986,8 +988,21 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
return out
}
// validateBlockDataConfig validates data_config based on block type.
// For text type, it checks for the presence of text field.
// For chart types, it validates table_name, series/count_all, group_by, and filter fields.
func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
var errs []string
// text 类型特殊校验:只需要有 text 字段即可
if strings.ToLower(blockType) == "text" {
if txt, _ := cfg["text"].(string); strings.TrimSpace(txt) == "" {
errs = append(errs, "text 类型组件缺少必填字段 text")
}
return errs
}
// 图表类型通用校验
// table_name 必填
if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" {
errs = append(errs, "缺少必填字段 table_name")

View File

@@ -71,6 +71,7 @@ func Shortcuts() []common.Shortcut {
BaseDashboardCreate,
BaseDashboardUpdate,
BaseDashboardDelete,
BaseDashboardArrange,
BaseDashboardBlockList,
BaseDashboardBlockGet,
BaseDashboardBlockCreate,

View File

@@ -312,4 +312,4 @@ https://{domain}/base/{base-token}?table={table-id}&view={view-id}
| [`form commands`](references/lark-base-form-create.md) | `+form-list / +form-get / +form-create / +form-update / +form-delete` |
| [`form questions commands`](references/lark-base-form-questions-create.md) | `+form-questions-list / +form-questions-create / +form-questions-update / +form-questions-delete` |
| [`workflow commands`](references/lark-base-workflow.md) | `+workflow-list / +workflow-get / +workflow-create / +workflow-update / +workflow-enable / +workflow-disable` |
| [`dashboard / dashboard-block commands`](references/lark-base-dashboard.md) | `+dashboard-list / +dashboard-get / +dashboard-create / +dashboard-update / +dashboard-delete / +dashboard-block-list / +dashboard-block-get / +dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` |
| [`dashboard / dashboard-block commands`](references/lark-base-dashboard.md) | `+dashboard-list / +dashboard-get / +dashboard-create / +dashboard-update / +dashboard-delete / +dashboard-arrange / +dashboard-block-list / +dashboard-block-get / +dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` |

View File

@@ -18,6 +18,7 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
| `wordCloud` | 词云 |
| `radar` | 雷达图 |
| `statistics` | 指标卡 |
| `text` | 文本(支持 Markdown |
## 字段类型与操作符速查AI 决策用)
@@ -45,6 +46,29 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
| `filter.conjunction` | `"and"` / `"or"` | 筛选逻辑 |
| `filter.conditions` | `[{ "field_name", "operator", "value" }]` | 筛选条件数组value 类型因字段类型而异(见下方 filter 格式规则) |
### text 类型特殊结构
`text` 类型组件用于展示富文本内容,**不需要数据源配置**(无 `table_name``series``group_by``filter`)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `text` | string | **必填**。支持 Markdown 语法,详见下方说明 |
**支持的 Markdown 语法:**
| 语法 | 示例 | 效果 |
|------|------|------|
| 一级标题 | `# 标题` | 大标题 |
| 二级标题 | `## 标题` | 中标题 |
| 三级标题 | `### 标题` | 小标题 |
| 加粗 | `**文字**` | **文字** |
| 斜体 | `*文字*` | *文字* |
| 删除线 | `~~文字~~` | ~~文字~~ |
| 有序列表 | `1. 项目` | 1. 项目 |
| 无序列表 | `- 项目` | - 项目 |
> **注意**:以上未提及的 Markdown 语法(如链接、图片、代码块、表格等)均不支持。
## group_by 详细说明
### mode 枚举
@@ -138,8 +162,10 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
## 约束与本地校验
- 必填与互斥
- 必填:`table_name`
- 互斥:`series``count_all` 二选一,且至少提供其一
- 图表类型必填:`table_name`
- text 类型必填:`text`
- 互斥:`series``count_all` 二选一,且至少提供其一(仅图表类型)
- text 类型**不支持**`series``count_all``group_by``filter`
- 长度/结构
- `group_by` 最多 2 个;每项 `field_name` 必填
- `group_by[].sort.type` 取值 `group|value|view``order` 取值 `asc|desc`
@@ -147,7 +173,8 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
- `series[].rollup` 自动转成大写(如 `sum``SUM`
- `group_by[].sort.type/order` 自动转成小写
- 本地校验(可通过 `--no-validate` 跳过)
- `+dashboard-block-create/update` 默认对 `data_config` 做轻量校验;失败会聚合错误并给出修复建议
- `+dashboard-block-create` 默认对 `data_config` 做轻量校验;失败会聚合错误并给出修复建议
- `+dashboard-block-update` 不做强类型校验,由后端验证具体字段
- 仅需传入合法 JSONCLI 不会擅自改写你的业务含义
## 可复制模板
@@ -287,6 +314,16 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
}
```
文本组件Markdown 富文本):
```json
{
"text": "# 🚀 一级标题\n这是一个 **加粗** *斜体* ~~删除线~~ 的示例。\n\n## 📌 二级标题\n1. 有序列表项 1\n2. 有序列表项 2\n\n### 📌 三级标题\n- 无序列表项 1\n- 无序列表项 2"
}
```
> **注意**text 类型组件不需要 `table_name`、`series`、`group_by`、`filter` 等数据源相关字段。
## 常见错误与修复
- 同时存在 `series``count_all`

View File

@@ -0,0 +1,83 @@
# base +dashboard-arrange
> **前置条件:** 先阅读 [lark-base-dashboard.md](lark-base-dashboard.md) 了解整体工作流
自动重新排列仪表盘组件布局。服务端根据组件数量和类型进行智能布局优化。
## 使用场景
| 场景 | 说明 |
|------|------|
| **从 0 到 1 搭建后** | 使用 `+dashboard-create``+dashboard-block-create` 创建仪表盘后,默认布局可能不够工整美观,推荐使用本命令做一次整体重排 |
| **用户明确要求** | 用户主动要求对已有仪表盘进行布局重排或美化时 |
> [!CAUTION]
> - **不建议**在已有仪表盘上自动调用此命令,除非用户明确要求
> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期
> - 无法指定具体位置(如"第一排放 A第二排放 B"),排列逻辑是**自适应**的
## 推荐命令
```bash
# 基础用法
lark-cli base +dashboard-arrange \
--base-token xxx \
--dashboard-id blk_xxx
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--dashboard-id <id>` | 是 | 仪表盘 ID |
| `--user-id-type <type>` | 否 | 用户 ID 类型open_id / union_id / user_id |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 返回示例
```json
{
"dashboard_id": "blk_xxx",
"name": "数据分析",
"blocks": [
{
"block_id": "chtbxxxx",
"block_name": "总销售额",
"block_type": "statistics",
"layout": {
"x": 0,
"y": 0,
"w": 6,
"h": 6
}
},
{
"block_id": "chtbcrxxxx",
"block_name": "月度趋势",
"block_type": "column",
"layout": {
"x": 6,
"y": 0,
"w": 6,
"h": 6
}
}
],
"arranged": true
}
```
## 返回重点
| 字段 | 说明 |
|------|------|
| `blocks[].layout` | 重排后的布局信息,包含 x/y/w/h |
| `arranged` | 是否重排成功 |
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
## 参考
- [lark-base-dashboard.md](lark-base-dashboard.md) — dashboard 模块指引

View File

@@ -22,6 +22,14 @@ lark-cli base +dashboard-block-create \
--type statistics \
--data-config '{"table_name":"订单表","count_all":true}'
# 文本组件示例Markdown 富文本)
lark-cli base +dashboard-block-create \
--base-token xxx \
--dashboard-id blk_xxx \
--name "说明文字" \
--type text \
--data-config '{"text":"# 标题\n## 副标题\n**加粗** *斜体* ~~删除~~\n1. 列表1\n2. 列表2"}'
# 复杂配置用文件传入
lark-cli base +dashboard-block-create \
--base-token xxx \
@@ -40,8 +48,8 @@ lark-cli base +dashboard-block-create \
| `--base-token <token>` | 是 | Base Token |
| `--dashboard-id <id>` | 是 | 仪表盘 ID`+dashboard-list/get` 获取) |
| `--name <name>` | **是** | 组件名称(允许重名) |
| `--type <type>` | **是** | 组件类型,见下方枚举值。**不同 type 对应不同的 data_config 结构**,常用:`column`(柱状图)、`line`(折线图)、`pie`(饼图)、`statistics`(指标卡) |
| `--data-config <json>` | 否 | 数据配置 JSON**结构随 type 变化**。**⚠️ 必须阅读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解如何构造** |
| `--type <type>` | **是** | 组件类型,见下方枚举值。**不同 type 对应不同的 data_config 结构**,常用:`column`(柱状图)、`line`(折线图)、`pie`(饼图)、`statistics`(指标卡)`text`(文本) |
| `--data-config <json>` | 否 | 数据配置 JSON**结构随 type 变化**。**⚠️ 必须阅读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解如何构造**。创建时会做本地校验,更新时由后端校验 |
| `--user-id-type <type>` | 否 | 用户 ID 类型filter 涉及人员字段时使用 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
@@ -61,6 +69,7 @@ lark-cli base +dashboard-block-create \
| `wordCloud` | 词云 |
| `radar` | 雷达图 |
| `statistics` | 指标卡 |
| `text` | 文本(支持 Markdown |
## 返回示例

View File

@@ -18,6 +18,7 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**
| 在仪表盘里添加组件 | `+dashboard-block-create` | 先读 [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
| 修改组件 | `+dashboard-block-update` | 先读 [lark-base-dashboard-block-update.md](lark-base-dashboard-block-update.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
| 查看仪表盘有哪些组件 | `+dashboard-get``+dashboard-block-list` | 本页下方「查看仪表盘」 |
| 智能重排组件布局 | `+dashboard-arrange` | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
## 典型场景工作流
@@ -58,6 +59,12 @@ lark-cli base +dashboard-block-create \
--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在已有仪表盘上添加新组件
@@ -119,7 +126,26 @@ lark-cli base +dashboard-block-update \
--data-config '{...}'
```
### 场景 4读取仪表盘或组件现状
### 场景 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**
@@ -154,6 +180,7 @@ lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --blo
| 类别比较(谁高谁低) | column | 柱状图组件 |
| 占比分布(各部分比例) | pie | 饼图组件 |
| 单个关键指标 | statistics | 指标卡组件 |
| 富文本说明/标题/注释 | text | 文本组件(支持 Markdown |
详细组件类型和 data_config 完整规则:[dashboard-block-data-config.md](dashboard-block-data-config.md)
@@ -205,6 +232,7 @@ A: 在「添加新组件」或「编辑组件」前查看已有组件可以:
| `+dashboard-create` | 创建仪表盘 | [lark-base-dashboard-create.md](lark-base-dashboard-create.md) |
| `+dashboard-update` | 修改仪表盘 | [lark-base-dashboard-update.md](lark-base-dashboard-update.md) |
| `+dashboard-delete` | 删除仪表盘 | [lark-base-dashboard-delete.md](lark-base-dashboard-delete.md) |
| `+dashboard-arrange` | 智能重排布局 | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
| `+dashboard-block-list` | 列出组件 | [lark-base-dashboard-block-list.md](lark-base-dashboard-block-list.md) |
| `+dashboard-block-get` | 获取单个组件详情 | [lark-base-dashboard-block-get.md](lark-base-dashboard-block-get.md) |
| `+dashboard-block-create` | 创建组件 | [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md) |