diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go index 20232090a6..66b1193bc0 100644 --- a/shortcuts/base/base_dashboard_execute_test.go +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -598,6 +598,49 @@ func TestBaseDashboardBlockDryRun_Create(t *testing.T) { } } +func TestBaseDashboardBlockCreate_CanonicalizesNPSType(t *testing.T) { + dataConfig := `{"table_name":"Survey","group_by":[{"field_name":"Score"}]}` + for _, blockType := range []string{"nps", "NPS", "NpS", " nps", " NpS "} { + t.Run("dry-run "+blockType, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "NPS", "--type", blockType, "--data-config", dataConfig, "--dry-run"} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"type": "nps"`) { + t.Fatalf("dry-run body did not canonicalize type: %s", got) + } + }) + + t.Run("execute "+blockType, func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &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_nps", "type": "nps"}, + }, + } + reg.Register(stub) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "NPS", "--type", blockType, "--data-config", dataConfig} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + body := decodeCapturedJSONBody(t, stub) + if body["type"] != "nps" { + t.Fatalf("request type=%#v, want nps", body["type"]) + } + group := body["data_config"].(map[string]interface{})["group_by"].([]interface{})[0].(map[string]interface{}) + if _, hasMode := group["mode"]; hasMode { + t.Fatalf("CLI unexpectedly added group_by mode: %#v", group) + } + }) + } +} + func TestBaseDashboardBlockDryRun_CreateRankingDefaults(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "负责人排行", "--type", "ranking", @@ -899,6 +942,222 @@ 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) + } + withoutMode := cloneMap(valid) + withoutMode["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score"}} + if problems := validateBlockDataConfig("nps", withoutMode); len(problems) != 0 { + t.Fatalf("nps should allow omitted group_by mode: %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: "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: "padded group_by mode", + mut: func(cfg map[string]interface{}) { + cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": " integrated "}} + }, + 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 28bba98dc1..ae6ccb2f59 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -852,6 +852,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 ranking --data-config '{"table_name":"Orders"`, `--type text --data-config '{"text":"# Sales Dashboard"}'`, "+table-list and +field-list", diff --git a/shortcuts/base/block_data_config.go b/shortcuts/base/block_data_config.go index 84c9d3c804..ccafc68605 100644 --- a/shortcuts/base/block_data_config.go +++ b/shortcuts/base/block_data_config.go @@ -45,6 +45,14 @@ func matchesBlockType(blockType string, candidates []string) bool { return false } +func normalizeDashboardBlockType(blockType string) string { + trimmed := strings.TrimSpace(blockType) + if strings.EqualFold(trimmed, "nps") { + return "nps" + } + return trimmed +} + func isTextBlockType(blockType string) bool { return matchesBlockType(blockType, textBlockTypes) } func isChartBlockType(blockType string) bool { return matchesBlockType(blockType, chartBlockTypes) } @@ -123,6 +131,9 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} { // normalizeDataConfigForCreate adds type-specific defaults only when the // create request declares the block type. func normalizeDataConfigForCreate(blockType string, cfg map[string]interface{}) map[string]interface{} { + if normalizeDashboardBlockType(blockType) == "nps" { + return cloneMap(cfg) + } out := normalizeDataConfig(cfg) if !matchesBlockType(blockType, []string{"ranking"}) || out == nil { return out @@ -145,17 +156,20 @@ func normalizeDataConfigForCreate(blockType string, cfg map[string]interface{}) // dashboard chart rules. BaseApp list validation lives in // 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 类型组件"} - } + blockType = strings.ToLower(normalizeDashboardBlockType(blockType)) switch { case isTextBlockType(blockType): - return validateTextDataConfig(blockType, cfg) + return append(validateNonNPSDataConfig(cfg), validateTextDataConfig(blockType, cfg)...) + case blockType == "nps": + return validateNPSDataConfig(cfg) case matchesBlockType(blockType, []string{"ranking"}): return validateRankingDataConfig(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)...) @@ -165,6 +179,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 @@ -280,6 +301,57 @@ 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 不能为空") + } + if rawMode, hasMode := m["mode"]; hasMode { + mode, modeOK := rawMode.(string) + if !modeOK || 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 +} + func validateRankingDataConfig(cfg map[string]interface{}) []string { var problems []string if tableName, _ := cfg["table_name"].(string); strings.TrimSpace(tableName) == "" { diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index 10324431a7..abc29cc3b0 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(雷达图)|ranking(排行榜)|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(雷达图)|ranking(排行榜)|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 "Top Owners" --type ranking --data-config '{"table_name":"Orders","group_by":[{"field_name":"Owner"}],"series":[{"field_name":"Amount","rollup":"SUM"}]}'`, `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}'`, @@ -52,9 +53,12 @@ var BaseDashboardBlockCreate = common.Shortcut{ raw := strings.TrimSpace(runtime.Str("data-config")) if raw == "" { if !runtime.Bool("no-validate") { - switch strings.ToLower(strings.TrimSpace(runtime.Str("type"))) { + blockType := strings.ToLower(normalizeDashboardBlockType(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") case "ranking": return errs.NewValidationError(errs.SubtypeInvalidArgument, "ranking 类型组件必须提供 data-config").WithParam("--data-config") } diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go index b5551c7504..dc1c6200fb 100644 --- a/shortcuts/base/dashboard_ops.go +++ b/shortcuts/base/dashboard_ops.go @@ -47,7 +47,7 @@ func buildDashboardBlockBody(pc *parseCtx, runtime *common.RuntimeContext, inclu body["name"] = name } if includeType { - if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { + if blockType := normalizeDashboardBlockType(runtime.Str("type")); blockType != "" { body["type"] = blockType } } 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 c3b221c499..3198feec57 100644 --- a/skills/lark-base/references/lark-base-dashboard-block-config.md +++ b/skills/lark-base/references/lark-base-dashboard-block-config.md @@ -19,11 +19,12 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档是 Dashboar | `radar` | 雷达图 | | `ranking` | 排行榜 | | `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 @@ -48,6 +49,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 类型特殊结构 @@ -214,6 +216,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` 必填 @@ -238,6 +241,7 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty - 看流程转化 → 漏斗图 - 看多维度评分 → 雷达图 - 显示单个指标 → 指标卡(统计数字或记录数) +- 统计满意度评分分布 → NPS 图(一个 Rating 字段 + 可选分段) - 查看单维度 Top N → 排行榜 最小柱状图: @@ -394,6 +398,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