diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go index 62f56d0d9..5f89b1a09 100644 --- a/shortcuts/base/base_dashboard_execute_test.go +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -244,6 +244,32 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) { } }) + t.Run("text block json readback diagnostics", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_text", + "name": "文本", + "type": "text", + "data_config": map[string]interface{}{ + "text": "\"图表说明\"", + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dashboard_text_json_readback"`) || !strings.Contains(got, `"text_plain": "图表说明"`) { + t.Fatalf("stdout=%s", got) + } + }) + t.Run("with user-id-type", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -270,35 +296,70 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) { // TestBaseDashboardBlockExecuteGetData tests the +dashboard-block-get-data command. func TestBaseDashboardBlockExecuteGetData(t *testing.T) { - factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "dimensions": []interface{}{ - map[string]interface{}{"field_name": "文本", "alias": "dim_text"}, - }, - "measures": []interface{}{ - map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"}, + t.Run("with measure values", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dimensions": []interface{}{ + map[string]interface{}{"field_name": "文本", "alias": "dim_text"}, + }, + "measures": []interface{}{ + map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"}, + }, + "main_data": []interface{}{ + map[string]interface{}{ + "dim_text": map[string]interface{}{"value": "A"}, + "me_count": map[string]interface{}{"value": 3}, + }, + }, }, - "main_data": []interface{}{ - map[string]interface{}{ - "dim_text": map[string]interface{}{"value": "A"}, - "me_count": map[string]interface{}{"value": 3}, + }, + }) + if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) { + t.Fatalf("stdout=%s", got) + } + if strings.Contains(got, `"_diagnostics"`) { + t.Fatalf("unexpected diagnostics in stdout=%s", got) + } + }) + + t.Run("diagnoses rows without measure values", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_empty_measure/data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dimensions": []interface{}{ + map[string]interface{}{"field_name": "任务名称", "alias": "dim_task"}, + }, + "measures": []interface{}{ + map[string]interface{}{"field_name": "处理时长", "aggregation": "sum", "alias": "me_duration"}, + }, + "main_data": []interface{}{ + map[string]interface{}{"dim_task": map[string]interface{}{"value": "A"}}, + map[string]interface{}{"dim_task": map[string]interface{}{"value": "B"}}, }, }, }, - }, + }) + if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_empty_measure"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"_diagnostics"`) || !strings.Contains(got, `"empty_measure_values"`) || !strings.Contains(got, `"me_duration"`) { + t.Fatalf("stdout=%s", got) + } }) - if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil { - t.Fatalf("err=%v", err) - } - got := stdout.String() - if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) { - t.Fatalf("stdout=%s", got) - } } // TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command. @@ -756,6 +817,38 @@ func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) { if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, "新内容") { t.Fatalf("stdout=%s", got) } + if strings.Contains(got, `"dashboard_text_json_readback"`) { + t.Fatalf("unexpected text diagnostics in stdout=%s", got) + } + }) + + t.Run("diagnoses json encoded text readback", 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", + "--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, `"dashboard_text_json_readback"`) || !strings.Contains(got, `"text_plain": "# 新内容"`) { + t.Fatalf("stdout=%s", got) + } }) t.Run("update without type skips strict validation", func(t *testing.T) { diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 79bd12ac4..da5f0b0f2 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1277,6 +1277,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) + t.Run("list json shorthand alias", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Name"}, + "record_id_list": []interface{}{"rec_json_alias"}, + "data": []interface{}{[]interface{}{"Charlie"}}, + "total": 1, + }, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--json"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_json_alias"`) || strings.Contains(got, "| Name |") { + t.Fatalf("stdout=%s", got) + } + }) + t.Run("list markdown format", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 4dd2769c4..94b23af80 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -461,6 +461,22 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) { } } +func TestBaseRecordListJSONAliasIsHidden(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseRecordList.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + flag := cmd.Flags().Lookup("json") + if flag == nil { + t.Fatal("flag --json missing") + } + if !flag.Hidden { + t.Fatal("flag --json must be hidden") + } + if strings.Contains(cmd.Flags().FlagUsages(), "--json") { + t.Fatalf("help should not include hidden --json:\n%s", cmd.Flags().FlagUsages()) + } +} + func TestBaseDashboardHelpGuidesAgents(t *testing.T) { tests := []struct { name string diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go index 0391ff51c..6d0e83f8e 100644 --- a/shortcuts/base/dashboard_ops.go +++ b/shortcuts/base/dashboard_ops.go @@ -5,6 +5,7 @@ package base import ( "context" + "encoding/json" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -257,6 +258,7 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error { if err != nil { return err } + attachDashboardTextBlockDiagnostics(data) runtime.Out(map[string]interface{}{"block": data}, nil) return nil } @@ -267,10 +269,80 @@ func executeDashboardBlockGetData(runtime *common.RuntimeContext) error { if err != nil { return err } + attachDashboardBlockDataDiagnostics(data) runtime.Out(data, nil) return nil } +func attachDashboardBlockDataDiagnostics(data map[string]interface{}) { + measureAliases := dashboardBlockMeasureAliases(data["measures"]) + if len(measureAliases) == 0 { + return + } + rows, ok := data["main_data"].([]interface{}) + if !ok || len(rows) == 0 { + return + } + for _, rawRow := range rows { + row, ok := rawRow.(map[string]interface{}) + if !ok { + continue + } + if dashboardBlockRowHasMeasureValue(row, measureAliases) { + return + } + } + data["_diagnostics"] = []interface{}{ + map[string]interface{}{ + "type": "empty_measure_values", + "message": "chart data defines measures, but main_data rows contain no computed measure values", + "hint": "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.", + "measure_aliases": measureAliases, + "main_data_rows": len(rows), + }, + } +} + +func dashboardBlockMeasureAliases(raw interface{}) []string { + measures, ok := raw.([]interface{}) + if !ok { + return nil + } + aliases := make([]string, 0, len(measures)) + for _, rawMeasure := range measures { + measure, ok := rawMeasure.(map[string]interface{}) + if !ok { + continue + } + alias, ok := measure["alias"].(string) + if !ok { + continue + } + alias = strings.TrimSpace(alias) + if alias != "" { + aliases = append(aliases, alias) + } + } + return aliases +} + +func dashboardBlockRowHasMeasureValue(row map[string]interface{}, aliases []string) bool { + for _, alias := range aliases { + value, ok := row[alias] + if !ok || value == nil { + continue + } + valueMap, ok := value.(map[string]interface{}) + if !ok { + return true + } + if nestedValue, exists := valueMap["value"]; exists && nestedValue != nil { + return true + } + } + return false +} + // executeDashboardBlockCreate creates a new dashboard block. func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) @@ -325,10 +397,59 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error { if err != nil { return err } + attachDashboardTextBlockDiagnostics(data) runtime.Out(map[string]interface{}{"block": data, "updated": true}, nil) return nil } +func attachDashboardTextBlockDiagnostics(block map[string]interface{}) { + if !isDashboardTextBlock(block) { + return + } + cfg, ok := block["data_config"].(map[string]interface{}) + if !ok { + return + } + rawText, ok := cfg["text"].(string) + if !ok || strings.TrimSpace(rawText) == "" { + return + } + plainText, ok := decodeDashboardTextReadback(rawText) + if !ok || plainText == rawText { + return + } + appendDashboardDiagnostic(block, map[string]interface{}{ + "type": "dashboard_text_json_readback", + "message": "text block data_config.text is returned as a JSON-encoded string", + "hint": "When verifying text block content, compare text_plain with the target text; keep data_config.text unchanged for raw API compatibility.", + "text_plain": plainText, + }) +} + +func isDashboardTextBlock(block map[string]interface{}) bool { + blockType, _ := block["type"].(string) + if blockType == "" { + blockType, _ = block["block_type"].(string) + } + return strings.EqualFold(strings.TrimSpace(blockType), "text") +} + +func decodeDashboardTextReadback(raw string) (string, bool) { + var decoded string + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return "", false + } + return decoded, true +} + +func appendDashboardDiagnostic(target map[string]interface{}, diagnostic map[string]interface{}) { + if existing, ok := target["_diagnostics"].([]interface{}); ok { + target["_diagnostics"] = append(existing, diagnostic) + return + } + target["_diagnostics"] = []interface{}{diagnostic} +} + // 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) diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index b91bb742d..a6ca60db9 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -28,6 +28,7 @@ var BaseRecordList = common.Shortcut{ {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, pageSizeLimitAliasFlag(), recordReadFormatFlag(), + recordReadJSONOutputAliasFlag(), }, Tips: []string{ "Example: lark-cli base +record-list --base-token --table-id --limit 50", @@ -88,3 +89,7 @@ func recordReadFormatFlag() common.Flag { Desc: "output format: markdown (default) | json", } } + +func recordReadJSONOutputAliasFlag() common.Flag { + return common.Flag{Name: "json", Type: "bool", Desc: "hidden shorthand for --format json", Hidden: true} +} diff --git a/shortcuts/base/record_markdown.go b/shortcuts/base/record_markdown.go index ea5c86b9d..dfd312edf 100644 --- a/shortcuts/base/record_markdown.go +++ b/shortcuts/base/record_markdown.go @@ -16,6 +16,12 @@ import ( const maxRecordMarkdownIgnoredFields = 20 func validateRecordReadFormat(runtime *common.RuntimeContext) error { + if recordReadJSONOutputAlias(runtime) { + if runtime.Changed("format") && runtime.Str("format") != "json" { + return baseValidationErrorf("--json and --format %s are mutually exclusive; use --format json", runtime.Str("format")) + } + return nil + } switch runtime.Str("format") { case "", "json", "markdown": return nil @@ -24,6 +30,18 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error { } } +func recordReadOutputFormat(runtime *common.RuntimeContext) string { + if recordReadJSONOutputAlias(runtime) { + return "json" + } + return runtime.Str("format") +} + +func recordReadJSONOutputAlias(runtime *common.RuntimeContext) bool { + flag := runtime.Cmd.Flags().Lookup("json") + return flag != nil && flag.Value.Type() == "bool" && flag.Changed && runtime.Bool("json") +} + func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error { return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown) } diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index ec13c829e..7cf1fe0d6 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -403,7 +403,7 @@ func executeRecordList(runtime *common.RuntimeContext) error { if err != nil { return err } - if runtime.Str("format") == "markdown" { + if recordReadOutputFormat(runtime) == "markdown" { return outputRecordMarkdown(runtime, data) } runtime.Out(data, nil) diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 3399146dc..9b3091758 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-*` | 先按下方 Dashboard 快路径执行;组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);完整查看/编辑/排障再读 [lark-base-dashboard.md](references/lark-base-dashboard.md) | | 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);系统角色不可删除;关闭高级权限会影响自定义角色 | @@ -90,68 +90,33 @@ metadata: - `91403` 或明确不可访问错误不要循环换身份重试。 - `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。 -## 查询与统计规则 - -涉及查询、统计或判断结论时,先阅读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守: - -1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。 -2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉原始记录到本地上下文再手工筛选排序。 -3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。 -4. 多表查询必须先确认关系字段和连接键;link 单元格里的 `record_id` 是关系键,不是用户可读答案。 -5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。 -6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。 -7. `+data-query` 可返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。 - -## 写入前置规则 - -- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。 -- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 -- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。 -- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。 -- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。 -- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。 -- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。 -- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。 - -## 表单与视图细节 - -- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。 -- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。 -- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。 -- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。 - -## Dashboard / Workflow / Role - -- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 -- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 - -## 常见恢复 - -| 错误 / 现象 | 恢复动作 | -|---|---| -| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按入口规则重新获取真实 `base_token` | -| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API | -| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 | -| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue | -| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 | -| formula / lookup 创建失败 | 先读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md),再按 guide 重建请求 | -| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 | -| `1254104` | 批量超过 200,分批调用 | -| `1254291` | 并发写冲突,串行写入并在批次间短暂等待 | -| `91403` | 无权限访问该 Base,按 `lark-shared` 权限流程处理,不要盲目重试 | - -## 保留 Reference - -- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP -- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT -- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造 -- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造 -- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段 -- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充 -- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释 -- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON -- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON -- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议 -- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT -- [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT +## 高频硬约束 + +- 查询/统计/全局结论先读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md);聚合优先 `+data-query`,不要用默认页或本地 `jq` 代替全量筛选、排序、分组和计数。 +- 写记录或字段前先读真实字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不当作普通记录字段写入。 +- 字段 JSON、公式、lookup、CellValue、表单、视图筛选、workflow steps、role 权限 JSON 都是复杂结构;需要创建/更新时读对应 reference,不在入口猜 schema。 +- 批量写入单批最多 200 条;连续写同一表或同一 dashboard 的组件时串行执行,遇到 `1254291` 按短暂等待后重试。 +- `91403` 或明确不可访问错误不要循环换身份重试,按权限错误处理或转 `lark-shared`。 + +## 链接与 Token 快判 + +- Base URL 不是命令 flag;`lark-cli base +...` 不接受 `--url`。从 `/base/` 提取 token 后传 `--base-token `。 +- `/base/{token}` 提取 token 作为 `--base-token`;`?table=tbl...` 是 `--table-id`,`?table=blk...` 是 dashboard ID,`?view=...` 是 `--view-id`。 +- `/wiki/{token}` 先用 wiki 节点查询;只有节点对象是 bitable 时,才把 obj token 当作 `--base-token`。 +- `/share/base/view/...`、`/share/base/dashboard/...`、`/record/...`、`/base/workspace/...` 暂不支持直接用 Base CLI 访问;生成记录分享链接用 `+record-share-link-create`。 + +## Dashboard 快路径 + +- 新建仪表盘:`+base-get` 确认 Base,`+base-block-list` / `+table-list` / `+field-list` 确认真实资源,再 `+dashboard-create`,随后串行 `+dashboard-block-create`。 +- 创建 block 前必须确定 `dashboard_id`、组件 `name/type`、真实表名和字段名;`data_config` 使用表名/字段名,不用 table_id/field_id。 +- 构造或更新 block `data_config` 时读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md)。需要完整 dashboard 工作流、查看/编辑已有仪表盘或排障时,再读 [lark-base-dashboard.md](references/lark-base-dashboard.md)。 +- `+dashboard-arrange` 是服务端智能布局;只有用户明确要求重排/美化,或你已创建多个组件且需要交付可视化布局时再执行。 +- `+dashboard-block-get-data` 只接受 `--base-token` 和 `--block-id`,不要传 `--dashboard-id`。它只读图表计算结果;需要 block 名称、类型、布局或 `data_config` 时用 `+dashboard-block-get`。 +- 校验图表时,`_diagnostics.type = "empty_measure_values"` 表示该图表未真正产出指标值;先更新或重建 block,再向用户声明完成。 +- 新建宽泛看板时,交付验证优先用 `+dashboard-get` / `+dashboard-block-list` 确认组件存在,并抽样校验关键统计或图表;只有用户要求逐项数据或诊断异常时,才对每个组件逐个 `+dashboard-block-get-data`。 + +## 其他复杂入口 + +- Workflow 创建/更新或解释 steps:读 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只需确认 workflow ID。 +- Role 操作:读 [lark-base-role-guide.md](references/lark-base-role-guide.md);create/update 或解读完整权限配置再读 [role-config.md](references/role-config.md)。系统角色不可删除。 +- 表单提交前必须先 `+form-detail`;附件放在 `--json.attachments`,不要写进普通 fields。 diff --git a/tests/cli_e2e/base/base_record_list_dryrun_test.go b/tests/cli_e2e/base/base_record_list_dryrun_test.go new file mode 100644 index 000000000..58f3c70c4 --- /dev/null +++ b/tests/cli_e2e/base/base_record_list_dryrun_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseRecordListDryRunAcceptsJSONOutputAlias(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-list", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--limit", "5", + "--field-id", "Name", + "--json", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + url := gjson.Get(result.Stdout, "api.0.url").String() + require.Contains(t, url, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records") + require.Contains(t, url, "limit=5") + require.Contains(t, url, "field_id=Name") +}