From b5eb4c643d112e5b637e344cf151255beb5e88c5 Mon Sep 17 00:00:00 2001 From: mobaijie Date: Thu, 30 Apr 2026 17:50:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Product=20CLI=204=E6=9C=9F=20no-meego?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d --- .gitignore | 2 + shortcuts/base/base_dryrun_ops_test.go | 35 ++ shortcuts/base/base_form_detail.go | 44 +++ shortcuts/base/base_form_submit.go | 316 +++++++++++++++++ shortcuts/base/base_shortcuts_test.go | 3 +- shortcuts/base/shortcuts.go | 2 + skills/lark-base/SKILL.md | 2 + .../references/lark-base-form-detail.md | 318 ++++++++++++++++++ .../references/lark-base-form-submit.md | 172 ++++++++++ skills/lark-base/references/lark-base-form.md | 3 +- .../base/base_form_detail_dryrun_test.go | 65 ++++ 11 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 shortcuts/base/base_form_detail.go create mode 100644 shortcuts/base/base_form_submit.go create mode 100644 skills/lark-base/references/lark-base-form-detail.md create mode 100644 skills/lark-base/references/lark-base-form-submit.md create mode 100644 tests/cli_e2e/base/base_form_detail_dryrun_test.go diff --git a/.gitignore b/.gitignore index 90313e480..d2918cf40 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ cmd/api/download.bin app.log /sidecar-server-demo /server-demo + +lark-env.sh diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index b3d59aa7b..66833a5f6 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -284,3 +284,38 @@ func TestDryRunViewOps(t *testing.T) { assertDryRunContains(t, dryRunViewGetProperty(listRT, "a/b"), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/a%2Fb") } + +func TestDryRunFormSubmit(t *testing.T) { + ctx := context.Background() + + // fields-only mode (share-token, no attachments) + shareTokenRT := newBaseTestRuntime( + map[string]string{ + "share-token": "shrXXXX", + "json": `{"fields":{"服务评分":5,"评价内容":"服务态度好"}}`, + }, + nil, nil, + ) + assertDryRunContains(t, + dryRunFormSubmit(ctx, shareTokenRT), + "POST /open-apis/base/v3/bases/tables/forms/submit", + `"share_token":"shrXXXX"`, + `"服务评分":5`, + `"评价内容":"服务态度好"`) + + // with attachments inside --json: { "fields": {...}, "attachments": { fieldName: [paths...] } } + withAttachmentsRT := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "share-token": "shrXXXX", + "json": `{"fields":{"服务评分":5},"attachments":{"附件":["./report.pdf","./image.png"],"截图":["./screenshot.png"]}}`, + }, + nil, nil, + ) + assertDryRunContains(t, + dryRunFormSubmit(ctx, withAttachmentsRT), + "POST /open-apis/base/v3/bases/tables/forms/submit", + "Upload attachment for field \"附件\": report.pdf", + "Upload attachment for field \"附件\": image.png", + "Upload attachment for field \"截图\": screenshot.png") +} diff --git a/shortcuts/base/base_form_detail.go b/shortcuts/base/base_form_detail.go new file mode 100644 index 000000000..4dc765003 --- /dev/null +++ b/shortcuts/base/base_form_detail.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormDetail = common.Shortcut{ + Service: "base", + Command: "+form-detail", + Description: "Get form detail by share token", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "share-token", Desc: "Form share token (share_token)", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/tables/forms/detail"). + Body(map[string]interface{}{ + "share_token": runtime.Str("share-token"), + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := map[string]interface{}{ + "share_token": runtime.Str("share-token"), + } + + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", "tables", "forms", "detail"), nil, body) + if err != nil { + return err + } + + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/base_form_submit.go b/shortcuts/base/base_form_submit.go new file mode 100644 index 000000000..5116690fd --- /dev/null +++ b/shortcuts/base/base_form_submit.go @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync" + + "golang.org/x/sync/errgroup" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + uploadAttachConcurrency = 5 +) + +var BaseFormSubmit = common.Shortcut{ + Service: "base", + Command: "+form-submit", + Description: "Submit a form (fill and submit form data)", + Risk: "write", + Scopes: []string{"base:form:update", "docs:document.media:upload"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + {Name: "share-token", Desc: "表单分享 Token(必填),从表单分享链接中提取", Required: true}, + {Name: "base-token", Desc: "Base token(--json 包含 attachments 时必填,用于上传附件到 Base Drive Media)"}, + {Name: "json", Desc: `JSON 对象,包含 "fields"(普通字段值)和 "attachments"(附件文件路径)。示例:'{"fields":{"评分":5,"评价":"好"},"attachments":{"附件":["./a.pdf","./b.png"]}}'`, Required: true}, + }, + Tips: []string{ + `示例(无附件):--share-token shrXXXX --json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'`, + `示例(带附件):--share-token shrXXXX --base-token basXXX --json '{"fields":{"服务评分":5},"attachments":{"附件":["./report.pdf"]}}'`, + `"fields" 中的单元格值写法与 lark-base-cell-value.md 对齐;"attachments" 将字段名映射到本地文件路径数组,CLI 自动并行上传后合并写入。`, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFormSubmit(runtime) + }, + DryRun: dryRunFormSubmit, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFormSubmit(runtime) + }, +} + +func validateFormSubmit(runtime *common.RuntimeContext) error { + // 校验 --json 结构:提取 "fields" 和 "attachments" + pc := newParseCtx(runtime) + raw, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return err + } + + fields, _ := raw["fields"].(map[string]interface{}) + attachments, hasAttachments := raw["attachments"] + + if !hasAttachments && fields == nil { + return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"") + } + + if hasAttachments { + // 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要) + if runtime.Str("base-token") == "" { + return common.FlagErrorf("--base-token is required when --json contains \"attachments\"") + } + + attMap, ok := attachments.(map[string]interface{}) + if !ok { + return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays") + } + for fieldName, value := range attMap { + paths, ok := value.([]interface{}) + if !ok { + return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value) + } + for i, item := range paths { + if _, ok := item.(string); !ok { + return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item) + } + } + if len(paths) == 0 { + return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName) + } + } + } + + return nil +} + +// parseFormSubmitJSON 将 --json 解析为字段和附件映射。 +func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) { + pc := newParseCtx(runtime) + raw, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return nil, nil, err + } + + fields, _ := raw["fields"].(map[string]interface{}) + if fields == nil { + fields = make(map[string]interface{}) + } + + var attMap map[string][]string + if attachments, ok := raw["attachments"]; ok { + attObj, ok := attachments.(map[string]interface{}) + if !ok { + return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`) + } + if len(attObj) > 0 { + attMap = make(map[string][]string, len(attObj)) + for fieldName, value := range attObj { + paths, ok := value.([]interface{}) + if !ok { + return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value) + } + filePaths := make([]string, 0, len(paths)) + for _, item := range paths { + if s, ok := item.(string); ok { + filePaths = append(filePaths, s) + } else { + return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item) + } + } + if len(filePaths) > 0 { + attMap[fieldName] = filePaths + } + } + } + } + + return fields, attMap, nil +} + +func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + fields, attachmentMap, err := parseFormSubmitJSON(runtime) + if err != nil { + return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err)) + } + + if len(attachmentMap) > 0 { + dry := common.NewDryRunAPI(). + Desc("Form submit with attachments: upload local files per field → merge with fields → submit") + + for fieldName, filePaths := range attachmentMap { + for _, p := range filePaths { + fileName := filepath.Base(p) + dry = dry.POST("/open-apis/drive/v1/medias/upload_all"). + Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": baseAttachmentParentType, + "parent_node": "", + "file": "@" + p, + "size": "", + }) + } + } + + body := buildFormSubmitBody(runtime, fields) + dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit"). + Body(body). + Desc("Submit form with uploaded attachment tokens merged with fields") + return dry + } + + body := buildFormSubmitBody(runtime, fields) + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/tables/forms/submit"). + Body(body) +} + +func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "share_token": runtime.Str("share-token"), + "content": content, + } +} + +func executeFormSubmit(runtime *common.RuntimeContext) error { + fields, attachmentMap, err := parseFormSubmitJSON(runtime) + if err != nil { + return err + } + + // 上传附件并合并到字段中 + if len(attachmentMap) > 0 { + baseToken := runtime.Str("base-token") + fio := runtime.FileIO() + if fio == nil { + return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)") + } + + // Step 1: 收集所有唯一路径(跨字段去重) + allPaths := collectUniquePaths(attachmentMap) + if len(allPaths) == 0 { + return common.FlagErrorf("attachments in --json contains no valid file paths") + } + + // Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用 + sizeMap := make(map[string]int64, len(allPaths)) + for _, filePath := range allPaths { + if _, err := validate.SafeInputPath(filePath); err != nil { + return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err) + } + fileInfo, err := fio.Stat(filePath) + if err != nil { + if errors.Is(err, fileio.ErrPathValidation) { + return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err) + } + return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err) + } + if fileInfo.Size() > baseAttachmentUploadMaxFileSize { + return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath) + } + if !fileInfo.Mode().IsRegular() { + return output.ErrValidation("attachment file %s is not a regular file", filePath) + } + sizeMap[filePath] = fileInfo.Size() + } + + // Step 3: 并行上传,构建路径 → 附件结果映射 + fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths)) + resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseToken, sizeMap) + if err != nil { + return err + } + + // Step 4: 根据共享结果映射,按字段组装单元格 + for fieldName, filePaths := range attachmentMap { + cell := make([]interface{}, 0, len(filePaths)) + for _, p := range filePaths { + if att, ok := resultMap[p]; ok { + cell = append(cell, att) + } + } + fields[fieldName] = cell + } + fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap)) + } + + body := buildFormSubmitBody(runtime, fields) + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", "tables", "forms", "submit"), + nil, body) + if err != nil { + return err + } + + runtime.Out(data, nil) + return nil +} + +// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。 +func collectUniquePaths(attachmentMap map[string][]string) []string { + seen := make(map[string]bool, len(attachmentMap)*4) + var order []string + for _, filePaths := range attachmentMap { + for _, p := range filePaths { + if !seen[p] { + seen[p] = true + order = append(order, p) + } + } + } + return order +} + +// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。 +func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, baseToken string, sizeMap map[string]int64) (map[string]interface{}, error) { + var ( + mu sync.Mutex + resultMap = make(map[string]interface{}, len(paths)) + ) + + g, _ := errgroup.WithContext(runtime.Ctx()) + g.SetLimit(uploadAttachConcurrency) // 限制并发数 + + for _, filePath := range paths { + fp := filePath // 捕获循环变量 + g.Go(func() error { + fileName := filepath.Base(fp) + fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName) + + att, err := uploadSingleAttachment(runtime, fp, fileName, baseToken, sizeMap[fp]) + if err != nil { + return err + } + + mu.Lock() + resultMap[fp] = att + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + return resultMap, nil +} + +// uploadSingleAttachment 上传单个文件,返回附件单元格项。 +// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。 +func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (interface{}, error) { + att, err := uploadAttachmentToBase(runtime, filePath, fileName, baseToken, fileSize) + if err != nil { + return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err) + } + return att, nil +} diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index eeca3b8d1..7084fd401 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -138,8 +138,9 @@ func TestShortcutsCatalog(t *testing.T) { "+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable", "+workflow-list", "+workflow-get", "+workflow-create", "+workflow-update", "+workflow-enable", "+workflow-disable", "+data-query", - "+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", + "+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", "+form-detail", "+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list", + "+form-submit", "+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange", "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete", } diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index 60ebfe000..7d16252ff 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -68,10 +68,12 @@ func Shortcuts() []common.Shortcut { BaseFormsList, BaseFormUpdate, BaseFormGet, + BaseFormDetail, BaseFormQuestionsCreate, BaseFormQuestionsDelete, BaseFormQuestionsUpdate, BaseFormQuestionsList, + BaseFormSubmit, BaseDashboardList, BaseDashboardGet, BaseDashboardCreate, diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 0a8218087..3452daa2b 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -186,6 +186,8 @@ metadata: | 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 | |------|------------------|----------------|----------| | `+form-list / +form-get` | 列出表单,或获取单个表单 | [`lark-base-form-list.md`](references/lark-base-form-list.md)、[`lark-base-form-get.md`](references/lark-base-form-get.md) | `+form-list` 可用来获取 `form-id`;`+form-get` 适合查看已有表单配置 | +| `+form-detail` | 通过表单分享链接获取表单详情(含题目列表、字段类型、校验规则) | [`lark-base-form-detail.md`](references/lark-base-form-detail.md) | 只读;仅需 `--share-token`(从分享链接提取),不需要 base-token/table-id/form-id;返回的 `questions` 可直接用于 `+form-submit` 构造参数 | +| `+form-submit` | 通过表单分享链接填写并提交表单(支持普通字段 + 附件上传) | [`lark-base-form-submit.md`](references/lark-base-form-submit.md) | 写入操作;仅支持 share_token 模式;**当 `--json` 包含 attachments 时必须额外提供 `--base-token`**(附件上传到 Base Drive Media 需要);附件通过 `--json.attachments` 传入本地路径,CLI 自动并行上传 | | `+form-create / +form-update / +form-delete` | 创建、更新或删除表单 | [`lark-base-form-create.md`](references/lark-base-form-create.md)、[`lark-base-form-update.md`](references/lark-base-form-update.md)、[`lark-base-form-delete.md`](references/lark-base-form-delete.md) | 创建后可继续进入表单问题相关操作;更新或删除前先确认目标表单 | | `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 | | `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`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)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 | diff --git a/skills/lark-base/references/lark-base-form-detail.md b/skills/lark-base/references/lark-base-form-detail.md new file mode 100644 index 000000000..ef70effa0 --- /dev/null +++ b/skills/lark-base/references/lark-base-form-detail.md @@ -0,0 +1,318 @@ +# base +form-detail + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +通过表单分享 Token 获取表单详情(含表单元信息、题目详情)。只读操作,不修改任何数据。 + +与 `+form-get` 的区别:`+form-get` 需要 `base-token` + `table-id` + `form-id`(从 Base 内部获取);`+form-detail` 仅需 `share-token`(从分享链接获取,无需知道 Base/表信息)。 + +## 命令 + +```bash +# 通过 share_token 获取表单详情 +lark-cli base +form-detail \ + --share-token + +# 以 pretty 格式展示(适合阅读 questions 结构) +lark-cli base +form-detail \ + --share-token \ + --format pretty + +# 使用 jq 过滤只看题目列表 +lark-cli base +form-detail \ + --share-token \ + --jq '.data.questions' + +# 预览 API 调用(不执行) +lark-cli base +form-detail \ + --share-token \ + --dry-run + +# 使用应用身份(bot) +lark-cli base +form-detail \ + --share-token \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--share-token ` | 是 | 表单分享 Token(从表单分享链接中提取) | +| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv | +| `--as` | 否 | 身份:user(默认)\| bot | +| `--dry-run` | 否 | 预览 API 调用,不执行 | +| `--jq ` | 否 | 用 jq 表达式过滤 JSON 输出 | + +### 从分享链接提取 share-token + +用户提供形如以下格式的表单分享链接时: + +``` +https://bitable-test.feishu-boe.cn/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye +``` + +**提取方式:** 取 URL 路径最后一段作为 `--share-token`。 + +以上述链接为例: + +- `share-token` = `shrbcvST8eZy0vk8zjVZ1CAXNye` + +```bash +lark-cli base +form-detail \ + --share-token shrbcvST8eZy0vk8zjVZ1CAXNye +``` + +## 输出格式 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `base_token` | string | 所属多维表格 Base token | +| `name` | string | 表单名称 | +| `description` | string | 表单描述 | +| `questions[]` | array | 题目列表(含 id / title / type / required / description / filter) | + +### questions 中每个题目的字段 + +#### 固定字段(所有题目共有) + +| 字段 | 类型 | 是否必填 | 说明 | +|------|------|----------|------| +| `id` | string | 是 | 题目标识(对应 field_id) | +| `title` | string | 是 | 题目标题 | +| `type` | string | 是 | 字段类型(见下方类型对照表,与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 对齐) | +| `required` | bool | 是 | 是否必填 | +| `description` | string | 否 | 题目描述 | +| `filter` | object | 否 | 题目显示条件(详见下方 filter 结构说明) | + +#### 动态字段(按 type 不同而不同,直接平铺在 question 中) + +除上述固定字段外,每种 `type` 还会携带该类型特有的配置字段(与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的「常见补充字段」对应),例如: + +- **text** → `style`(含 `style.type`: plain / phone / url / email / barcode) +- **number** → `style`(含 `style.type`: plain / currency / progress / rating 及其子配置) +- **select** → `multiple`(bool)、`options`(选项列表)或 `dynamic_options_source` +- **datetime / created_at / updated_at** → `style.format` +- **user / group_chat** → `multiple` +- **link** → `link_table`、`bidirectional`、`bidirectional_link_field_name` +- **formula** → `expression` +- **lookup** → `from`、`select`、`where`、`aggregate` +- **auto_number** → `style.rules` +- **attachment / location / checkbox / stage / created_by / updated_by** → 无额外动态字段 + +### filter 结构说明 + +`filter` 控制题目在表单中的显示/隐藏逻辑,由 `conjunction`(逻辑关系)和 `conditions`(条件列表)组成。 + +以下以一个「活动报名」表单为例,其中「紧急联系人」题目的 filter 配置: + +```json +{ + "conjunction": "and", + "conditions": [ + {"field_name": "是否携带家属", "operator": "is", "value": ["是"]}, + {"field_name": "参与人数", "operator": "isGreater", "value": [1]} + ] +} +``` + +> 以上述 JSON 为例:当题目「是否携带家属」的值为「是」**并且**题目「参与人数」大于 1 时,「紧急联系人」才会展示(`conjunction: "and"` 表示全部条件需同时满足;若为 `"or"` 则任一条件满足即显示)。 + +另一个常见场景——用 `or` 控制可选填的补充信息: + +```json +{ + "conjunction": "or", + "conditions": [ + {"field_name": "满意度评分", "operator": "isLessEqual", "value": [3]}, + {"field_name": "是否愿意回访", "operator": "is", "value": ["是"]} + ] +} +``` + +> 即:评分 ≤ 3 **或** 愿意接受回访时,才展示「改进建议」文本框。 + +#### filter 字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `conjunction` | string | 条件间逻辑关系:`and`(全部满足) / `or`(任一满足) | +| `conditions[]` | array | 条件列表 | + +#### conditions 中每个条件项的字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `field_name` | string | 所依赖的题目标题(引用其他题目的 title) | +| `operator` | string | 过滤操作符(见下方 operator 可选值) | +| `value` | array | 过滤值数组(部分 operator 不需要,如 `isEmpty` / `isNotEmpty`) | + +#### operator 可选值 + +| operator | 含义 | 适用类型 | +|----------|------|----------| +| `is` | 等于 | 除附件外全部 | +| `isNot` | 不等于 | 除附件外全部 | +| `contains` | 包含 | 文本、选项、人员、群聊、地理位置 | +| `doesNotContain` | 不包含 | 文本、选项、人员、群聊、地理位置 | +| `isEmpty` | 为空 | 全部 | +| `isNotEmpty` | 不为空 | 全部 | +| `isGreater` | 大于 | 数字、日期时间 | +| `isGreaterEqual` | 大于等于 | 数字、日期时间 | +| `isLess` | 小于 | 数字、日期时间 | +| `isLessEqual` | 小于等于 | 数字、日期时间 | + +> **附件(attachment)特殊说明:** 仅支持 `isEmpty` 和 `isNotEmpty`,不支持 `is` / `isNot` / `contains` 及比较操作符。 + +#### value 的格式(按所依赖题目的类型区分) + +| 所依赖题目类型 | value 格式 | 示例 | +|----------------|-----------|------| +| 文本类(text / phone / email / url 等) | 字符串数组 | `["1", "2"]` | +| 数字类(number) | 数字数组 | `[1, 2]` | +| 选项类(select / multi_select) | 选项名称数组 | `["选项A", "选项B"]` | +| 人员类(user) | open_id 数组 | `["ou_d57864434a537020cf7a4a681d393e2d"]` | +| 群聊类(group_chat) | open_id 数组 | `["oc_f62478de5cc958583191e778db972603"]` | +| 地理位置(location) | 地点名称数组 | `["北京总部"]` | +| 日期时间类(datetime) | 时间字符串数组,固定格式 `yyyy-MM-dd HH:mm:ss` | `["2026-05-07 14:30:00"]` | +| 关联(link / duplexlink) | 记录 ID 数组 | `["recxxxxxxx", "recyyyyyyy"]` | + +### type 可选值 + +与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的字段类型完全对齐。 + +| type 值 | 含义 | 常见动态字段 | +|----------|------|-------------| +| `text` | 文本(含电话/邮箱/链接/条码等子类型) | `style` | +| `number` | 数字(含货币/进度/评分等子类型) | `style` | +| `select` | 选项(单选/多选由 `multiple` 区分) | `multiple`、`options` / `dynamic_options_source` | +| `datetime` | 日期时间 | `style.format` | +| `user` | 人员 | `multiple` | +| `group_chat` | 群组 | `multiple` | +| `attachment` | 附件 | 无 | +| `location` | 地理位置 | 无 | +| `checkbox` | 复选框 | 无 | +| `link` | 关联 | `link_table`、`bidirectional`、`bidirectional_link_field_name` | +| `formula` | 公式 | `expression` | +| `lookup` | 引用 | `from`、`select`、`where`、`aggregate` | +| `auto_number` | 自动编号 | `style.rules` | +| `created_at` | 创建时间 | `style.format` | +| `updated_at` | 更新时间 | `style.format` | +| `created_by` | 创建人 | 无 | +| `updated_by` | 更新人 | 无 | +| `stage` | 阶段 | 无 | + +```json +{ + "ok": true, + "data": { + "base_token": "DEAabEZVXaV1AasQn4bb6VFRcPd", + "name": "2026 年度技术大会报名", + "description": "请填写参会信息,带 * 为必填项", + "questions": [ + { + "id": "fldzaYFpb6", + "required": true, + "title": "姓名", + "type": "text" + }, + { + "id": "fldCoBpOlx", + "required": true, + "title": "手机号", + "type": "text", + "style": { "type": "phone" } + }, + { + "id": "fldmmhZFCs", + "required": false, + "title": "公司邮箱", + "type": "text", + "style": { "type": "email" } + }, + { + "id": "fldhqmqCj8", + "required": true, + "title": "参会日期", + "type": "datetime", + "style": { "format": "yyyy-MM-dd" } + }, + { + "id": "fldlyRrfrN", + "required": true, + "title": "参与人数", + "type": "number" + }, + { + "id": "fldRakYky3", + "required": false, + "title": "是否携带家属", + "type": "select", + "multiple": false, + "options": [ + { "name": "是", "hue": "Green", "lightness": "Lighter" }, + { "name": "否", "hue": "Gray", "lightness": "Lighter" } + ] + }, + { + "id": "fldyrOO0X4", + "required": false, + "title": "紧急联系人", + "type": "text", + "filter": { + "conjunction": "and", + "conditions": [ + {"field_name": "是否携带家属", "operator": "is", "value": ["是"]}, + {"field_name": "参与人数", "operator": "isGreater", "value": [1]} + ] + } + }, + { + "id": "fldM9AsRc2", + "required": false, + "title": "上传简历", + "type": "attachment", + "filter": { + "conjunction": "or", + "conditions": [ + {"field_name": "是否携带家属", "operator": "isNotEmpty"} + ] + } + }, + { + "id": "fldN7PsWx1", + "required": true, + "title": "所属部门", + "type": "user", + "multiple": false + }, + { + "id": "fldKq3mTz8", + "required": true, + "title": "参会主题", + "type": "select", + "multiple": true, + "options": [ + { "name": "AI 与大模型", "hue": "Purple", "lightness": "Lighter" }, + { "name": "云原生", "hue": "Blue", "lightness": "Lighter" }, + { "name": "工程效能", "hue": "Orange", "lightness": "Lighter" }, + { "name": "前端技术", "hue": "Carmine", "lightness": "Lighter" } + ] + } + ] + } +} +``` + +## 提示 + +- `share_token` 从表单分享链接中提取,格式通常为 `shr` + 随机字符串(如 `shrbcvST8eZy0vk8zjVZ1CAXNye`) +- 返回的 `questions` 列表可直接用于构造 `+form-submit` 的 `--json.fields` 参数 +- `questions[].title` 对应题目标题,可用于 `+form-submit` 的字段名映射 +- 如果需要通过 Base 内部路径操作表单,使用 `+form-get`(需要 base-token / table-id / form-id) + +## 参考 + +- [lark-base](../SKILL.md) — 多维表格全部命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 +- [lark-base-form-submit](lark-base-form-submit.md) — 获取详情后可用 submit 填写提交 diff --git a/skills/lark-base/references/lark-base-form-submit.md b/skills/lark-base/references/lark-base-form-submit.md new file mode 100644 index 000000000..b7d8e1dbf --- /dev/null +++ b/skills/lark-base/references/lark-base-form-submit.md @@ -0,0 +1,172 @@ +# base +form-submit + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +通过表单分享链接填写并提交多维表格表单。仅支持分享模式(share_token),支持填写普通字段值和上传本地文件作为附件。 + +## 填写前必读:先获取表单详情 + +**在调用 `+form-submit` 之前,必须先使用 [`+form-detail`](lark-base-form-detail.md) 获取表单详情。** 原因如下: + +1. **字段类型匹配**:每个题目的 `type` 决定了值的格式(文本、数字、选项、人员、日期等),需根据类型正确构造 `fields` 中的值 +2. **必填校验**:通过 `questions[].required` 判断哪些题目为必填项,避免遗漏 +3. **显示条件过滤**:部分题目带有 `filter`(显示/隐藏逻辑),需根据用户已填的其他题目值判断该题目是否应该出现——**不应填写被 filter 隐藏的题目** +4. **获取 base_token(附件场景必用)**:`+form-detail` 返回的 `data.base_token` 是该表单所属的多维表格标识。当表单包含附件字段时,提交时必须通过 `--base-token` 传入此值,因为附件需要上传到该 Base 的 Drive Media 中 + +典型流程: + +```bash +# 1️⃣ 先获取表单详情,了解所有题目 +lark-cli base +form-detail --share-token + +# 2️⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件 + +# 3️⃣ 再提交 +lark-cli base +form-submit \ + --share-token \ + --json '{"fields":{...}}' +``` + +详见 [`lark-base-form-detail.md`](lark-base-form-detail.md) 中的「questions 结构说明」和「filter 结构说明」。 + +## 命令 + +```bash +# 基本提交(填写普通字段) +lark-cli base +form-submit \ + --share-token \ + --json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' + +# 带附件提交(需要额外提供 --base-token) +lark-cli base +form-submit \ + --share-token \ + --base-token \ + --json '{ + "fields": {"服务评分": 5, "评价内容": "好"}, + "attachments": { + "附件字段名": ["./report.pdf", "./photo.png"], + "另一个附件字段": ["./doc.docx"] + } + }' + +# 使用应用身份(bot) +lark-cli base +form-submit \ + --share-token \ + --json '{"fields":{...}}' \ + --as bot + +# 预览 API 调用(不实际执行) +lark-cli base +form-submit \ + --share-token \ + --json '{"fields":{...}}' \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--share-token ` | 是 | 表单分享 Token(必填),从表单分享链接中提取 | +| `--base-token ` | 条件必填 | Base token;**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media | +| `--json ` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 | +| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv | +| `--as` | 否 | 身份:user(默认)\| bot | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +### --json 结构说明 + +`--json` 是一个 JSON 对象,包含两个部分: + +#### fields(普通字段) + +`fields` 中的单元格值写法与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 完全对齐,填写前应先阅读该文档了解各类型的构造规则: + +```json +{ + "文本字段": "Hello World", + "电话字段": "13800000000", + "超链接字段": "https://example.com", + "数字字段": 12.5, + "单选字段": "选项A", + "多选字段": ["选项A", "选项B"], + "时间字段": "2026-04-27 14:30:00", + "复选框字段": true, + "人员字段": [{ "id": "ou_7094d131420c8749632145f08fbf114a" }], + "关联字段": [{ "id": "recXXXXXXXXXXXX" }], + "地理位置字段": { "lng": 116.397428, "lat": 39.90923 } +} +``` + +> **注意:附件类型字段不要写在 `fields` 里。** `fields` 中不包含附件,附件有独立的填写方式,见下方「attachments(附件上传)」章节。 + +> 自动编号、公式、创建/修改人、创建/修改时间等系统字段会自动填入,无需手动传入。 + +#### attachments(附件上传) + +**附件字段的填写方式与 `fields` 中的普通单元格完全不同**,不能在 `fields` 里传 `file_token` 或其他附件格式。必须将附件字段单独放在 `--json` 的顶层 `attachments` 对象中,值为**本地文件路径数组**(不是 token): + +```json +{ + "attachments": { + "附件字段名": ["./report.pdf", "./photo.png"], + "另一个附件字段": ["./doc.docx"] + } +} +``` + +CLI 收到路径后会自动完成以下流程: +1. 校验所有文件(存在性、大小 ≤2GB、常规文件) +2. 并行上传到 Base Drive Media(并发上限 5,跨字段重复路径自动去重) +3. 获取 `file_token` 后合并到最终表单提交内容中 + +> 与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 中 Record 场景的附件写法不同:Record 写入时附件走独立的 `+record-upload-attachment` 命令;而 `+form-submit` 只需在 `attachments` 中传本地路径,上传由 CLI 内部自动完成。 + +### 从分享链接提取 share-token + +用户提供形如以下格式的表单分享链接时: + +``` +https://bitable-test.feishu-boe.cn/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye +``` + +**提取方式:** 取 URL 路径最后一段作为 `--share-token`。 + +以上述链接为例: + +- `share-token` = `shrbcvST8eZy0vk8zjVZ1CAXNye` + +```bash +lark-cli base +form-submit \ + --share-token shrbcvST8eZy0vk8zjVZ1CAXNye \ + --json '{"fields":{...}}' +``` + +## 输出格式 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `can_submit_again` | bool | 是否可以再次填写 | + +```json +{ + "ok": true, + "data": { + "can_submit_again": true + } +} +``` + +## 提示 + +- 本命令仅支持通过表单分享链接(share_token)提交,不支持通过 base_token + table_id + view_id 方式提交 +- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base +- 附件字段只需在 `--json.attachments` 中提供本地路径即可,CLI 自动完成校验、并行上传、Token 获取和合并写入 +- 限流:单应用 20 QPS,单用户 5 QPS +- 权限要求:`base:form:update` + +## 参考 + +- [lark-base](../SKILL.md) — 多维表格全部命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 +- [lark-base-form](references/lark-base-form.md) — 表单管理总览 +- [lark-base-record-upload-attachment](references/lark-base-record-upload-attachment.md) — 给已有记录上传附件(不同场景) diff --git a/skills/lark-base/references/lark-base-form.md b/skills/lark-base/references/lark-base-form.md index 5b0892a50..6775b7804 100644 --- a/skills/lark-base/references/lark-base-form.md +++ b/skills/lark-base/references/lark-base-form.md @@ -9,7 +9,8 @@ form 相关命令索引。 | 文档 | 命令 | 说明 | |------|------|------| | [lark-base-form-list.md](lark-base-form-list.md) | `+form-list` | 分页列出表单 | -| [lark-base-form-get.md](lark-base-form-get.md) | `+form-get` | 获取表单详情 | +| [lark-base-form-get.md](lark-base-form-get.md) | `+form-get` | 获取表单详情(Base 内部路径) | +| [lark-base-form-detail.md](lark-base-form-detail.md) | `+form-detail` | 通过分享链接获取表单详情(含题目列表) | | [lark-base-form-create.md](lark-base-form-create.md) | `+form-create` | 创建表单 | | [lark-base-form-update.md](lark-base-form-update.md) | `+form-update` | 更新表单 | | [lark-base-form-delete.md](lark-base-form-delete.md) | `+form-delete` | 删除表单 | diff --git a/tests/cli_e2e/base/base_form_detail_dryrun_test.go b/tests/cli_e2e/base/base_form_detail_dryrun_test.go new file mode 100644 index 000000000..6c189b800 --- /dev/null +++ b/tests/cli_e2e/base/base_form_detail_dryrun_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBaseFormDetailDryRun(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", "+form-detail", + "--share-token", "shrXXXX", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/detail") + assert.Contains(t, output, `"share_token"`) + assert.Contains(t, output, "shrXXXX") + assert.Contains(t, output, `"method": "POST"`) +} + +func TestBaseFormDetailDryRun_MissingShareToken(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", "+form-detail", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode) + assert.Contains(t, result.Stderr, "share-token") +} + +func setBaseDryRunConfigEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "base_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "base_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +}