diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go index d07dd6cba7..5c4b94fc92 100644 --- a/shortcuts/base/base_dashboard_execute_test.go +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -817,6 +817,217 @@ func TestNormalizeDataConfigSortOrder(t *testing.T) { }) } +func TestValidateNPSDataConfig(t *testing.T) { + valid := map[string]interface{}{ + "table_name": "Survey", + "group_by": []interface{}{ + map[string]interface{}{"field_name": "Score", "mode": "integrated"}, + }, + "category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)}, + } + if problems := validateBlockDataConfig("nps", valid); len(problems) != 0 { + t.Fatalf("valid nps config got problems: %v", problems) + } + if problems := validateBlockDataConfig(" NpS ", cloneMap(valid)); len(problems) != 0 { + t.Fatalf("normalized nps type got problems: %v", problems) + } + + withoutCountAll := cloneMap(valid) + if problems := validateBlockDataConfig("nps", withoutCountAll); len(problems) != 0 { + t.Fatalf("nps should allow omitted count_all: %v", problems) + } + + withCountAll := cloneMap(valid) + withCountAll["count_all"] = true + if problems := validateBlockDataConfig("nps", withCountAll); len(problems) != 0 { + t.Fatalf("nps should allow count_all true: %v", problems) + } + + invalid := cloneMap(valid) + invalid["series"] = []interface{}{map[string]interface{}{"field_name": "Score", "rollup": "SUM"}} + invalid["count_all"] = false + invalid["group_by"] = []interface{}{ + map[string]interface{}{"field_name": "Score", "mode": "enumerated", "sort": map[string]interface{}{"type": "group"}}, + } + problems := validateBlockDataConfig("nps", invalid) + for _, want := range []string{"不支持 series", "只能为 true", "只能为 integrated", "不支持 sort"} { + if !containsProblem(problems, want) { + t.Fatalf("problems=%v, want containing %q", problems, want) + } + } + + for _, tc := range []struct { + field string + value interface{} + }{ + {field: "sort", value: map[string]interface{}{"type": "group", "order": "asc"}}, + {field: "limit_size", value: float64(10)}, + {field: "number_format", value: map[string]interface{}{"formatName": "digital"}}, + {field: "text", value: "NPS"}, + } { + t.Run("reject top-level "+tc.field, func(t *testing.T) { + cfg := cloneMap(valid) + cfg[tc.field] = tc.value + problems := validateBlockDataConfig("nps", cfg) + if !containsProblem(problems, "nps 不支持 "+tc.field) { + t.Fatalf("problems=%v, want containing %q", problems, "nps 不支持 "+tc.field) + } + }) + } + + for _, tc := range []struct { + name string + mut func(map[string]interface{}) + want string + }{ + { + name: "missing table_name", + mut: func(cfg map[string]interface{}) { + delete(cfg, "table_name") + }, + want: "缺少必填字段 table_name", + }, + { + name: "invalid group_by shape", + mut: func(cfg map[string]interface{}) { + cfg["group_by"] = map[string]interface{}{"field_name": "Score", "mode": "integrated"} + }, + want: "nps.group_by 必须是长度为 1 的数组", + }, + { + name: "missing group_by field_name", + mut: func(cfg map[string]interface{}) { + cfg["group_by"] = []interface{}{map[string]interface{}{"mode": "integrated"}} + }, + want: "nps.group_by[0].field_name 不能为空", + }, + { + name: "missing group_by mode", + mut: func(cfg map[string]interface{}) { + cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score"}} + }, + want: "nps.group_by[0].mode 只能为 integrated", + }, + { + name: "empty group_by mode", + mut: func(cfg map[string]interface{}) { + cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": ""}} + }, + want: "nps.group_by[0].mode 只能为 integrated", + }, + { + name: "non-string group_by mode", + mut: func(cfg map[string]interface{}) { + cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": true}} + }, + want: "nps.group_by[0].mode 只能为 integrated", + }, + { + name: "invalid category_range", + mut: func(cfg map[string]interface{}) { + cfg["category_range"] = []interface{}{float64(0), float64(6), float64(10)} + }, + want: "nps.category_range 必须是长度为 4 的数组", + }, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := cloneMap(valid) + tc.mut(cfg) + problems := validateBlockDataConfig("nps", cfg) + if !containsProblem(problems, tc.want) { + t.Fatalf("problems=%v, want containing %q", problems, tc.want) + } + }) + } +} + +func TestValidateNonNPSRejectsCategoryRange(t *testing.T) { + for _, tc := range []struct { + name string + blockType string + cfg map[string]interface{} + }{ + { + name: "statistics", + blockType: "statistics", + cfg: map[string]interface{}{ + "table_name": "T", + "count_all": true, + "category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)}, + }, + }, + { + name: "ordinary chart", + blockType: "column", + cfg: map[string]interface{}{ + "table_name": "T", + "count_all": true, + "group_by": []interface{}{map[string]interface{}{"field_name": "Score"}}, + "category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)}, + }, + }, + { + name: "text", + blockType: "text", + cfg: map[string]interface{}{ + "text": "Summary", + "category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + problems := validateBlockDataConfig(tc.blockType, tc.cfg) + if !containsProblem(problems, "category_range 仅支持 nps 类型组件") { + t.Fatalf("problems=%v, want category_range rejection", problems) + } + }) + } +} + +func TestBaseDashboardBlockCreate_NPSRequiresDataConfig(t *testing.T) { + for _, tc := range []struct { + name string + blockType string + }{ + {name: "nps", blockType: "nps"}, + {name: "padded nps", blockType: " NpS "}, + {name: "padded text", blockType: " text "}, + } { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseDashboardBlockCreate, []string{ + "+dashboard-block-create", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--name", "Block", + "--type", tc.blockType, + }, factory, stdout) + if err == nil { + t.Fatalf("expected validation error for missing data_config, got nil (stdout=%s)", stdout.String()) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T %v", err, err) + } + if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype) + } + if ve.Param != "--data-config" { + t.Fatalf("param=%q, want --data-config", ve.Param) + } + }) + } +} + +func containsProblem(problems []string, want string) bool { + for _, p := range problems { + if strings.Contains(p, want) { + return true + } + } + return false +} + // ── Text Block Tests ──────────────────────────────────────────────── // TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content. diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 5ff21275bd..502daeffd2 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -795,6 +795,7 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) { shortcut: BaseDashboardBlockCreate, wantTips: []string{ `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`, + `--type nps --data-config '{"table_name":"Survey","group_by":[{"field_name":"Score","mode":"integrated"}],"category_range":[0,6,8,10]}'`, `--type text --data-config '{"text":"# Sales Dashboard"}'`, "+table-list and +field-list", "not table_id or field_id", diff --git a/shortcuts/base/block_data_config.go b/shortcuts/base/block_data_config.go index 190b6302bb..64402d6266 100644 --- a/shortcuts/base/block_data_config.go +++ b/shortcuts/base/block_data_config.go @@ -124,14 +124,17 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} { // app_list_block_data_config.go and never enters this dashboard path. func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string { blockType = strings.ToLower(strings.TrimSpace(blockType)) - if _, hasNumberFormat := cfg["number_format"]; hasNumberFormat && blockType != "statistics" { - return []string{"number_format 仅支持 statistics 类型组件"} - } switch { case isTextBlockType(blockType): - return validateTextDataConfig(blockType, cfg) + return append(validateNonNPSDataConfig(cfg), validateTextDataConfig(blockType, cfg)...) + case blockType == "nps": + return validateNPSDataConfig(cfg) default: - problems := validateChartDataConfig(cfg) + problems := validateNonNPSDataConfig(cfg) + if _, hasNumberFormat := cfg["number_format"]; hasNumberFormat && blockType != "statistics" { + return append(problems, "number_format 仅支持 statistics 类型组件") + } + problems = append(problems, validateChartDataConfig(cfg)...) if matchesBlockType(blockType, []string{"statistics"}) { if rawNumberFormat, hasNumberFormat := cfg["number_format"]; hasNumberFormat { problems = append(problems, validateNumberFormat(rawNumberFormat)...) @@ -141,6 +144,13 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str } } +func validateNonNPSDataConfig(cfg map[string]interface{}) []string { + if _, hasRange := cfg["category_range"]; hasRange { + return []string{"category_range 仅支持 nps 类型组件"} + } + return nil +} + // validateTextDataConfig validates the text data_config shape. func validateTextDataConfig(blockType string, cfg map[string]interface{}) []string { var problems []string @@ -256,6 +266,55 @@ func validateNumberFormat(raw interface{}) []string { return problems } +func validateNPSDataConfig(cfg map[string]interface{}) []string { + var errs []string + if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" { + errs = append(errs, "缺少必填字段 table_name") + } + for _, field := range []string{"sort", "limit_size", "number_format", "text"} { + if _, hasField := cfg[field]; hasField { + errs = append(errs, fmt.Sprintf("nps 不支持 %s", field)) + } + } + if _, hasSeries := cfg["series"]; hasSeries { + errs = append(errs, "nps 不支持 series;请省略 series,服务端会使用 count_all:true") + } + if v, hasCountAll := cfg["count_all"]; hasCountAll { + if b, ok := v.(bool); !ok || !b { + errs = append(errs, "nps.count_all 出现时只能为 true") + } + } + gb, ok := cfg["group_by"].([]interface{}) + if !ok || len(gb) != 1 { + errs = append(errs, "nps.group_by 必须是长度为 1 的数组") + } else { + m, ok := gb[0].(map[string]interface{}) + if !ok { + errs = append(errs, "nps.group_by[0] 必须是对象") + } else { + fn, _ := m["field_name"].(string) + if strings.TrimSpace(fn) == "" { + errs = append(errs, "nps.group_by[0].field_name 不能为空") + } + mode, modeOK := m["mode"].(string) + if !modeOK || strings.TrimSpace(mode) != "integrated" { + errs = append(errs, "nps.group_by[0].mode 只能为 integrated") + } + if _, hasSort := m["sort"]; hasSort { + errs = append(errs, "nps.group_by[0] 不支持 sort") + } + } + } + if cr, hasRange := cfg["category_range"]; hasRange { + arr, ok := cr.([]interface{}) + if !ok || len(arr) != 4 { + errs = append(errs, "nps.category_range 必须是长度为 4 的数组") + } + } + errs = append(errs, validateBlockFilter(cfg, "filter", false)...) + return errs +} + // ── BaseApp chart data_config (multi-datasource) ───────────────────── // // BaseApp page charts differ from dashboard charts by supporting multiple diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index c367b5d7d3..ac77c0d9fd 100644 --- a/shortcuts/base/dashboard_block_create.go +++ b/shortcuts/base/dashboard_block_create.go @@ -24,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(指标卡)|text(文本). Read lark-base-dashboard-block-config.md before creating.", Required: true}, + {Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|nps(NPS 图)|text(文本). Read lark-base-dashboard-block-config.md before creating.", Required: true}, {Name: "data-config", Desc: "data_config JSON object; read lark-base-dashboard-block-config.md for the SSOT"}, {Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; all four keys required and numeric (position is submitted whole, so a partial object cannot express a complete placement). Advisory bounds x/y>=0, 1<=w<=12 and x+w<=12, h>=1 — coordinate VALUES are not validated locally and pass through as given; the server auto-arranges out-of-range or overlapping positions. Omit for server auto-layout`}, {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, @@ -33,6 +33,7 @@ var BaseDashboardBlockCreate = common.Shortcut{ Tips: []string{ `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`, `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Revenue" --type statistics --data-config '{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}],"number_format":{"formatName":"dollar_rounded","precision":2}}'`, + `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Satisfaction NPS" --type nps --data-config '{"table_name":"Survey","group_by":[{"field_name":"Score","mode":"integrated"}],"category_range":[0,6,8,10]}'`, `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`, `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}' --position '{"x":0,"y":0,"w":6,"h":4}'`, "Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.", @@ -50,9 +51,14 @@ var BaseDashboardBlockCreate = common.Shortcut{ } raw := strings.TrimSpace(runtime.Str("data-config")) if raw == "" { - // text 类型必须提供 data-config(含 text 内容) - if !runtime.Bool("no-validate") && strings.EqualFold(strings.TrimSpace(runtime.Str("type")), "text") { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config") + if !runtime.Bool("no-validate") { + blockType := strings.ToLower(strings.TrimSpace(runtime.Str("type"))) + switch blockType { + case "text": + return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config") + case "nps": + return errs.NewValidationError(errs.SubtypeInvalidArgument, "nps 类型组件必须提供 data-config,包含必填字段 table_name 与 group_by").WithParam("--data-config") + } } return nil } diff --git a/skills/lark-base/references/lark-base-dashboard-block-config.md b/skills/lark-base/references/lark-base-dashboard-block-config.md index a6ddc228e6..d4c8626f18 100644 --- a/skills/lark-base/references/lark-base-dashboard-block-config.md +++ b/skills/lark-base/references/lark-base-dashboard-block-config.md @@ -18,11 +18,12 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档是 Dashboar | `wordCloud` | 词云 | | `radar` | 雷达图 | | `statistics` | 指标卡 | +| `nps` | NPS 图 | | `text` | 文本(支持 Markdown) | ## 字段类型与操作符速查(AI 决策用) -> 先用 `+field-list` / `+field-get` 确认字段 `type`;本节使用当前字段接口里的 canonical 类型名:`number`、`text`、`select`、`datetime`、`checkbox`、`user`。 +> 先用 `+field-list` / `+field-get` 确认字段 `type`;本节使用当前字段接口里的 canonical 类型名:`number`、`text`、`select`、`datetime`、`checkbox`、`user`。NPS 使用的 `Rating` 是 Dashboard 服务端识别的评分字段语义,不属于当前字段操作符速查里的通用筛选类型。 ``` text: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty @@ -47,6 +48,7 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty | `filter` | object | 筛选条件 | | `filter.conjunction` | `"and"` / `"or"` | 筛选逻辑 | | `filter.conditions` | `[{ "field_name", "operator", "value" }]` | 筛选条件数组,value 类型因字段类型而异(见下方 filter 格式规则) | +| `category_range` | `[min, detractorMax, passiveMax, max]` | NPS 三段边界,仅 `nps` 类型支持;首尾必须等于 Rating 字段量程,首尾匹配由服务端按字段元数据校验 | ### text 类型特殊结构 @@ -201,6 +203,7 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty - 图表类型必填:`table_name` - text 类型必填:`text` - 互斥:`series` 与 `count_all` 二选一,且至少提供其一(仅图表类型) + - nps 类型必填:`table_name`、长度为 1 的 `group_by`;`count_all` 可省略,出现时只能为 `true`;不支持 `series` - text 类型**不支持**:`series`、`count_all`、`group_by`、`filter` - 长度/结构 - `group_by` 最多 2 个;每项 `field_name` 必填 @@ -225,6 +228,7 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty - 看流程转化 → 漏斗图 - 看多维度评分 → 雷达图 - 显示单个指标 → 指标卡(统计数字或记录数) +- 统计满意度评分分布 → NPS 图(一个 Rating 字段 + 可选分段) 最小柱状图: @@ -363,6 +367,20 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty } ``` +NPS 图(按 Rating 评分字段统计记录数): + +```json +{ + "table_name": "问卷结果", + "group_by": [{ "field_name": "满意度评分", "mode": "integrated" }], + "category_range": [0, 6, 8, 10] +} +``` + +NPS 的 `group_by[0].field_name` 必须指向 Base 的评分字段(Dashboard 内部识别为 `Rating` 语义)。调用方可通过 Base 字段详情或界面字段配置确认评分字段的最小值与最大值;CLI 只能做轻量 JSON 校验,字段类型、字段量程、`category_range` 首尾是否等于评分字段最小值和最大值由服务端按字段元数据校验。 + +`category_range` 可省略,服务端会按 Rating 字段自身量程生成默认分段。显式传入时数组长度必须为 4,且首尾必须等于 Rating 字段最小值和最大值。 + 指标卡(统计记录数): ```json