diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg
index c3ca976b..f0d5afcb 100644
--- a/.github/badges/coverage.svg
+++ b/.github/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go
index 06b417c5..bc6d06a5 100644
--- a/internal/helpers/aitable.go
+++ b/internal/helpers/aitable.go
@@ -130,6 +130,11 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableRecordUpdateCommand(runner),
newAitableRecordBatchUpdateCommand(runner),
newAitableRecordDeleteCommand(runner),
+ newAitableRecordHistoryListCommand(runner),
+ newAitableRecordShareURLCommand(runner),
+ newAitableRecordUpsertCommand(runner),
+ newAitableRecordPrimaryDocGetCommand(runner),
+ newAitableRecordPrimaryDocCreateCommand(runner),
newAitableRecordListAlias(runner),
)
@@ -264,15 +269,36 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
+ viewGet := newAitableViewGetCommand(runner)
+ viewGet.AddCommand(
+ newAitableViewGetLockCommand(runner),
+ newAitableViewGetFrozenColsCommand(runner),
+ newAitableViewGetRowHeightCommand(runner),
+ newAitableViewGetFillColorRuleCommand(runner),
+ )
+ viewUpdate := newAitableViewUpdateCommand(runner)
+ viewUpdate.AddCommand(
+ newAitableViewUpdateFrozenColsCommand(runner),
+ newAitableViewUpdateRowHeightCommand(runner),
+ newAitableViewUpdateFillColorRuleCommand(runner),
+ )
view.AddCommand(
- newAitableViewGetCommand(runner),
+ viewGet,
newAitableViewListCommand(runner),
newAitableViewCreateCommand(runner),
- newAitableViewUpdateCommand(runner),
+ viewUpdate,
newAitableViewDeleteCommand(runner),
+ newAitableViewLockCommand(runner),
+ newAitableViewDuplicateCommand(runner),
)
- root.AddCommand(base, table, field, record, newAitableFormCommand(runner), template, attachment, export, importCmd, dashboard, chart, view)
+ root.AddCommand(
+ base, table, field, record, newAitableFormCommand(runner),
+ newAitableWorkflowCommand(runner),
+ template, attachment, export, importCmd, dashboard, chart, view,
+ newAitableAdvpermCommand(runner),
+ newAitableSectionCommand(runner),
+ )
// 顶层别名:dws aitable search/list/create/info → base search/list/create/get
// 每个 alias 复用现有 constructor,独立 cobra.Command 实例(避免与 base.* 共享 flag 指针)
diff --git a/internal/helpers/aitable_extra.go b/internal/helpers/aitable_extra.go
new file mode 100644
index 00000000..b1e14fe7
--- /dev/null
+++ b/internal/helpers/aitable_extra.go
@@ -0,0 +1,1047 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "fmt"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
+ "github.com/spf13/cobra"
+)
+
+func runAitableHelperTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
+ return runAitableProductTool(cmd, runner, "aitable-helper", tool, params)
+}
+
+func newAitableRecordHistoryListCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "history-list",
+ Short: i18n.T("查询行记录变更历史"),
+ Example: " dws aitable record history-list --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID --limit 50 --offset 0",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, tableID, err := requiredAitableBaseTable(cmd)
+ if err != nil {
+ return err
+ }
+ recordID, err := aitableRequiredFlag(cmd, "record-id")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "recordId": recordID,
+ }
+ if cmd.Flags().Changed("offset") {
+ offset, _ := cmd.Flags().GetInt("offset")
+ if offset < 0 {
+ return apperrors.NewValidation(fmt.Sprintf("--offset must be >= 0, got %d", offset))
+ }
+ params["offset"] = offset
+ }
+ if cmd.Flags().Changed("limit") {
+ limit, _ := cmd.Flags().GetInt("limit")
+ if limit < 1 || limit > 50 {
+ return apperrors.NewValidation(fmt.Sprintf("--limit must be in [1, 50], got %d", limit))
+ }
+ params["limit"] = limit
+ }
+ return runAitableHelperTool(cmd, runner, "query_record_history", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableFlags(cmd)
+ cmd.Flags().String("record-id", "", i18n.T("Record ID (必填)"))
+ cmd.Flags().Int("offset", 0, i18n.T("分页偏移量,>= 0"))
+ cmd.Flags().Int("limit", 0, i18n.T("分页大小 [1, 50],不传使用服务端默认值"))
+ return cmd
+}
+
+func newAitableRecordShareURLCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "share-url",
+ Short: i18n.T("批量获取记录分享链接"),
+ Example: " dws aitable record share-url --base-id BASE_ID --table-id TABLE_ID --record-ids rec1,rec2 --view-id VIEW_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, tableID, err := requiredAitableBaseTable(cmd)
+ if err != nil {
+ return err
+ }
+ recordIDsRaw, err := aitableRequiredFlag(cmd, "record-ids")
+ if err != nil {
+ return err
+ }
+ recordIDs := parseAitableCSVValues(recordIDsRaw)
+ if len(recordIDs) == 0 {
+ return apperrors.NewValidation("--record-ids must contain at least one record ID")
+ }
+ if len(recordIDs) > 20 {
+ return apperrors.NewValidation(fmt.Sprintf("--record-ids exceeds limit: got %d, max 20", len(recordIDs)))
+ }
+ params := map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "recordIds": recordIDs,
+ }
+ if viewID := aitableStringFlag(cmd, "view-id"); viewID != "" {
+ params["viewId"] = viewID
+ }
+ return runAitableHelperTool(cmd, runner, "get_record_share_url", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableFlags(cmd)
+ cmd.Flags().String("record-ids", "", i18n.T("Record ID 列表,逗号分隔,单次最多 20 条 (必填)"))
+ cmd.Flags().String("view-id", "", i18n.T("View ID,可选"))
+ return cmd
+}
+
+func newAitableRecordUpsertCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "upsert",
+ Short: i18n.T("批量创建或更新记录"),
+ Example: " dws aitable record upsert --base-id BASE_ID --table-id TABLE_ID --records '[{\"recordId\":\"rec1\",\"cells\":{\"fld\":\"x\"}},{\"cells\":{\"fld\":\"new\"}}]'",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, tableID, err := requiredAitableBaseTable(cmd)
+ if err != nil {
+ return err
+ }
+ records, err := resolveAitableRecordsInput(cmd, "records", "fields")
+ if err != nil {
+ return err
+ }
+ if len(records) == 0 {
+ return apperrors.NewValidation("--records must contain at least one record")
+ }
+ if len(records) > 100 {
+ return apperrors.NewValidation(fmt.Sprintf("--records exceeds limit: got %d, max 100", len(records)))
+ }
+ return runAitableHelperTool(cmd, runner, "record_upsert", map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "records": records,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableFlags(cmd)
+ cmd.Flags().String("records", "", i18n.T("记录 JSON 数组 (必填,可改用 --records-file)"))
+ cmd.Flags().String("records-file", "", i18n.T("从文件读取 records JSON"))
+ addAitableHiddenStringFlag(cmd, "fields", "--records 的兼容别名")
+ return cmd
+}
+
+func newAitableRecordPrimaryDocGetCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "primary-doc-get",
+ Short: i18n.T("查询记录的主键文档"),
+ Example: " dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, tableID, err := requiredAitableBaseTable(cmd)
+ if err != nil {
+ return err
+ }
+ recordID, err := aitableRequiredFlag(cmd, "record-id")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "get_primary_doc", map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "recordId": recordID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableFlags(cmd)
+ cmd.Flags().String("record-id", "", i18n.T("Record ID (必填)"))
+ return cmd
+}
+
+func newAitableRecordPrimaryDocCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "primary-doc-create",
+ Short: i18n.T("为记录创建主键文档"),
+ Example: " dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, tableID, err := requiredAitableBaseTable(cmd)
+ if err != nil {
+ return err
+ }
+ fieldID, err := aitableRequiredFlag(cmd, "field-id")
+ if err != nil {
+ return err
+ }
+ recordID, err := aitableRequiredFlag(cmd, "record-id")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "create_primary_doc", map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "fieldId": fieldID,
+ "recordId": recordID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableFlags(cmd)
+ cmd.Flags().String("field-id", "", i18n.T("PrimaryDoc 字段 ID (必填)"))
+ cmd.Flags().String("record-id", "", i18n.T("Record ID (必填)"))
+ return cmd
+}
+
+func newAitableViewGetLockCommand(runner executor.Runner) *cobra.Command {
+ return newAitableViewHelperGetCommand(runner, "lock", i18n.T("获取视图锁定状态"), "get_view_lock_status")
+}
+
+func newAitableViewGetFrozenColsCommand(runner executor.Runner) *cobra.Command {
+ return newAitableViewHelperGetCommand(runner, "frozen-cols", i18n.T("获取视图冻结列数"), "get_frozen_columns_of_view")
+}
+
+func newAitableViewGetRowHeightCommand(runner executor.Runner) *cobra.Command {
+ return newAitableViewHelperGetCommand(runner, "row-height", i18n.T("获取视图行高"), "get_cell_height_of_view")
+}
+
+func newAitableViewGetFillColorRuleCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "fill-color-rule",
+ Short: i18n.T("获取视图数据高亮规则"),
+ Example: " dws aitable view get fill-color-rule --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, tableID, viewID, err := requiredAitableBaseTableView(cmd)
+ if err != nil {
+ return err
+ }
+ return runAitableTool(cmd, runner, "get_views", map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "viewIds": []string{viewID},
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableViewFlags(cmd)
+ return cmd
+}
+
+func newAitableViewHelperGetCommand(runner executor.Runner, use, short, tool string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: use,
+ Short: short,
+ Example: fmt.Sprintf(" dws aitable view get %s --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID", use),
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ params, err := requiredAitableViewParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, tool, params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableViewFlags(cmd)
+ return cmd
+}
+
+func newAitableViewUpdateFrozenColsCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "frozen-cols",
+ Short: i18n.T("更新视图冻结列数"),
+ Example: " dws aitable view update frozen-cols --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --count 1",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !cmd.Flags().Changed("count") {
+ return apperrors.NewValidation("--count is required")
+ }
+ count, _ := cmd.Flags().GetInt("count")
+ if count < 0 {
+ return apperrors.NewValidation(fmt.Sprintf("--count must be >= 0, got %d", count))
+ }
+ params, err := requiredAitableViewParams(cmd)
+ if err != nil {
+ return err
+ }
+ params["count"] = count
+ return runAitableHelperTool(cmd, runner, "set_frozen_columns_of_view", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableViewFlags(cmd)
+ cmd.Flags().Int("count", 0, i18n.T("冻结列数,>= 0;0 表示取消冻结 (必填)"))
+ return cmd
+}
+
+func newAitableViewUpdateRowHeightCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "row-height",
+ Short: i18n.T("更新视图行高"),
+ Example: " dws aitable view update row-height --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --cell-height 56",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !cmd.Flags().Changed("cell-height") {
+ return apperrors.NewValidation("--cell-height is required")
+ }
+ cellHeight, _ := cmd.Flags().GetInt("cell-height")
+ if cellHeight <= 0 {
+ return apperrors.NewValidation(fmt.Sprintf("--cell-height must be > 0, got %d", cellHeight))
+ }
+ params, err := requiredAitableViewParams(cmd)
+ if err != nil {
+ return err
+ }
+ params["cellHeight"] = cellHeight
+ return runAitableHelperTool(cmd, runner, "set_cell_height_of_view", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableViewFlags(cmd)
+ cmd.Flags().Int("cell-height", 0, i18n.T("单元格高度,像素值 (必填)"))
+ return cmd
+}
+
+func newAitableViewUpdateFillColorRuleCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "fill-color-rule",
+ Short: i18n.T("更新视图数据高亮规则"),
+ Example: " dws aitable view update fill-color-rule --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --json '[]'",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ raw, err := aitableRequiredFlag(cmd, "json")
+ if err != nil {
+ return err
+ }
+ conditionalFormats, err := parseAitableJSONArray(raw, "json")
+ if err != nil {
+ return err
+ }
+ params, err := requiredAitableViewParams(cmd)
+ if err != nil {
+ return err
+ }
+ params["conditionalFormats"] = conditionalFormats
+ return runAitableTool(cmd, runner, "set_view_fill_color_rule", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableViewFlags(cmd)
+ cmd.Flags().String("json", "", i18n.T("conditionalFormats JSON 数组 (必填)"))
+ return cmd
+}
+
+func newAitableViewLockCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "lock",
+ Short: i18n.T("锁定或解锁视图"),
+ Example: " dws aitable view lock --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --off",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ params, err := requiredAitableViewParams(cmd)
+ if err != nil {
+ return err
+ }
+ action := "lock"
+ if off, _ := cmd.Flags().GetBool("off"); off {
+ action = "unlock"
+ }
+ params["action"] = action
+ return runAitableHelperTool(cmd, runner, "lock_or_unlock_view", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableViewFlags(cmd)
+ cmd.Flags().Bool("off", false, i18n.T("解锁视图;不传则锁定"))
+ return cmd
+}
+
+func newAitableViewDuplicateCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "duplicate",
+ Short: i18n.T("复制视图"),
+ Example: " dws aitable view duplicate --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --new-name 副本视图",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, tableID, viewID, err := requiredAitableBaseTableView(cmd)
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "sourceViewId": viewID,
+ }
+ if newName := aitableStringFlag(cmd, "new-name"); newName != "" {
+ params["newViewName"] = newName
+ }
+ return runAitableHelperTool(cmd, runner, "duplicate_view", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseTableViewFlags(cmd)
+ cmd.Flags().String("new-name", "", i18n.T("新视图名称"))
+ return cmd
+}
+
+func requiredAitableViewParams(cmd *cobra.Command) (map[string]any, error) {
+ baseID, tableID, viewID, err := requiredAitableBaseTableView(cmd)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "baseId": baseID,
+ "tableId": tableID,
+ "viewId": viewID,
+ }, nil
+}
+
+func newAitableWorkflowCommand(runner executor.Runner) *cobra.Command {
+ group := newAitableExtraGroup("workflow", i18n.T("自动化工作流管理"))
+ group.AddCommand(
+ newAitableWorkflowEnableCommand(runner),
+ newAitableWorkflowDisableCommand(runner),
+ newAitableWorkflowGetCommand(runner),
+ newAitableWorkflowListCommand(runner),
+ )
+ return group
+}
+
+func newAitableWorkflowEnableCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "enable",
+ Short: i18n.T("启用指定工作流"),
+ Example: " dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ workflowID, err := aitableRequiredFlag(cmd, "workflow-id")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "enable_workflow", map[string]any{
+ "baseId": baseID,
+ "workflowId": workflowID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("workflow-id", "", i18n.T("工作流 ID (必填)"))
+ return cmd
+}
+
+func newAitableWorkflowDisableCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "disable",
+ Short: i18n.T("禁用指定工作流"),
+ Example: " dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ workflowID, err := aitableRequiredFlag(cmd, "workflow-id")
+ if err != nil {
+ return err
+ }
+ if !confirmDeletePrompt(cmd, i18n.T("工作流"), workflowID) {
+ return nil
+ }
+ return runAitableHelperTool(cmd, runner, "disable_workflow", map[string]any{
+ "baseId": baseID,
+ "workflowId": workflowID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("workflow-id", "", i18n.T("工作流 ID (必填)"))
+ return cmd
+}
+
+func newAitableWorkflowGetCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "get",
+ Short: i18n.T("获取单个工作流详情"),
+ Example: " dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ workflowID, err := aitableRequiredFlag(cmd, "workflow-id")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "get_workflow", map[string]any{
+ "baseId": baseID,
+ "workflowId": workflowID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("workflow-id", "", i18n.T("工作流 ID (必填)"))
+ return cmd
+}
+
+func newAitableWorkflowListCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: i18n.T("列出 Base 下的工作流"),
+ Example: " dws aitable workflow list --base-id BASE_ID --limit 50 --offset 100",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"baseId": baseID}
+ if cmd.Flags().Changed("limit") {
+ limit, _ := cmd.Flags().GetInt("limit")
+ if limit < 1 || limit > 100 {
+ return apperrors.NewValidation(fmt.Sprintf("--limit must be in [1, 100], got %d", limit))
+ }
+ params["limit"] = limit
+ }
+ if cmd.Flags().Changed("offset") {
+ offset, _ := cmd.Flags().GetInt("offset")
+ if offset < 0 {
+ return apperrors.NewValidation(fmt.Sprintf("--offset must be >= 0, got %d", offset))
+ }
+ params["offset"] = offset
+ }
+ return runAitableHelperTool(cmd, runner, "list_workflows", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().Int("limit", 0, i18n.T("分页大小 [1, 100],不传使用服务端默认值"))
+ cmd.Flags().Int("offset", 0, i18n.T("分页偏移量,>= 0"))
+ return cmd
+}
+
+func newAitableAdvpermCommand(runner executor.Runner) *cobra.Command {
+ group := newAitableExtraGroup("advperm", i18n.T("高级权限管理"))
+ group.AddCommand(
+ newAitableAdvpermEnableCommand(runner),
+ newAitableAdvpermDisableCommand(runner),
+ newAitableAdvpermRoleListCommand(runner),
+ newAitableAdvpermRoleGetCommand(runner),
+ newAitableAdvpermRoleCreateCommand(runner),
+ newAitableAdvpermRoleUpdateCommand(runner),
+ newAitableAdvpermRoleDeleteCommand(runner),
+ )
+ return group
+}
+
+func newAitableAdvpermEnableCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "enable",
+ Short: i18n.T("开启高级权限总开关"),
+ Example: " dws aitable advperm enable --base-id BASE_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "set_advanced_permission", map[string]any{
+ "baseId": baseID,
+ "enabled": true,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ return cmd
+}
+
+func newAitableAdvpermDisableCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "disable",
+ Short: i18n.T("关闭高级权限总开关"),
+ Example: " dws aitable advperm disable --base-id BASE_ID --yes",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ if !confirmDeletePrompt(cmd, i18n.T("高级权限"), baseID) {
+ return nil
+ }
+ return runAitableHelperTool(cmd, runner, "set_advanced_permission", map[string]any{
+ "baseId": baseID,
+ "enabled": false,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ return cmd
+}
+
+func newAitableAdvpermRoleListCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "role-list",
+ Short: i18n.T("列出 Base 下所有角色"),
+ Example: " dws aitable advperm role-list --base-id BASE_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "list_roles", map[string]any{"baseId": baseID})
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ return cmd
+}
+
+func newAitableAdvpermRoleGetCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "role-get",
+ Short: i18n.T("获取单个角色完整配置"),
+ Example: " dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, roleID, err := requiredAitableRoleParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "get_role", map[string]any{
+ "baseId": baseID,
+ "roleId": roleID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("role-id", "", i18n.T("角色 ID (必填)"))
+ return cmd
+}
+
+func newAitableAdvpermRoleCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "role-create",
+ Short: i18n.T("创建自定义角色"),
+ Example: " dws aitable advperm role-create --base-id BASE_ID --name 市场可读 --sub-roles '[{\"targetId\":\"tbl\",\"targetType\":\"sheet\",\"authLevel\":\"read\"}]'",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ name, err := aitableRequiredFlag(cmd, "name")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "baseId": baseID,
+ "name": name,
+ }
+ if err := appendAitableRoleOptionalFlags(cmd, params); err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "create_role", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ addAitableRoleMutationFlags(cmd, true)
+ return cmd
+}
+
+func newAitableAdvpermRoleUpdateCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "role-update",
+ Short: i18n.T("增量更新自定义角色配置"),
+ Example: " dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name 新名字",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, roleID, err := requiredAitableRoleParams(cmd)
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "baseId": baseID,
+ "roleId": roleID,
+ }
+ if name := aitableStringFlag(cmd, "name"); name != "" {
+ params["name"] = name
+ }
+ if err := appendAitableRoleOptionalFlags(cmd, params); err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "patch_role", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("role-id", "", i18n.T("角色 ID (必填)"))
+ addAitableRoleMutationFlags(cmd, false)
+ return cmd
+}
+
+func newAitableAdvpermRoleDeleteCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "role-delete",
+ Short: i18n.T("删除自定义角色"),
+ Example: " dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, roleID, err := requiredAitableRoleParams(cmd)
+ if err != nil {
+ return err
+ }
+ if !confirmDeletePrompt(cmd, i18n.T("角色"), roleID) {
+ return nil
+ }
+ return runAitableHelperTool(cmd, runner, "delete_role", map[string]any{
+ "baseId": baseID,
+ "roleId": roleID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("role-id", "", i18n.T("角色 ID (必填)"))
+ return cmd
+}
+
+func requiredAitableRoleParams(cmd *cobra.Command) (string, string, error) {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return "", "", err
+ }
+ roleID, err := aitableRequiredFlag(cmd, "role-id")
+ if err != nil {
+ return "", "", err
+ }
+ return baseID, roleID, nil
+}
+
+func addAitableRoleMutationFlags(cmd *cobra.Command, nameRequired bool) {
+ label := i18n.T("角色名称")
+ if nameRequired {
+ label = i18n.T("角色名称 (必填)")
+ }
+ cmd.Flags().String("name", "", label)
+ cmd.Flags().String("role-type", "", i18n.T("角色类型"))
+ cmd.Flags().String("flow-type", "", i18n.T("流程类型"))
+ cmd.Flags().String("sub-roles", "", i18n.T("子角色配置 JSON 数组"))
+}
+
+func appendAitableRoleOptionalFlags(cmd *cobra.Command, params map[string]any) error {
+ if roleType := aitableStringFlag(cmd, "role-type"); roleType != "" {
+ params["roleType"] = roleType
+ }
+ if flowType := aitableStringFlag(cmd, "flow-type"); flowType != "" {
+ params["flowType"] = flowType
+ }
+ if subRolesRaw := aitableStringFlag(cmd, "sub-roles"); subRolesRaw != "" {
+ subRoles, err := parseAitableJSONArray(subRolesRaw, "sub-roles")
+ if err != nil {
+ return err
+ }
+ params["subRoles"] = subRoles
+ }
+ return nil
+}
+
+func newAitableSectionCommand(runner executor.Runner) *cobra.Command {
+ group := newAitableExtraGroup("section", i18n.T("文件夹与节点管理"))
+ group.AddCommand(
+ newAitableSectionCreateCommand(runner),
+ newAitableSectionRenameCommand(runner),
+ newAitableSectionDeleteCommand(runner),
+ newAitableSectionReorderCommand(runner),
+ newAitableSectionListEmptyCommand(runner),
+ newAitableSectionListNodesCommand(runner),
+ newAitableSectionMoveNodeCommand(runner),
+ )
+ return group
+}
+
+func newAitableSectionCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: i18n.T("创建文件夹"),
+ Example: " dws aitable section create --base-id BASE_ID --name 我的文件夹 --parent-section-id SECTION_ID --index 0",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ name, err := aitableRequiredFlag(cmd, "name")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "baseId": baseID,
+ "name": name,
+ }
+ if cmd.Flags().Changed("parent-section-id") {
+ parentSectionID, _ := cmd.Flags().GetString("parent-section-id")
+ params["parentSectionId"] = parentSectionID
+ }
+ if index, _ := cmd.Flags().GetInt("index"); index >= 0 {
+ params["index"] = index
+ }
+ return runAitableHelperTool(cmd, runner, "create_section", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("name", "", i18n.T("文件夹名称 (必填)"))
+ cmd.Flags().String("parent-section-id", "", i18n.T("父文件夹 ID;空字符串表示根目录"))
+ cmd.Flags().Int("index", -1, i18n.T("目标位置,0-based;不传则追加"))
+ return cmd
+}
+
+func newAitableSectionRenameCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "rename",
+ Short: i18n.T("重命名文件夹"),
+ Example: " dws aitable section rename --base-id BASE_ID --section-id SECTION_ID --new-name 新名称",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, sectionID, err := requiredAitableSectionParams(cmd)
+ if err != nil {
+ return err
+ }
+ newName, err := aitableRequiredFlag(cmd, "new-name")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "rename_section", map[string]any{
+ "baseId": baseID,
+ "sectionId": sectionID,
+ "newName": newName,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("section-id", "", i18n.T("文件夹 ID (必填)"))
+ cmd.Flags().String("new-name", "", i18n.T("新文件夹名称 (必填)"))
+ return cmd
+}
+
+func newAitableSectionDeleteCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: i18n.T("删除文件夹"),
+ Example: " dws aitable section delete --base-id BASE_ID --section-id SECTION_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, sectionID, err := requiredAitableSectionParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "delete_section", map[string]any{
+ "baseId": baseID,
+ "sectionId": sectionID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("section-id", "", i18n.T("文件夹 ID (必填)"))
+ return cmd
+}
+
+func newAitableSectionReorderCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "reorder",
+ Short: i18n.T("调整文件夹顺序"),
+ Example: " dws aitable section reorder --base-id BASE_ID --section-id SECTION_ID --target-index 0",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, sectionID, err := requiredAitableSectionParams(cmd)
+ if err != nil {
+ return err
+ }
+ if !cmd.Flags().Changed("target-index") {
+ return apperrors.NewValidation("--target-index is required")
+ }
+ targetIndex, _ := cmd.Flags().GetInt("target-index")
+ if targetIndex < 0 {
+ return apperrors.NewValidation(fmt.Sprintf("--target-index must be >= 0, got %d", targetIndex))
+ }
+ return runAitableHelperTool(cmd, runner, "reorder_section", map[string]any{
+ "baseId": baseID,
+ "sectionId": sectionID,
+ "targetIndex": targetIndex,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("section-id", "", i18n.T("文件夹 ID (必填)"))
+ cmd.Flags().Int("target-index", -1, i18n.T("目标位置,0-based (必填)"))
+ return cmd
+}
+
+func newAitableSectionListEmptyCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list-empty",
+ Short: i18n.T("列出空文件夹"),
+ Example: " dws aitable section list-empty --base-id BASE_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "list_empty_sections", map[string]any{"baseId": baseID})
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ return cmd
+}
+
+func newAitableSectionListNodesCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list-nodes",
+ Short: i18n.T("列出全部节点"),
+ Example: " dws aitable section list-nodes --base-id BASE_ID",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ return runAitableHelperTool(cmd, runner, "list_nsheet_nodes", map[string]any{"baseId": baseID})
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ return cmd
+}
+
+func newAitableSectionMoveNodeCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "move-node",
+ Short: i18n.T("移动节点"),
+ Example: " dws aitable section move-node --base-id BASE_ID --node-id NODE_ID --new-parent-section-id SECTION_ID --target-index 0",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return err
+ }
+ nodeID, err := aitableRequiredFlag(cmd, "node-id")
+ if err != nil {
+ return err
+ }
+ if !cmd.Flags().Changed("new-parent-section-id") {
+ return apperrors.NewValidation("--new-parent-section-id is required; pass empty string to move to base root")
+ }
+ newParentSectionID, _ := cmd.Flags().GetString("new-parent-section-id")
+ params := map[string]any{
+ "baseId": baseID,
+ "nodeId": nodeID,
+ "newParentSectionId": newParentSectionID,
+ }
+ if targetIndex, _ := cmd.Flags().GetInt("target-index"); targetIndex >= 0 {
+ params["targetIndex"] = targetIndex
+ }
+ return runAitableHelperTool(cmd, runner, "move_nsheet_node", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addAitableBaseFlag(cmd)
+ cmd.Flags().String("node-id", "", i18n.T("要移动的节点 ID (必填)"))
+ cmd.Flags().String("new-parent-section-id", "", i18n.T("目标父文件夹 ID;空字符串表示根目录 (必填)"))
+ cmd.Flags().Int("target-index", -1, i18n.T("目标位置,0-based"))
+ return cmd
+}
+
+func requiredAitableSectionParams(cmd *cobra.Command) (string, string, error) {
+ baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
+ if err != nil {
+ return "", "", err
+ }
+ sectionID, err := aitableRequiredFlag(cmd, "section-id")
+ if err != nil {
+ return "", "", err
+ }
+ return baseID, sectionID, nil
+}
+
+func addAitableBaseFlag(cmd *cobra.Command) {
+ cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
+ addAitableHiddenStringFlag(cmd, "base", "--base-id 的兼容别名")
+}
+
+func newAitableExtraGroup(use, short string) *cobra.Command {
+ return &cobra.Command{
+ Use: use,
+ Short: short,
+ Args: cobra.NoArgs,
+ TraverseChildren: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return cmd.Help()
+ },
+ }
+}
diff --git a/internal/helpers/aitable_extra_test.go b/internal/helpers/aitable_extra_test.go
new file mode 100644
index 00000000..f26b1f70
--- /dev/null
+++ b/internal/helpers/aitable_extra_test.go
@@ -0,0 +1,196 @@
+package helpers
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func executeAitableExtraCommand(t *testing.T, cmd *cobra.Command, args ...string) {
+ t.Helper()
+
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+}
+
+func TestAitableRecordHistoryListRoutesToHelper(t *testing.T) {
+ t.Parallel()
+
+ runner := &aitableCommandRunner{}
+ cmd := newAitableRecordHistoryListCommand(runner)
+ executeAitableExtraCommand(t, cmd,
+ "--base-id", "BASE_001",
+ "--table-id", "TABLE_001",
+ "--record-id", "REC_001",
+ "--offset", "10",
+ "--limit", "30",
+ )
+
+ if got := runner.last.CanonicalProduct; got != "aitable-helper" {
+ t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
+ }
+ if got := runner.last.Tool; got != "query_record_history" {
+ t.Fatalf("Tool = %q, want query_record_history", got)
+ }
+ if got := runner.last.Params["recordId"]; got != "REC_001" {
+ t.Fatalf("recordId = %#v, want REC_001", got)
+ }
+ if got := runner.last.Params["offset"]; got != 10 {
+ t.Fatalf("offset = %#v, want 10", got)
+ }
+ if got := runner.last.Params["limit"]; got != 30 {
+ t.Fatalf("limit = %#v, want 30", got)
+ }
+}
+
+func TestAitableRecordUpsertAcceptsFieldsAlias(t *testing.T) {
+ t.Parallel()
+
+ runner := &aitableCommandRunner{}
+ cmd := newAitableRecordUpsertCommand(runner)
+ executeAitableExtraCommand(t, cmd,
+ "--base-id", "BASE_001",
+ "--table-id", "TABLE_001",
+ "--fields", `[{"recordId":"REC_001","cells":{"fld":"updated"}},{"cells":{"fld":"new"}}]`,
+ )
+
+ if got := runner.last.CanonicalProduct; got != "aitable-helper" {
+ t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
+ }
+ if got := runner.last.Tool; got != "record_upsert" {
+ t.Fatalf("Tool = %q, want record_upsert", got)
+ }
+ records, ok := runner.last.Params["records"].([]any)
+ if !ok {
+ t.Fatalf("records type = %T, want []any", runner.last.Params["records"])
+ }
+ if len(records) != 2 {
+ t.Fatalf("records len = %d, want 2", len(records))
+ }
+}
+
+func TestAitableViewExtraCommandsRouteToExpectedTools(t *testing.T) {
+ t.Parallel()
+
+ t.Run("lock unlock", func(t *testing.T) {
+ t.Parallel()
+ runner := &aitableCommandRunner{}
+ cmd := newAitableViewLockCommand(runner)
+ executeAitableExtraCommand(t, cmd,
+ "--base-id", "BASE_001",
+ "--table-id", "TABLE_001",
+ "--view-id", "VIEW_001",
+ "--off",
+ )
+ if got := runner.last.CanonicalProduct; got != "aitable-helper" {
+ t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
+ }
+ if got := runner.last.Tool; got != "lock_or_unlock_view" {
+ t.Fatalf("Tool = %q, want lock_or_unlock_view", got)
+ }
+ if got := runner.last.Params["action"]; got != "unlock" {
+ t.Fatalf("action = %#v, want unlock", got)
+ }
+ })
+
+ t.Run("fill color rule", func(t *testing.T) {
+ t.Parallel()
+ runner := &aitableCommandRunner{}
+ cmd := newAitableViewUpdateFillColorRuleCommand(runner)
+ executeAitableExtraCommand(t, cmd,
+ "--base-id", "BASE_001",
+ "--table-id", "TABLE_001",
+ "--view-id", "VIEW_001",
+ "--json", `[]`,
+ )
+ if got := runner.last.CanonicalProduct; got != "aitable" {
+ t.Fatalf("CanonicalProduct = %q, want aitable", got)
+ }
+ if got := runner.last.Tool; got != "set_view_fill_color_rule" {
+ t.Fatalf("Tool = %q, want set_view_fill_color_rule", got)
+ }
+ if formats, ok := runner.last.Params["conditionalFormats"].([]any); !ok || len(formats) != 0 {
+ t.Fatalf("conditionalFormats = %#v, want empty []any", runner.last.Params["conditionalFormats"])
+ }
+ })
+}
+
+func TestAitableWorkflowListRoutesToHelper(t *testing.T) {
+ t.Parallel()
+
+ runner := &aitableCommandRunner{}
+ cmd := newAitableWorkflowListCommand(runner)
+ executeAitableExtraCommand(t, cmd,
+ "--base-id", "BASE_001",
+ "--limit", "50",
+ "--offset", "100",
+ )
+
+ if got := runner.last.CanonicalProduct; got != "aitable-helper" {
+ t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
+ }
+ if got := runner.last.Tool; got != "list_workflows" {
+ t.Fatalf("Tool = %q, want list_workflows", got)
+ }
+ if got := runner.last.Params["limit"]; got != 50 {
+ t.Fatalf("limit = %#v, want 50", got)
+ }
+ if got := runner.last.Params["offset"]; got != 100 {
+ t.Fatalf("offset = %#v, want 100", got)
+ }
+}
+
+func TestAitableAdvpermRoleCreateParsesSubRoles(t *testing.T) {
+ t.Parallel()
+
+ runner := &aitableCommandRunner{}
+ cmd := newAitableAdvpermRoleCreateCommand(runner)
+ executeAitableExtraCommand(t, cmd,
+ "--base-id", "BASE_001",
+ "--name", "市场可读",
+ "--sub-roles", `[{"targetId":"TABLE_001","targetType":"sheet","authLevel":"read"}]`,
+ )
+
+ if got := runner.last.CanonicalProduct; got != "aitable-helper" {
+ t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
+ }
+ if got := runner.last.Tool; got != "create_role" {
+ t.Fatalf("Tool = %q, want create_role", got)
+ }
+ subRoles, ok := runner.last.Params["subRoles"].([]any)
+ if !ok || len(subRoles) != 1 {
+ t.Fatalf("subRoles = %#v, want single-item []any", runner.last.Params["subRoles"])
+ }
+}
+
+func TestAitableSectionMoveNodeAllowsRootParent(t *testing.T) {
+ t.Parallel()
+
+ runner := &aitableCommandRunner{}
+ cmd := newAitableSectionMoveNodeCommand(runner)
+ executeAitableExtraCommand(t, cmd,
+ "--base-id", "BASE_001",
+ "--node-id", "NODE_001",
+ "--new-parent-section-id", "",
+ "--target-index", "0",
+ )
+
+ if got := runner.last.CanonicalProduct; got != "aitable-helper" {
+ t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
+ }
+ if got := runner.last.Tool; got != "move_nsheet_node" {
+ t.Fatalf("Tool = %q, want move_nsheet_node", got)
+ }
+ if got := runner.last.Params["newParentSectionId"]; got != "" {
+ t.Fatalf("newParentSectionId = %#v, want empty string", got)
+ }
+ if got := runner.last.Params["targetIndex"]; got != 0 {
+ t.Fatalf("targetIndex = %#v, want 0", got)
+ }
+}
diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go
index 2f4794ce..9a36a489 100644
--- a/internal/helpers/doc.go
+++ b/internal/helpers/doc.go
@@ -85,6 +85,7 @@ func (docHandler) Command(runner executor.Runner) *cobra.Command {
newDocPermissionAddCommand(runner),
newDocPermissionUpdateCommand(runner),
newDocPermissionListCommand(runner),
+ newDocPermissionRemoveCommand(runner),
)
export := &cobra.Command{
@@ -174,6 +175,7 @@ func newDocSearchCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive search or dws wiki node search")
params := map[string]any{}
if keyword := docFlagOrFallback(cmd, "query", "keyword"); keyword != "" {
params["keyword"] = keyword
@@ -222,6 +224,7 @@ func newDocListCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive list or dws wiki node list")
params := map[string]any{}
if folder := docFlagOrFallback(cmd, "folder", "parent-id", "node", "file-id", "nodee"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
@@ -274,6 +277,9 @@ func newDocReadCommand(runner executor.Runner) *cobra.Command {
if output := docStringFlag(cmd, "output"); output != "" {
params["__output__"] = output
}
+ if format == "jsonml" {
+ return runDocReadJSONML(cmd, runner, params)
+ }
return runDocTool(cmd, runner, "doc", "get_document_content", params)
},
}
@@ -814,6 +820,7 @@ func newDocFileCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "wiki node create")
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
@@ -850,6 +857,7 @@ func newDocFolderCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "wiki node create --type folder")
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
@@ -889,6 +897,7 @@ func newDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Comm
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive "+use)
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -919,6 +928,7 @@ func newDocRenameCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive rename")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -1467,6 +1477,56 @@ func docInvocationResult(cmd *cobra.Command, runner executor.Runner, product, to
return runner.Run(cmd.Context(), invocation)
}
+func runDocReadJSONML(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
+ outputPath, _ := params["__output__"].(string)
+ result, err := docInvocationResult(cmd, runner, "doc", "get_document_content", params)
+ if err != nil {
+ return err
+ }
+ if !result.Invocation.Implemented {
+ return writeCommandPayload(cmd, result)
+ }
+ payload := normalizeDocReadJSONMLResult(result)
+ if outputPath != "" {
+ data, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal JSONML output: %w", err)
+ }
+ if err := os.WriteFile(outputPath, data, 0644); err != nil {
+ return fmt.Errorf("failed to write output file %s: %w", outputPath, err)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "[INFO] JSONML 已写入 %s\n", outputPath)
+ return nil
+ }
+ return writeCommandPayload(cmd, payload)
+}
+
+func normalizeDocReadJSONMLResult(result executor.Result) map[string]any {
+ content := result.Response
+ if nested, ok := result.Response["content"].(map[string]any); ok {
+ content = nested
+ }
+ out := map[string]any{}
+ for k, v := range content {
+ if k == "content" {
+ continue
+ }
+ out[k] = v
+ }
+ if raw, ok := out["jsonml"].(string); ok {
+ var decoded any
+ if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
+ out["jsonml"] = decoded
+ }
+ }
+ if revision, ok := out["version"]; ok {
+ if _, exists := out["revision"]; !exists {
+ out["revision"] = revision
+ }
+ }
+ return out
+}
+
func addDocNodeFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", i18n.T("文档 nodeId / URL"))
addDocHiddenStringFlag(cmd, "url", "--node alias")
@@ -2082,6 +2142,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive permission add")
return runDocPermissionMutation(cmd, runner, "add_permission")
},
}
@@ -2091,6 +2152,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
addDocHiddenStringFlag(cmd, "users", "--user alias")
cmd.Flags().String("role", "", i18n.T("权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填,辅助构造返回的 docUrl)"))
+ addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2108,6 +2170,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive permission update")
return runDocPermissionMutation(cmd, runner, "update_permission")
},
}
@@ -2118,6 +2181,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
addDocHiddenStringFlag(cmd, "uid", "--user alias")
cmd.Flags().String("role", "", i18n.T("新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
+ addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2135,6 +2199,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive permission list")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -2157,7 +2222,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
}
params["filterRoleIds"] = roles
}
- if v, _ := cmd.Flags().GetString("workspace"); v != "" {
+ if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
@@ -2183,6 +2248,59 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("filter-role", "", i18n.T("按角色过滤(逗号分隔): OWNER / MANAGER / EDITOR / DOWNLOADER / READER"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
+ addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
+ return cmd
+}
+
+func newDocPermissionRemoveCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "remove",
+ Aliases: []string{"rm"},
+ Short: i18n.T("移除文档协作者权限"),
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ docDeprecatedNotice(cmd, "drive permission remove")
+ nodeID, err := docRequiredNode(cmd)
+ if err != nil {
+ return err
+ }
+ rawUsers := docFlagOrFallback(cmd, "users", "user", "uid")
+ if strings.TrimSpace(rawUsers) == "" {
+ return apperrors.NewValidation("--users is required")
+ }
+ userIDs, err := parseDocPermissionUsers(rawUsers)
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": nodeID,
+ "userIds": userIDs,
+ }
+ if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
+ params["workspaceId"] = v
+ }
+ if commandDryRun(cmd) {
+ return writeCommandPayload(cmd, executor.NewHelperInvocation(
+ cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
+ ))
+ }
+ result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
+ ))
+ if err != nil {
+ return err
+ }
+ return writeCommandPayload(cmd, result)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addDocNodeFlags(cmd)
+ cmd.Flags().String("users", "", i18n.T("被移除用户 userId 列表,逗号分隔,单次最多 30 (必填)"))
+ addDocHiddenStringFlag(cmd, "user", "--users alias")
+ addDocHiddenStringFlag(cmd, "uid", "--users alias")
+ cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
+ addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2217,7 +2335,7 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
"roleId": role,
"userIds": userIDs,
}
- if v, _ := cmd.Flags().GetString("workspace"); v != "" {
+ if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
@@ -2234,6 +2352,10 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
return writeCommandPayload(cmd, result)
}
+func docDeprecatedNotice(cmd *cobra.Command, replacement string) {
+ fmt.Fprintf(cmd.ErrOrStderr(), "warning: deprecated: use dws %s instead.\n", replacement)
+}
+
// TRANSITIONAL: 等 mse 把 delete_document 加入 doc toolOverrides(含
// destructive_hint: true)后,本 helper 可删除——CLI discovery 会自动
// 生成等价命令。工单:plan/mse-yuyuan-patch.md 改动 2.2。
diff --git a/internal/helpers/doc_test.go b/internal/helpers/doc_test.go
index 45dd40c6..37efacd3 100644
--- a/internal/helpers/doc_test.go
+++ b/internal/helpers/doc_test.go
@@ -96,6 +96,35 @@ func TestDocPermissionListLimitAliases(t *testing.T) {
}
}
+func TestDocPermissionRemoveRoutesToRemovePermission(t *testing.T) {
+ t.Parallel()
+
+ runner := &docCommandRunner{}
+ cmd := newDocTestRoot(runner)
+ _, errOut, err := executeDocCommand(t, cmd,
+ "permission", "rm",
+ "--node", "NODE_001",
+ "--users", "uid1,uid2",
+ "--workspace", "WS_001",
+ )
+ if err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut)
+ }
+ if runner.last.Tool != "remove_permission" {
+ t.Fatalf("tool = %q, want remove_permission", runner.last.Tool)
+ }
+ if got := runner.last.Params["nodeId"]; got != "NODE_001" {
+ t.Fatalf("nodeId = %#v, want NODE_001", got)
+ }
+ users, ok := runner.last.Params["userIds"].([]string)
+ if !ok || strings.Join(users, ",") != "uid1,uid2" {
+ t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
+ }
+ if got := runner.last.Params["workspaceId"]; got != "WS_001" {
+ t.Fatalf("workspaceId = %#v, want WS_001", got)
+ }
+}
+
func TestDocPermissionListMaxresultsRejected(t *testing.T) {
t.Parallel()
diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go
index b083c394..ae7a4cf8 100644
--- a/internal/helpers/drive.go
+++ b/internal/helpers/drive.go
@@ -79,6 +79,12 @@ func (driveHandler) Command(runner executor.Runner) *cobra.Command {
newDriveCommitCommand(runner),
newDriveUploadCommand(runner),
newDriveDeleteCommand(runner),
+ newDriveSearchCommand(runner),
+ newDriveCopyCommand(runner),
+ newDriveMoveCommand(runner),
+ newDriveRenameCommand(runner),
+ newDrivePermissionCommand(runner),
+ newDriveFolderCommand(runner),
)
return root
}
@@ -98,6 +104,17 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
if maxResults <= 0 {
maxResults = 20
}
+ if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
+ params := map[string]any{"workspaceId": workspaceID}
+ if folderID := driveFlagOrFallback(cmd, "folder", "parent-id"); folderID != "" {
+ params["folderId"] = normalizeDocNodeID(folderID)
+ }
+ if maxResults > 0 {
+ params["pageSize"] = maxResults
+ }
+ addDriveStringParam(cmd, params, "pageToken", "cursor", "next-token")
+ return runDriveInvocation(cmd, runner, "doc", "list_nodes", params)
+ }
params := map[string]any{"maxResults": float64(maxResults)}
addDriveStringParam(cmd, params, "spaceId", "space-id")
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
@@ -124,6 +141,8 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("space-id", "", "空间 ID,不传则使用「我的文件」对应 spaceId (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则列出空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
+ cmd.Flags().String("workspace", "", "文档空间/知识库 ID,传入则路由到文档空间")
+ addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
cmd.Flags().String("cursor", "", "分页游标,首次不传 (可选)")
addDriveHiddenStringFlag(cmd, "next-token", "--cursor 的兼容别名")
cmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选)")
@@ -147,6 +166,7 @@ spaceType 筛选规则:
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki space list instead.")
params := map[string]any{}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
@@ -380,6 +400,9 @@ func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断 (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
+ cmd.Flags().String("workspace", "", "目标知识库 ID,传入时路由到文档空间上传")
+ addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
+ cmd.Flags().Bool("convert", false, "是否转换为钉钉在线文档(文档空间上传时生效)")
return cmd
}
@@ -388,6 +411,9 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
if strings.TrimSpace(filePath) == "" {
return apperrors.NewValidation("--file is required")
}
+ if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
+ return runDriveUploadToDoc(cmd, runner, workspaceID)
+ }
absPath, err := filepath.Abs(filePath)
if err != nil {
@@ -493,6 +519,93 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
return writeCommandPayload(cmd, result)
}
+func runDriveUploadToDoc(cmd *cobra.Command, runner executor.Runner, workspaceID string) error {
+ filePath, _ := cmd.Flags().GetString("file")
+ absPath, err := filepath.Abs(filePath)
+ if err != nil {
+ return apperrors.NewValidation("无法解析文件路径: " + err.Error())
+ }
+ fi, err := os.Stat(absPath)
+ if err != nil {
+ return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
+ }
+ if fi.IsDir() {
+ return apperrors.NewValidation("--file 不能是目录: " + absPath)
+ }
+ fileSize := fi.Size()
+ if fileSize <= 0 {
+ return apperrors.NewValidation("文件为空")
+ }
+
+ fileName := driveFlagOrFallback(cmd, "file-name", "name")
+ if fileName == "" {
+ fileName = filepath.Base(absPath)
+ }
+ folderID := driveFlagOrFallback(cmd, "folder", "parent-id")
+ if folderID != "" {
+ folderID = normalizeDocNodeID(folderID)
+ }
+
+ step1Params := map[string]any{"workspaceId": workspaceID}
+ if folderID != "" {
+ step1Params["folderId"] = folderID
+ }
+ commitParams := map[string]any{
+ "name": fileName,
+ "fileSize": float64(fileSize),
+ "workspaceId": workspaceID,
+ }
+ if folderID != "" {
+ commitParams["folderId"] = folderID
+ }
+ if convert, _ := cmd.Flags().GetBool("convert"); convert {
+ commitParams["convertToOnlineDoc"] = true
+ }
+
+ if commandDryRun(cmd) {
+ return writeCommandPayload(cmd, map[string]any{
+ "dry_run": true,
+ "step_1_get_file_upload_info": executor.NewHelperInvocation(
+ cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
+ ),
+ "step_2_http_put_oss": "PUT file bytes to resourceUrl with returned headers",
+ "step_3_commit_uploaded_file": executor.NewHelperInvocation(
+ cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
+ ),
+ "file": absPath,
+ "name": fileName,
+ "size": fileSize,
+ })
+ }
+
+ fmt.Fprintf(cmd.ErrOrStderr(), "[1/3] 获取文档空间上传凭证 %s (%d 字节)...\n", fileName, fileSize)
+ step1Result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
+ ))
+ if err != nil {
+ return fmt.Errorf("获取上传凭证失败: %w", err)
+ }
+ resourceURL, uploadKey, headers, err := extractDocFileUploadInfo(step1Result.Response)
+ if err != nil {
+ return err
+ }
+
+ fmt.Fprintln(cmd.ErrOrStderr(), "[2/3] 上传文件到 OSS...")
+ if err := httpPutDriveFile(cmd.Context(), resourceURL, headers, absPath, fileSize); err != nil {
+ return err
+ }
+
+ fmt.Fprintln(cmd.ErrOrStderr(), "[3/3] 提交文件入库...")
+ commitParams["uploadKey"] = uploadKey
+ result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
+ ))
+ if err != nil {
+ return fmt.Errorf("提交文件入库失败: %w", err)
+ }
+ return writeCommandPayload(cmd, result)
+}
+
// ── delete (drive surface routed to doc MCP server) ────────
func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
@@ -528,6 +641,303 @@ func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
return cmd
}
+func newDriveSearchCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "search",
+ Short: "搜索文件(聚合钉盘和文档空间)",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ keyword := driveFlagOrFallback(cmd, "query", "keyword")
+ if keyword == "" {
+ return apperrors.NewValidation("--query is required")
+ }
+ target := driveStringFlag(cmd, "target")
+ driveParams := map[string]any{"keyword": keyword}
+ if target != "" && target != "all" {
+ driveParams["searchTarget"] = target
+ }
+ addDriveStringSliceParam(cmd, driveParams, "fileTypes", "file-types")
+ addDriveStringSliceParam(cmd, driveParams, "extensions", "extensions")
+ addDriveStringSliceParam(cmd, driveParams, "creatorUserIds", "creator-uids")
+ addDriveInt64Param(cmd, driveParams, "createdTimeFrom", "created-from")
+ addDriveInt64Param(cmd, driveParams, "createdTimeTo", "created-to")
+ addDriveInt64Param(cmd, driveParams, "modifiedTimeFrom", "modified-from")
+ addDriveInt64Param(cmd, driveParams, "modifiedTimeTo", "modified-to")
+ if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
+ driveParams["pageSize"] = float64(pageSize)
+ }
+ addDriveStringParam(cmd, driveParams, "pageToken", "cursor", "page-token")
+
+ if target == "file" || target == "space" {
+ return runDriveInvocation(cmd, runner, "drive", "search_files", driveParams)
+ }
+
+ driveResult, driveErr := driveInvocationResult(cmd, runner, "drive", "search_files", driveParams)
+ docParams := map[string]any{"keyword": keyword}
+ if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
+ docParams["pageSize"] = pageSize
+ }
+ if extensions, ok := driveParams["extensions"]; ok {
+ docParams["extensions"] = extensions
+ }
+ docResult, docErr := driveInvocationResult(cmd, runner, "doc", "search_documents", docParams)
+ if driveErr != nil && docErr != nil {
+ return fmt.Errorf("aggregated search failed: drive: %v; doc: %v", driveErr, docErr)
+ }
+ result := map[string]any{}
+ if driveErr == nil {
+ result["drive_results"] = driveResult.Response
+ }
+ if docErr == nil {
+ result["doc_results"] = docResult.Response
+ }
+ return writeCommandPayload(cmd, map[string]any{"success": true, "result": result})
+ },
+ }
+ preferLegacyLeaf(cmd)
+ cmd.Flags().String("query", "", "搜索关键词 (必填)")
+ addDriveHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
+ cmd.Flags().String("target", "", "搜索范围: all(默认) / file / space")
+ cmd.Flags().StringSlice("file-types", nil, "按文件内容类型过滤")
+ cmd.Flags().StringSlice("extensions", nil, "按文件扩展名过滤")
+ cmd.Flags().StringSlice("creator-uids", nil, "按创建者 userId 过滤")
+ cmd.Flags().Int64("created-from", 0, "创建时间起始毫秒时间戳")
+ cmd.Flags().Int64("created-to", 0, "创建时间截止毫秒时间戳")
+ cmd.Flags().Int64("modified-from", 0, "修改时间起始毫秒时间戳")
+ cmd.Flags().Int64("modified-to", 0, "修改时间截止毫秒时间戳")
+ cmd.Flags().Int("limit", 0, "每页返回数量")
+ cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
+ _ = cmd.Flags().MarkHidden("page-size")
+ cmd.Flags().String("cursor", "", "分页游标")
+ addDriveHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
+ return cmd
+}
+
+func newDriveCopyCommand(runner executor.Runner) *cobra.Command {
+ return newDriveDocTransferCommand(runner, "copy", "copy_document")
+}
+
+func newDriveMoveCommand(runner executor.Runner) *cobra.Command {
+ return newDriveDocTransferCommand(runner, "move", "move_document")
+}
+
+func newDriveDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: use,
+ Short: use + " 文档空间节点",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
+ if folder := driveFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
+ params["targetFolderId"] = normalizeDocNodeID(folder)
+ }
+ addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
+ return runDriveInvocation(cmd, runner, "doc", tool, params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addDriveDocNodeFlags(cmd)
+ cmd.Flags().String("folder", "", "目标文件夹 nodeId")
+ addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
+ addDriveHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
+ addDriveHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
+ cmd.Flags().String("workspace", "", "目标知识库 ID")
+ addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
+ return cmd
+}
+
+func newDriveRenameCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "rename",
+ Short: "重命名文档空间节点",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
+ if err != nil {
+ return err
+ }
+ name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
+ if err != nil {
+ return err
+ }
+ return runDriveInvocation(cmd, runner, "doc", "rename_document", map[string]any{
+ "nodeId": normalizeDocNodeID(nodeID),
+ "newName": name,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addDriveDocNodeFlags(cmd)
+ cmd.Flags().String("name", "", "新名称 (必填)")
+ addDriveHiddenStringFlag(cmd, "title", "--name 的兼容别名")
+ return cmd
+}
+
+func newDrivePermissionCommand(runner executor.Runner) *cobra.Command {
+ root := &cobra.Command{
+ Use: "permission",
+ Aliases: []string{"perm"},
+ Short: "文档空间节点权限管理",
+ Args: cobra.NoArgs,
+ TraverseChildren: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return cmd.Help()
+ },
+ }
+ root.AddCommand(
+ newDrivePermissionMutationCommand(runner, "add", "add_permission", true),
+ newDrivePermissionMutationCommand(runner, "update", "update_permission", true),
+ newDrivePermissionListCommand(runner),
+ newDrivePermissionRemoveCommand(runner),
+ )
+ preferLegacyLeaf(root)
+ return root
+}
+
+func newDrivePermissionMutationCommand(runner executor.Runner, use, tool string, requireRole bool) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: use,
+ Short: use + " 文档空间节点权限",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
+ if err != nil {
+ return err
+ }
+ rawUsers := driveFlagOrFallback(cmd, "users", "user", "uid")
+ if rawUsers == "" {
+ return apperrors.NewValidation("--users is required")
+ }
+ userIDs, err := parseDocPermissionUsers(rawUsers)
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": normalizeDocNodeID(nodeID),
+ "userIds": userIDs,
+ }
+ if requireRole {
+ rawRole, err := driveRequiredFlag(cmd, "role")
+ if err != nil {
+ return err
+ }
+ role, ok := normalizeDocPermissionRole(rawRole)
+ if !ok {
+ return apperrors.NewValidation(fmt.Sprintf("invalid --role: %s", rawRole))
+ }
+ params["roleId"] = role
+ }
+ addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
+ return runDriveInvocation(cmd, runner, "doc", tool, params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addDriveDocNodeFlags(cmd)
+ cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
+ addDriveHiddenStringFlag(cmd, "user", "--users 的兼容别名")
+ addDriveHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
+ if requireRole {
+ cmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
+ }
+ cmd.Flags().String("workspace", "", "知识库 ID (选填)")
+ addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
+ return cmd
+}
+
+func newDrivePermissionListCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Aliases: []string{"ls"},
+ Short: "查询文档空间节点协作者",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
+ if limit := driveIntFlagOrFallback(cmd, "limit", "max-results", "page-size"); limit > 0 {
+ params["maxResults"] = limit
+ }
+ if filterRole := driveStringFlag(cmd, "filter-role"); filterRole != "" {
+ params["filterRoleIds"] = parseDriveRoleList(filterRole)
+ }
+ addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
+ return runDriveInvocation(cmd, runner, "doc", "list_permission", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addDriveDocNodeFlags(cmd)
+ cmd.Flags().Int("limit", 30, "返回成员数上限")
+ cmd.Flags().Int("max-results", 0, "--limit 的兼容别名")
+ _ = cmd.Flags().MarkHidden("max-results")
+ cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
+ _ = cmd.Flags().MarkHidden("page-size")
+ cmd.Flags().String("filter-role", "", "按角色过滤,逗号分隔")
+ cmd.Flags().String("workspace", "", "知识库 ID (选填)")
+ addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
+ return cmd
+}
+
+func newDrivePermissionRemoveCommand(runner executor.Runner) *cobra.Command {
+ cmd := newDrivePermissionMutationCommand(runner, "remove", "remove_permission", false)
+ cmd.Aliases = []string{"rm"}
+ return cmd
+}
+
+func newDriveFolderCommand(runner executor.Runner) *cobra.Command {
+ root := &cobra.Command{
+ Use: "folder",
+ Short: "文档空间文件夹兼容入口(deprecated)",
+ Args: cobra.NoArgs,
+ TraverseChildren: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return cmd.Help()
+ },
+ }
+ create := &cobra.Command{
+ Use: "create",
+ Short: "创建文档空间文件夹(deprecated)",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki node create --type folder instead.")
+ name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"name": name}
+ if folder := driveFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
+ params["folderId"] = normalizeDocNodeID(folder)
+ }
+ addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
+ return runDriveInvocation(cmd, runner, "doc", "create_folder", params)
+ },
+ }
+ preferLegacyLeaf(root)
+ preferLegacyLeaf(create)
+ create.Flags().String("name", "", "文件夹名称 (必填)")
+ addDriveHiddenStringFlag(create, "title", "--name 的兼容别名")
+ create.Flags().String("folder", "", "父文件夹 nodeId 或 URL")
+ addDriveHiddenStringFlag(create, "parent-id", "--folder 的兼容别名")
+ create.Flags().String("workspace", "", "目标知识库 ID")
+ addDriveHiddenStringFlag(create, "workspace-id", "--workspace 的兼容别名")
+ root.AddCommand(create)
+ root.Hidden = true
+ return root
+}
+
func runDriveInfo(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
result, err := driveInvocationResult(cmd, runner, "drive", "get_file_info", params)
if err != nil {
@@ -814,6 +1224,64 @@ func addDriveStringParam(cmd *cobra.Command, params map[string]any, paramName st
}
}
+func addDriveStringSliceParam(cmd *cobra.Command, params map[string]any, paramName, flag string) {
+ if !cmd.Flags().Changed(flag) {
+ return
+ }
+ values, err := cmd.Flags().GetStringSlice(flag)
+ if err != nil || len(values) == 0 {
+ return
+ }
+ cleaned := make([]string, 0, len(values))
+ for _, value := range values {
+ for _, part := range strings.Split(value, ",") {
+ if item := strings.TrimSpace(part); item != "" {
+ cleaned = append(cleaned, item)
+ }
+ }
+ }
+ if len(cleaned) > 0 {
+ params[paramName] = cleaned
+ }
+}
+
+func addDriveInt64Param(cmd *cobra.Command, params map[string]any, paramName, flag string) {
+ if !cmd.Flags().Changed(flag) {
+ return
+ }
+ value, err := cmd.Flags().GetInt64(flag)
+ if err == nil && value > 0 {
+ params[paramName] = value
+ }
+}
+
+func addDriveDocNodeFlags(cmd *cobra.Command) {
+ cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
+ addDriveHiddenStringFlag(cmd, "url", "--node 的兼容别名")
+ addDriveHiddenStringFlag(cmd, "id", "--node 的兼容别名")
+ addDriveHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
+ addDriveHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
+ addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
+}
+
+func parseDriveRoleList(raw string) []string {
+ roles := make([]string, 0)
+ for _, part := range strings.Split(raw, ",") {
+ role := strings.ToUpper(strings.TrimSpace(part))
+ if role == "" {
+ continue
+ }
+ if normalized, ok := normalizeDocPermissionRole(role); ok {
+ roles = append(roles, normalized)
+ continue
+ }
+ if role == "OWNER" {
+ roles = append(roles, role)
+ }
+ }
+ return roles
+}
+
func addDriveHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
diff --git a/internal/helpers/drive_test.go b/internal/helpers/drive_test.go
index 5af1112f..ca144c52 100644
--- a/internal/helpers/drive_test.go
+++ b/internal/helpers/drive_test.go
@@ -21,19 +21,24 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
type driveCommandRunner struct {
+ calls int
+ all []executor.Invocation
last executor.Invocation
result executor.Result
err error
}
func (r *driveCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
+ r.calls++
r.last = invocation
+ r.all = append(r.all, invocation)
if r.err != nil {
return executor.Result{}, r.err
}
@@ -65,6 +70,109 @@ func TestDriveListPageSizeAliasMapsMaxResults(t *testing.T) {
}
}
+func TestDriveListWorkspaceRoutesToDocListNodes(t *testing.T) {
+ t.Parallel()
+
+ runner := &driveCommandRunner{}
+ cmd := newDriveListCommand(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"--workspace-id", "WS_001", "--folder", "FOLDER_001", "--limit", "10"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if runner.last.CanonicalProduct != "doc" {
+ t.Fatalf("product = %q, want doc", runner.last.CanonicalProduct)
+ }
+ if runner.last.Tool != "list_nodes" {
+ t.Fatalf("tool = %q, want list_nodes", runner.last.Tool)
+ }
+ if got := runner.last.Params["workspaceId"]; got != "WS_001" {
+ t.Fatalf("workspaceId = %#v, want WS_001", got)
+ }
+ if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
+ t.Fatalf("folderId = %#v, want FOLDER_001", got)
+ }
+ if got := runner.last.Params["pageSize"]; got != 10 {
+ t.Fatalf("pageSize = %#v, want 10", got)
+ }
+}
+
+func TestDriveCopyAliasesRouteToDoc(t *testing.T) {
+ t.Parallel()
+
+ runner := &driveCommandRunner{}
+ cmd := newDriveCopyCommand(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"--file-id", "NODE_001", "--parent-id", "FOLDER_001", "--workspace-id", "WS_001"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "copy_document" {
+ t.Fatalf("invocation = %#v, want doc copy_document", runner.last)
+ }
+ if got := runner.last.Params["nodeId"]; got != "NODE_001" {
+ t.Fatalf("nodeId = %#v, want NODE_001", got)
+ }
+ if got := runner.last.Params["targetFolderId"]; got != "FOLDER_001" {
+ t.Fatalf("targetFolderId = %#v, want FOLDER_001", got)
+ }
+ if got := runner.last.Params["workspaceId"]; got != "WS_001" {
+ t.Fatalf("workspaceId = %#v, want WS_001", got)
+ }
+}
+
+func TestDrivePermissionRemoveRoutesToDoc(t *testing.T) {
+ t.Parallel()
+
+ runner := &driveCommandRunner{}
+ cmd := newDrivePermissionCommand(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"remove", "--node", "NODE_001", "--users", "uid1,uid2"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "remove_permission" {
+ t.Fatalf("invocation = %#v, want doc remove_permission", runner.last)
+ }
+ users, ok := runner.last.Params["userIds"].([]string)
+ if !ok || strings.Join(users, ",") != "uid1,uid2" {
+ t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
+ }
+}
+
+func TestDriveSearchAggregatesDriveAndDoc(t *testing.T) {
+ t.Parallel()
+
+ runner := &driveCommandRunner{}
+ cmd := newDriveSearchCommand(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"--query", "报告", "--extensions", "pdf,docx", "--limit", "5"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if len(runner.all) != 2 {
+ t.Fatalf("calls = %d, want 2", len(runner.all))
+ }
+ if runner.all[0].CanonicalProduct != "drive" || runner.all[0].Tool != "search_files" {
+ t.Fatalf("first invocation = %#v, want drive search_files", runner.all[0])
+ }
+ if runner.all[1].CanonicalProduct != "doc" || runner.all[1].Tool != "search_documents" {
+ t.Fatalf("second invocation = %#v, want doc search_documents", runner.all[1])
+ }
+}
+
func TestDriveDownloadOutputDirectoryUsesServerFileName(t *testing.T) {
t.Parallel()
diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go
new file mode 100644
index 00000000..6edfe061
--- /dev/null
+++ b/internal/helpers/sheet.go
@@ -0,0 +1,2209 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ RegisterPublic(func() Handler {
+ return sheetHandler{}
+ })
+}
+
+type sheetHandler struct{}
+
+func (sheetHandler) Name() string {
+ return "sheet"
+}
+
+func (sheetHandler) Command(runner executor.Runner) *cobra.Command {
+ root := newSheetGroup("sheet", "钉钉电子表格")
+ root.Long = "管理钉钉在线电子表格:文档与工作表管理、单元格读写、CSV、行列、样式、筛选、条件格式和图片。"
+
+ rangeCmd := newSheetGroup("range", "数据区域操作")
+ rangeCmd.AddCommand(
+ newSheetRangeReadCommand(runner),
+ newSheetRangeUpdateCommand(runner),
+ newSheetRangeClearCommand(runner),
+ newSheetRangeSortCommand(runner),
+ newSheetRangeFillCommand(runner),
+ newSheetRangeCopyCommand(runner),
+ newSheetRangeMoveCommand(runner),
+ newSheetRangeSetStyleCommand(runner),
+ newSheetRangeBatchSetStyleCommand(runner),
+ )
+
+ filterCmd := newSheetGroup("filter", "全局筛选管理")
+ filterCmd.AddCommand(
+ newSheetFilterGetCommand(runner),
+ newSheetFilterCreateCommand(runner),
+ newSheetFilterDeleteCommand(runner),
+ newSheetFilterUpdateCommand(runner),
+ newSheetFilterClearCriteriaCommand(runner),
+ newSheetFilterSortCommand(runner),
+ )
+
+ filterViewCmd := newSheetGroup("filter-view", "筛选视图管理")
+ filterViewCmd.AddCommand(
+ newSheetFilterViewListCommand(runner),
+ newSheetFilterViewCreateCommand(runner),
+ newSheetFilterViewUpdateCommand(runner),
+ newSheetFilterViewDeleteCommand(runner),
+ newSheetFilterViewUpdateCriteriaCommand(runner),
+ newSheetFilterViewDeleteCriteriaCommand(runner),
+ newSheetFilterViewInfoCommand(runner),
+ newSheetFilterViewListCriteriaCommand(runner),
+ newSheetFilterViewGetCriteriaCommand(runner),
+ )
+
+ condFormatCmd := newSheetGroup("cond-format", "条件格式管理")
+ condFormatCmd.AddCommand(
+ newSheetCondFormatListCommand(runner),
+ newSheetCondFormatCreateCommand(runner),
+ newSheetCondFormatUpdateCommand(runner),
+ newSheetCondFormatDeleteCommand(runner),
+ )
+
+ root.AddCommand(
+ newSheetCreateCommand(runner),
+ newSheetListCommand(runner),
+ newSheetInfoCommand(runner),
+ newSheetNewCommand(runner),
+ newSheetUpdateCommand(runner),
+ newSheetCopyCommand(runner),
+ newSheetDeleteSheetCommand(runner),
+ rangeCmd,
+ newSheetFindCommand(runner),
+ newSheetAppendCommand(runner),
+ newSheetCSVPutCommand(runner),
+ newSheetCSVGetCommand(runner),
+ newSheetInsertDimensionCommand(runner),
+ newSheetDeleteDimensionCommand(runner),
+ newSheetUpdateDimensionCommand(runner),
+ newSheetMoveDimensionCommand(runner),
+ newSheetAddDimensionCommand(runner),
+ newSheetMergeCellsCommand(runner),
+ newSheetUnmergeCellsCommand(runner),
+ newSheetSetDropdownCommand(runner),
+ newSheetGetDropdownCommand(runner),
+ newSheetDeleteDropdownCommand(runner),
+ newSheetMediaUploadCommand(runner),
+ newSheetWriteImageCommand(runner),
+ newSheetReplaceCommand(runner),
+ filterCmd,
+ filterViewCmd,
+ condFormatCmd,
+ newSheetCreateFloatImageCommand(runner),
+ newSheetGetFloatImageCommand(runner),
+ newSheetListFloatImagesCommand(runner),
+ newSheetUpdateFloatImageCommand(runner),
+ newSheetDeleteFloatImageCommand(runner),
+ newSheetExportCommand(runner),
+ )
+ return root
+}
+
+func newSheetCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("create", "创建钉钉表格文档", func(cmd *cobra.Command, _ []string) error {
+ name, err := sheetRequiredFlag(cmd, "name")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"name": name}
+ sheetAddStringParam(cmd, params, "folderId", "folder")
+ sheetAddStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
+ return runSheetTool(cmd, runner, "create_workspace_sheet", params)
+ })
+ cmd.Flags().String("name", "", "表格名称 (必填)")
+ cmd.Flags().String("folder", "", "目标文件夹 ID 或 URL")
+ cmd.Flags().String("workspace", "", "目标知识库 ID")
+ addSheetHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
+ return cmd
+}
+
+func newSheetListCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("list", "获取全部工作表列表", func(cmd *cobra.Command, _ []string) error {
+ node, err := sheetRequiredFlag(cmd, "node")
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "get_all_sheets", map[string]any{"nodeId": node})
+ })
+ addSheetNodeFlags(cmd)
+ return cmd
+}
+
+func newSheetInfoCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("info", "获取指定工作表详情", func(cmd *cobra.Command, _ []string) error {
+ node, err := sheetRequiredFlag(cmd, "node")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"nodeId": node}
+ sheetAddStringParam(cmd, params, "sheetId", "sheet-id")
+ return runSheetTool(cmd, runner, "get_sheet", params)
+ })
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("sheet-id", "", "工作表 ID 或名称")
+ return cmd
+}
+
+func newSheetNewCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("new", "新建工作表", func(cmd *cobra.Command, _ []string) error {
+ node, name, err := sheetNodeAndName(cmd)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "create_sheet", map[string]any{"nodeId": node, "name": name})
+ })
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("name", "", "工作表名称 (必填)")
+ return cmd
+}
+
+func newSheetUpdateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update", "更新工作表属性", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ changed := false
+ if cmd.Flags().Changed("name") || cmd.Flags().Changed("title") {
+ params["title"] = sheetNameFlag(cmd)
+ changed = true
+ }
+ if cmd.Flags().Changed("index") {
+ index, _ := cmd.Flags().GetInt("index")
+ if index < 0 {
+ return apperrors.NewValidation("--index must be >= 0")
+ }
+ params["index"] = index
+ changed = true
+ }
+ if cmd.Flags().Changed("hidden") {
+ v, _ := cmd.Flags().GetBool("hidden")
+ params["hidden"] = v
+ changed = true
+ }
+ if cmd.Flags().Changed("frozen-row-count") {
+ v, _ := cmd.Flags().GetInt("frozen-row-count")
+ if v < 0 {
+ return apperrors.NewValidation("--frozen-row-count must be >= 0")
+ }
+ params["frozenRowCount"] = v
+ changed = true
+ }
+ if cmd.Flags().Changed("frozen-column-count") {
+ v, _ := cmd.Flags().GetInt("frozen-column-count")
+ if v < 0 {
+ return apperrors.NewValidation("--frozen-column-count must be >= 0")
+ }
+ params["frozenColumnCount"] = v
+ changed = true
+ }
+ if !changed {
+ return apperrors.NewValidation("at least one of --name, --index, --hidden, --frozen-row-count or --frozen-column-count is required")
+ }
+ return runSheetTool(cmd, runner, "update_sheet", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("name", "", "工作表新名称")
+ cmd.Flags().String("title", "", "--name alias")
+ _ = cmd.Flags().MarkHidden("title")
+ cmd.Flags().Int("index", 0, "工作表新位置索引,0-based")
+ cmd.Flags().Bool("hidden", false, "是否隐藏工作表")
+ cmd.Flags().Int("frozen-row-count", 0, "冻结行数")
+ cmd.Flags().Int("frozen-column-count", 0, "冻结列数")
+ return cmd
+}
+
+func newSheetCopyCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("copy", "复制工作表", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ if cmd.Flags().Changed("name") || cmd.Flags().Changed("title") {
+ params["title"] = sheetNameFlag(cmd)
+ }
+ if cmd.Flags().Changed("index") {
+ index, _ := cmd.Flags().GetInt("index")
+ if index < 0 {
+ return apperrors.NewValidation("--index must be >= 0")
+ }
+ params["index"] = index
+ }
+ return runSheetTool(cmd, runner, "copy_sheet", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("name", "", "副本名称")
+ cmd.Flags().String("title", "", "--name alias")
+ _ = cmd.Flags().MarkHidden("title")
+ cmd.Flags().Int("index", 0, "副本位置索引,0-based")
+ return cmd
+}
+
+func newSheetDeleteSheetCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("delete-sheet", "删除工作表", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "delete_sheet", params)
+ })
+ addSheetBaseFlags(cmd)
+ return cmd
+}
+
+func newSheetRangeReadCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("read", "读取工作表数据", func(cmd *cobra.Command, _ []string) error {
+ node, err := sheetRequiredFlag(cmd, "node")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"nodeId": node}
+ sheetAddStringParam(cmd, params, "sheetId", "sheet-id")
+ sheetAddStringParam(cmd, params, "range", "range")
+ sheetAddStringParam(cmd, params, "valueRenderOption", "value-render-option")
+ return runSheetTool(cmd, runner, "get_cell_infos", params)
+ })
+ cmd.Aliases = []string{"get"}
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("sheet-id", "", "工作表 ID 或名称")
+ cmd.Flags().String("range", "", "读取范围,A1 表示法")
+ cmd.Flags().String("value-render-option", "", "formatted_value | raw_value | formula")
+ return cmd
+}
+
+func newSheetRangeUpdateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update", "更新工作表指定区域内容", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "range", "values"); err != nil {
+ return err
+ }
+ var cells [][]any
+ if err := sheetParseJSONFlag(cmd, "values", &cells); err != nil {
+ return err
+ }
+ for i, row := range cells {
+ for j, cell := range row {
+ if cell == nil {
+ return apperrors.NewValidation(fmt.Sprintf("--values[%d][%d] must be an object, not null", i, j))
+ }
+ if _, ok := cell.(map[string]any); !ok {
+ return apperrors.NewValidation(fmt.Sprintf("--values[%d][%d] must be an object", i, j))
+ }
+ }
+ }
+ return runSheetTool(cmd, runner, "set_cell_range", map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "rangeAddress": sheetStringFlag(cmd, "range"),
+ "cells": cells,
+ })
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "目标单元格区域地址 (必填)")
+ cmd.Flags().String("values", "", "单元格内容二维 JSON 数组 (必填)")
+ return cmd
+}
+
+func newSheetRangeClearCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("clear", "清除工作表指定区域", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "range": sheetStringFlag(cmd, "range"),
+ }
+ sheetAddStringParam(cmd, params, "type", "type")
+ return runSheetTool(cmd, runner, "clear_range", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "清除范围,A1 表示法 (必填)")
+ cmd.Flags().String("type", "", "content | format | all")
+ return cmd
+}
+
+func newSheetRangeSortCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("sort", "对工作表指定区域排序", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "range", "sort-keys"); err != nil {
+ return err
+ }
+ var sortKeys []any
+ if err := sheetParseJSONFlag(cmd, "sort-keys", &sortKeys); err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "range": sheetStringFlag(cmd, "range"),
+ "sortKeys": sortKeys,
+ }
+ if v, _ := cmd.Flags().GetBool("has-header"); v {
+ params["hasHeader"] = true
+ }
+ return runSheetTool(cmd, runner, "sort_range", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "排序范围,A1 表示法 (必填)")
+ cmd.Flags().String("sort-keys", "", "排序规则 JSON 数组 (必填)")
+ cmd.Flags().Bool("has-header", false, "首行是否为表头")
+ return cmd
+}
+
+func newSheetRangeFillCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("fill", "自动填充工作表指定区域", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "source-range", "target-range"); err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "sourceRange": sheetStringFlag(cmd, "source-range"),
+ "destinationRange": sheetStringFlag(cmd, "target-range"),
+ }
+ sheetAddStringParam(cmd, params, "fillType", "fill-type")
+ return runSheetTool(cmd, runner, "fill_range", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("source-range", "", "源数据范围,A1 表示法 (必填)")
+ cmd.Flags().String("target-range", "", "目标填充范围,A1 表示法 (必填)")
+ cmd.Flags().String("fill-type", "", "copy | onlystyle | withoutstyle")
+ return cmd
+}
+
+func newSheetRangeCopyCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("copy-to", "复制工作表指定区域到目标位置", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetRangeTransferParams(cmd)
+ if err != nil {
+ return err
+ }
+ sheetAddStringParam(cmd, params, "pasteType", "paste-type")
+ return runSheetTool(cmd, runner, "copy_range", params)
+ })
+ addSheetRangeTransferFlags(cmd)
+ cmd.Flags().String("paste-type", "", "values | formulas | formats | all")
+ return cmd
+}
+
+func newSheetRangeMoveCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("move-to", "移动工作表指定区域到目标位置", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetRangeTransferParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "move_range", params)
+ })
+ addSheetRangeTransferFlags(cmd)
+ return cmd
+}
+
+func newSheetFindCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("find", "在工作表中搜索单元格内容", func(cmd *cobra.Command, _ []string) error {
+ query, err := sheetRequiredFlagOrFallback(cmd, "query", "find")
+ if err != nil {
+ return err
+ }
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ params["text"] = query
+ sheetAddStringParam(cmd, params, "range", "range")
+ for flag, key := range map[string]string{
+ "match-case": "matchCase",
+ "match-entire-cell": "matchEntireCell",
+ "use-regexp": "useRegExp",
+ "match-formula": "matchFormulaText",
+ "include-hidden": "includeHidden",
+ } {
+ v, _ := cmd.Flags().GetBool(flag)
+ params[key] = v
+ }
+ return runSheetTool(cmd, runner, "find_cells", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("query", "", "搜索文本 (必填)")
+ cmd.Flags().String("find", "", "--query alias")
+ _ = cmd.Flags().MarkHidden("find")
+ cmd.Flags().String("range", "", "搜索范围,A1 表示法")
+ cmd.Flags().Bool("match-case", true, "区分大小写")
+ cmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配")
+ cmd.Flags().Bool("use-regexp", false, "启用正则表达式搜索")
+ cmd.Flags().Bool("match-formula", false, "搜索公式文本")
+ cmd.Flags().Bool("include-hidden", false, "包含隐藏单元格")
+ return cmd
+}
+
+func newSheetAppendCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("append", "在工作表末尾追加数据", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "values"); err != nil {
+ return err
+ }
+ var values [][]any
+ if err := sheetParseJSONFlag(cmd, "values", &values); err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "append_rows", map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "values": values,
+ })
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("values", "", "追加数据二维 JSON 数组 (必填)")
+ return cmd
+}
+
+func newSheetCSVPutCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("csv-put", "将 CSV 数据写入表格指定位置", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "csv", "start-cell"); err != nil {
+ return err
+ }
+ csvContent := sheetStringFlag(cmd, "csv")
+ switch {
+ case csvContent == "-":
+ data, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ return fmt.Errorf("read stdin failed: %w", err)
+ }
+ csvContent = string(data)
+ case strings.HasPrefix(csvContent, "@"):
+ data, err := os.ReadFile(strings.TrimPrefix(csvContent, "@"))
+ if err != nil {
+ return fmt.Errorf("read CSV file failed: %w", err)
+ }
+ csvContent = string(data)
+ }
+ csvContent = strings.ReplaceAll(csvContent, "\r", "")
+ csvContent = strings.TrimPrefix(csvContent, "\xef\xbb\xbf")
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "csv": csvContent,
+ "startCell": sheetStringFlag(cmd, "start-cell"),
+ }
+ if cmd.Flags().Changed("allow-overwrite") {
+ v, _ := cmd.Flags().GetBool("allow-overwrite")
+ params["allowOverwrite"] = v
+ }
+ return runSheetTool(cmd, runner, "set_range_from_csv", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("csv", "", "CSV 文本、@文件路径 或 - (必填)")
+ cmd.Flags().String("start-cell", "", "起始单元格,A1 表示法 (必填)")
+ cmd.Flags().Bool("allow-overwrite", false, "允许覆盖已有数据")
+ return cmd
+}
+
+func newSheetCSVGetCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("csv-get", "以 CSV 格式读取工作表数据", func(cmd *cobra.Command, _ []string) error {
+ node, err := sheetRequiredFlag(cmd, "node")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"nodeId": node}
+ sheetAddStringParam(cmd, params, "sheetId", "sheet-id")
+ sheetAddStringParam(cmd, params, "range", "range")
+ sheetAddStringParam(cmd, params, "valueRenderOption", "value-render-option")
+ if cmd.Flags().Changed("max-chars") {
+ v, _ := cmd.Flags().GetInt("max-chars")
+ params["maxChars"] = v
+ }
+ return runSheetTool(cmd, runner, "get_range_as_csv", params)
+ })
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("sheet-id", "", "工作表 ID 或名称")
+ cmd.Flags().String("range", "", "读取范围,A1 表示法")
+ cmd.Flags().String("value-render-option", "", "formatted_value | raw_value | formula")
+ cmd.Flags().Int("max-chars", 0, "CSV 最大字符数")
+ return cmd
+}
+
+func newSheetInsertDimensionCommand(runner executor.Runner) *cobra.Command {
+ return newSheetDimensionPositionCommand(runner, "insert-dimension", "在指定位置插入行或列", "insert_dimension")
+}
+
+func newSheetDeleteDimensionCommand(runner executor.Runner) *cobra.Command {
+ return newSheetDimensionPositionCommand(runner, "delete-dimension", "删除指定位置的行或列", "delete_dimension")
+}
+
+func newSheetDimensionPositionCommand(runner executor.Runner, use, short, tool string) *cobra.Command {
+ cmd := newSheetLeaf(use, short, func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "dimension", "position", "length"); err != nil {
+ return err
+ }
+ dimension, err := sheetDimension(cmd)
+ if err != nil {
+ return err
+ }
+ length, err := sheetPositiveIntStringFlag(cmd, "length", 5000)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, tool, map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "dimension": dimension,
+ "position": sheetStringFlag(cmd, "position"),
+ "length": length,
+ })
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("dimension", "", "ROWS 或 COLUMNS (必填)")
+ cmd.Flags().String("position", "", "位置,A1 表示法 (必填)")
+ cmd.Flags().String("length", "", "数量,正整数 (必填)")
+ return cmd
+}
+
+func newSheetUpdateDimensionCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update-dimension", "更新指定范围行/列属性", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "dimension", "start-index", "length"); err != nil {
+ return err
+ }
+ dimension, err := sheetDimension(cmd)
+ if err != nil {
+ return err
+ }
+ length, err := sheetPositiveIntStringFlag(cmd, "length", 5000)
+ if err != nil {
+ return err
+ }
+ hiddenChanged := cmd.Flags().Changed("hidden")
+ pixelChanged := cmd.Flags().Changed("pixel-size")
+ if !hiddenChanged && !pixelChanged {
+ return apperrors.NewValidation("at least one of --hidden or --pixel-size is required")
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "dimension": dimension,
+ "startIndex": sheetStringFlag(cmd, "start-index"),
+ "length": length,
+ }
+ if hiddenChanged {
+ v, _ := cmd.Flags().GetBool("hidden")
+ params["hidden"] = v
+ }
+ if pixelChanged {
+ v, _ := cmd.Flags().GetInt("pixel-size")
+ if v < 0 {
+ return apperrors.NewValidation("--pixel-size must be >= 0")
+ }
+ params["pixelSize"] = v
+ }
+ return runSheetTool(cmd, runner, "update_dimension", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("dimension", "", "ROWS 或 COLUMNS (必填)")
+ cmd.Flags().String("start-index", "", "起始位置,A1 表示法 (必填)")
+ cmd.Flags().String("length", "", "数量,正整数 (必填)")
+ cmd.Flags().Bool("hidden", false, "是否隐藏")
+ cmd.Flags().Int("pixel-size", 0, "行高或列宽")
+ return cmd
+}
+
+func newSheetMoveDimensionCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("move-dimension", "移动行或列到指定位置", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "dimension", "start-index", "end-index", "destination-index"); err != nil {
+ return err
+ }
+ dimension, err := sheetDimension(cmd)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "move_dimension", map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "dimension": dimension,
+ "startIndex": sheetStringFlag(cmd, "start-index"),
+ "endIndex": sheetStringFlag(cmd, "end-index"),
+ "destinationIndex": sheetStringFlag(cmd, "destination-index"),
+ })
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("dimension", "", "ROWS 或 COLUMNS (必填)")
+ cmd.Flags().String("start-index", "", "源起始位置 (必填)")
+ cmd.Flags().String("end-index", "", "源结束位置 (必填)")
+ cmd.Flags().String("destination-index", "", "目标位置 (必填)")
+ return cmd
+}
+
+func newSheetAddDimensionCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("add-dimension", "在末尾追加空行或空列", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "dimension"); err != nil {
+ return err
+ }
+ dimension, err := sheetDimension(cmd)
+ if err != nil {
+ return err
+ }
+ length, _ := cmd.Flags().GetInt("length")
+ if length < 1 || length > 5000 {
+ return apperrors.NewValidation("--length must be between 1 and 5000")
+ }
+ return runSheetTool(cmd, runner, "add_dimension", map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "dimension": dimension,
+ "length": length,
+ })
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("dimension", "", "ROWS 或 COLUMNS (必填)")
+ cmd.Flags().Int("length", 0, "追加数量,正整数 (必填)")
+ return cmd
+}
+
+func newSheetMergeCellsCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("merge-cells", "合并单元格", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetRangeAddressParams(cmd)
+ if err != nil {
+ return err
+ }
+ sheetAddStringParam(cmd, params, "mergeType", "merge-type")
+ return runSheetTool(cmd, runner, "merge_cells", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "目标单元格区域地址 (必填)")
+ cmd.Flags().String("merge-type", "", "mergeAll | mergeRows | mergeColumns")
+ return cmd
+}
+
+func newSheetUnmergeCellsCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("unmerge-cells", "取消合并单元格", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetRangeAddressParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "unmerge_range", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "取消合并的范围 (必填)")
+ return cmd
+}
+
+func newSheetSetDropdownCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("set-dropdown", "设置下拉列表", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "range", "options"); err != nil {
+ return err
+ }
+ var options []map[string]any
+ if err := sheetParseJSONFlag(cmd, "options", &options); err != nil {
+ return err
+ }
+ if len(options) == 0 {
+ return apperrors.NewValidation("--options must contain at least one item")
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "range": sheetStringFlag(cmd, "range"),
+ "options": options,
+ }
+ if v, _ := cmd.Flags().GetBool("multi-select"); v {
+ params["enableMultiSelect"] = true
+ }
+ return runSheetTool(cmd, runner, "set_dropdown_lists", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "目标单元格范围 (必填)")
+ cmd.Flags().String("options", "", "下拉选项 JSON 数组 (必填)")
+ cmd.Flags().Bool("multi-select", false, "是否允许多选")
+ return cmd
+}
+
+func newSheetGetDropdownCommand(runner executor.Runner) *cobra.Command {
+ return newSheetRangeToolCommand(runner, "get-dropdown", "获取下拉列表配置", "get_dropdown_lists", "range")
+}
+
+func newSheetDeleteDropdownCommand(runner executor.Runner) *cobra.Command {
+ return newSheetRangeToolCommand(runner, "delete-dropdown", "删除下拉列表", "delete_dropdown_lists", "range")
+}
+
+func newSheetReplaceCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("replace", "查找替换文本", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "find"); err != nil {
+ return err
+ }
+ if !cmd.Flags().Changed("replacement") {
+ return apperrors.NewValidation("--replacement is required")
+ }
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ params["text"] = sheetStringFlag(cmd, "find")
+ params["replaceText"] = sheetStringFlag(cmd, "replacement")
+ sheetAddStringParam(cmd, params, "range", "range")
+ for flag, key := range map[string]string{
+ "match-case": "matchCase",
+ "match-entire-cell": "matchEntireCell",
+ "use-regexp": "useRegExp",
+ "include-hidden": "includeHidden",
+ } {
+ v, _ := cmd.Flags().GetBool(flag)
+ params[key] = v
+ }
+ return runSheetTool(cmd, runner, "replace_all", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("find", "", "查找文本 (必填)")
+ cmd.Flags().String("replacement", "", "替换文本 (必填,可为空)")
+ cmd.Flags().String("range", "", "替换范围,A1 表示法")
+ cmd.Flags().Bool("match-case", false, "区分大小写")
+ cmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配")
+ cmd.Flags().Bool("use-regexp", false, "启用正则表达式匹配")
+ cmd.Flags().Bool("include-hidden", false, "包含隐藏行/列")
+ return cmd
+}
+
+func newSheetFilterGetCommand(runner executor.Runner) *cobra.Command {
+ return newSheetBaseToolCommand(runner, "get", "获取全局筛选信息", "get_filter")
+}
+
+func newSheetFilterDeleteCommand(runner executor.Runner) *cobra.Command {
+ return newSheetBaseToolCommand(runner, "delete", "删除全局筛选", "delete_filter")
+}
+
+func newSheetFilterCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("create", "创建全局筛选", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "range": sheetStringFlag(cmd, "range"),
+ }
+ if v := sheetStringFlag(cmd, "criteria"); v != "" {
+ var criteria []any
+ if err := json.Unmarshal([]byte(v), &criteria); err != nil {
+ return fmt.Errorf("--criteria JSON parse failed: %w", err)
+ }
+ params["criteria"] = criteria
+ }
+ return runSheetTool(cmd, runner, "create_filter", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "筛选范围,A1 表示法 (必填)")
+ cmd.Flags().String("criteria", "", "筛选条件 JSON 数组")
+ return cmd
+}
+
+func newSheetFilterUpdateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update", "批量更新筛选条件", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "criteria"); err != nil {
+ return err
+ }
+ var criteria []any
+ if err := sheetParseJSONFlag(cmd, "criteria", &criteria); err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "update_filter", map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "criteria": criteria,
+ })
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("criteria", "", "筛选条件 JSON 数组 (必填)")
+ return cmd
+}
+
+func newSheetFilterClearCriteriaCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetColumnCommand(runner, "clear-criteria", "清除单列筛选条件", "clear_filter_criteria")
+ return cmd
+}
+
+func newSheetFilterSortCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("sort", "筛选排序", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ column, _ := cmd.Flags().GetInt("column")
+ ascending, _ := cmd.Flags().GetBool("ascending")
+ params["field"] = map[string]any{"column": column, "ascending": ascending}
+ return runSheetTool(cmd, runner, "sort_filter", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().Int("column", 0, "排序列偏移量,从 0 开始")
+ cmd.Flags().Bool("ascending", true, "是否升序")
+ return cmd
+}
+
+func newSheetFilterViewListCommand(runner executor.Runner) *cobra.Command {
+ return newSheetBaseToolCommand(runner, "list", "获取所有筛选视图", "get_filter_views")
+}
+
+func newSheetFilterViewCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("create", "创建筛选视图", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "name", "range"); err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "name": sheetStringFlag(cmd, "name"),
+ "range": sheetStringFlag(cmd, "range"),
+ }
+ if v := sheetStringFlag(cmd, "criteria"); v != "" {
+ var criteria []any
+ if err := json.Unmarshal([]byte(v), &criteria); err != nil {
+ return fmt.Errorf("--criteria JSON parse failed: %w", err)
+ }
+ params["criteria"] = criteria
+ }
+ return runSheetTool(cmd, runner, "create_filter_view", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("name", "", "筛选视图名称 (必填)")
+ cmd.Flags().String("range", "", "筛选视图范围,A1 表示法 (必填)")
+ cmd.Flags().String("criteria", "", "筛选条件 JSON 数组")
+ return cmd
+}
+
+func newSheetFilterViewUpdateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update", "更新筛选视图属性", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "filter-view-id"); err != nil {
+ return err
+ }
+ params := sheetFilterViewBaseParams(cmd)
+ changed := false
+ if cmd.Flags().Changed("name") {
+ params["name"] = sheetStringFlag(cmd, "name")
+ changed = true
+ }
+ if cmd.Flags().Changed("range") {
+ params["range"] = sheetStringFlag(cmd, "range")
+ changed = true
+ }
+ if cmd.Flags().Changed("criteria") {
+ var criteria []any
+ if err := sheetParseJSONFlag(cmd, "criteria", &criteria); err != nil {
+ return err
+ }
+ params["criteria"] = criteria
+ changed = true
+ }
+ if !changed {
+ return apperrors.NewValidation("at least one of --name, --range or --criteria is required")
+ }
+ return runSheetTool(cmd, runner, "update_filter_view", params)
+ })
+ addSheetFilterViewBaseFlags(cmd)
+ cmd.Flags().String("name", "", "筛选视图新名称")
+ cmd.Flags().String("range", "", "筛选视图新范围")
+ cmd.Flags().String("criteria", "", "筛选条件 JSON 数组")
+ return cmd
+}
+
+func newSheetFilterViewDeleteCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("delete", "删除筛选视图", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "filter-view-id"); err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "delete_filter_view", sheetFilterViewBaseParams(cmd))
+ })
+ addSheetFilterViewBaseFlags(cmd)
+ return cmd
+}
+
+func newSheetFilterViewUpdateCriteriaCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update-criteria", "更新筛选视图列条件", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "filter-view-id", "filter-criteria"); err != nil {
+ return err
+ }
+ params := sheetFilterViewBaseParams(cmd)
+ column, _ := cmd.Flags().GetInt("column")
+ if column < 0 {
+ return apperrors.NewValidation("--column must be >= 0")
+ }
+ var filterCriteria map[string]any
+ if err := sheetParseJSONFlag(cmd, "filter-criteria", &filterCriteria); err != nil {
+ return err
+ }
+ params["column"] = column
+ params["filterCriteria"] = filterCriteria
+ return runSheetTool(cmd, runner, "set_filter_view_criteria", params)
+ })
+ addSheetFilterViewBaseFlags(cmd)
+ cmd.Flags().Int("column", 0, "列偏移量,从 0 开始")
+ cmd.Flags().String("filter-criteria", "", "筛选条件 JSON 对象 (必填)")
+ return cmd
+}
+
+func newSheetFilterViewDeleteCriteriaCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetColumnCommand(runner, "delete-criteria", "删除筛选视图列条件", "clear_filter_view_criteria")
+ cmd.Flags().String("filter-view-id", "", "筛选视图 ID (必填)")
+ return cmd
+}
+
+func newSheetFilterViewInfoCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetFilterViewReadCommand(runner, "info", "获取单个筛选视图详情", "info")
+ return cmd
+}
+
+func newSheetFilterViewListCriteriaCommand(runner executor.Runner) *cobra.Command {
+ return newSheetFilterViewReadCommand(runner, "list-criteria", "列出筛选视图所有列条件", "list-criteria")
+}
+
+func newSheetFilterViewGetCriteriaCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetFilterViewReadCommand(runner, "get-criteria", "获取单列筛选条件", "get-criteria")
+ cmd.Flags().Int("column", 0, "列偏移量,从 0 开始")
+ return cmd
+}
+
+func newSheetFilterViewReadCommand(runner executor.Runner, use, short, mode string) *cobra.Command {
+ cmd := newSheetLeaf(use, short, func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "filter-view-id"); err != nil {
+ return err
+ }
+ result, err := sheetInvocationResult(cmd, runner, "sheet", "get_filter_views", map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ })
+ if err != nil {
+ return err
+ }
+ filterViews := sheetResultFilterViews(result.Response)
+ if len(filterViews) == 0 {
+ return writeCommandPayload(cmd, result)
+ }
+ view, err := sheetFindFilterView(filterViews, sheetStringFlag(cmd, "filter-view-id"))
+ if err != nil {
+ return err
+ }
+ switch mode {
+ case "info":
+ return writeCommandPayload(cmd, view)
+ case "list-criteria":
+ if criteria, ok := view["criteria"]; ok {
+ return writeCommandPayload(cmd, criteria)
+ }
+ return writeCommandPayload(cmd, map[string]any{})
+ case "get-criteria":
+ column, _ := cmd.Flags().GetInt("column")
+ if column < 0 {
+ return apperrors.NewValidation("--column must be >= 0")
+ }
+ criteria, _ := view["criteria"].(map[string]any)
+ if criteria == nil {
+ return apperrors.NewValidation("filter view has no criteria")
+ }
+ item, ok := criteria[strconv.Itoa(column)]
+ if !ok {
+ return apperrors.NewValidation("filter view column criteria not found")
+ }
+ return writeCommandPayload(cmd, item)
+ default:
+ return writeCommandPayload(cmd, result)
+ }
+ })
+ addSheetFilterViewBaseFlags(cmd)
+ return cmd
+}
+
+func newSheetCondFormatListCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("list", "获取条件格式规则", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ sheetAddStringParam(cmd, params, "ruleId", "rule-id")
+ return runSheetTool(cmd, runner, "get_cond_format", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("rule-id", "", "条件格式规则 ID")
+ return cmd
+}
+
+func newSheetCondFormatCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("create", "创建条件格式规则", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetCondFormatMutationBase(cmd, false)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "create_cond_format", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("ranges", "", "应用范围 JSON 数组 (必填)")
+ cmd.Flags().String("condition", "", "条件类型及参数 JSON 对象 (必填)")
+ cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象")
+ cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象")
+ return cmd
+}
+
+func newSheetCondFormatUpdateCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update", "更新条件格式规则", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetCondFormatMutationBase(cmd, true)
+ if err != nil {
+ return err
+ }
+ if len(params) <= 3 {
+ return apperrors.NewValidation("at least one of --ranges, --condition, --cell-style or --data-bar-style is required")
+ }
+ return runSheetTool(cmd, runner, "update_cond_format", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("rule-id", "", "条件格式规则 ID (必填)")
+ cmd.Flags().String("ranges", "", "应用范围 JSON 数组")
+ cmd.Flags().String("condition", "", "条件类型及参数 JSON 对象")
+ cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象")
+ cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象")
+ return cmd
+}
+
+func newSheetCondFormatDeleteCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("delete", "删除条件格式规则", func(cmd *cobra.Command, _ []string) error {
+ if ok, _ := cmd.Flags().GetBool("yes"); !ok && !commandDryRun(cmd) {
+ return apperrors.NewValidation("delete cond-format requires --yes")
+ }
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ ruleID, err := sheetRequiredFlag(cmd, "rule-id")
+ if err != nil {
+ return err
+ }
+ params["ruleId"] = ruleID
+ return runSheetTool(cmd, runner, "delete_cond_format", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("rule-id", "", "条件格式规则 ID (必填)")
+ cmd.Flags().Bool("yes", false, "确认删除")
+ return cmd
+}
+
+func newSheetCreateFloatImageCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("create-float-image", "创建浮动图片", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "src", "range"); err != nil {
+ return err
+ }
+ width, height, err := sheetPositiveSize(cmd, true)
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "src": sheetStringFlag(cmd, "src"),
+ "range": sheetStringFlag(cmd, "range"),
+ "width": width,
+ "height": height,
+ }
+ sheetAddChangedIntParam(cmd, params, "offsetX", "offset-x")
+ sheetAddChangedIntParam(cmd, params, "offsetY", "offset-y")
+ return runSheetTool(cmd, runner, "create_float_image", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("src", "", "图片资源路径 (必填)")
+ cmd.Flags().String("range", "", "锚点单元格 (必填)")
+ cmd.Flags().Int("width", 0, "图片宽度,像素 (必填)")
+ cmd.Flags().Int("height", 0, "图片高度,像素 (必填)")
+ cmd.Flags().Int("offset-x", 0, "水平偏移量")
+ cmd.Flags().Int("offset-y", 0, "垂直偏移量")
+ return cmd
+}
+
+func newSheetGetFloatImageCommand(runner executor.Runner) *cobra.Command {
+ return newSheetFloatImageIDCommand(runner, "get-float-image", "获取浮动图片详情", "get_float_image")
+}
+
+func newSheetListFloatImagesCommand(runner executor.Runner) *cobra.Command {
+ return newSheetBaseToolCommand(runner, "list-float-images", "列出工作表所有浮动图片", "list_float_images")
+}
+
+func newSheetUpdateFloatImageCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("update-float-image", "更新浮动图片属性", func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetFloatImageBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ changed := false
+ for flag, key := range map[string]string{"src": "src", "range": "range"} {
+ if cmd.Flags().Changed(flag) {
+ params[key] = sheetStringFlag(cmd, flag)
+ changed = true
+ }
+ }
+ for flag, key := range map[string]string{"width": "width", "height": "height", "offset-x": "offsetX", "offset-y": "offsetY"} {
+ if cmd.Flags().Changed(flag) {
+ v, _ := cmd.Flags().GetInt(flag)
+ if (flag == "width" || flag == "height") && v <= 0 {
+ return apperrors.NewValidation("--" + flag + " must be > 0")
+ }
+ if (flag == "offset-x" || flag == "offset-y") && v < 0 {
+ return apperrors.NewValidation("--" + flag + " must be >= 0")
+ }
+ params[key] = v
+ changed = true
+ }
+ }
+ if !changed {
+ return apperrors.NewValidation("at least one float image field is required")
+ }
+ return runSheetTool(cmd, runner, "update_float_image", params)
+ })
+ addSheetFloatImageBaseFlags(cmd)
+ cmd.Flags().String("src", "", "新的图片资源路径")
+ cmd.Flags().String("range", "", "新的锚点单元格")
+ cmd.Flags().Int("width", 0, "新的图片宽度")
+ cmd.Flags().Int("height", 0, "新的图片高度")
+ cmd.Flags().Int("offset-x", 0, "新的水平偏移量")
+ cmd.Flags().Int("offset-y", 0, "新的垂直偏移量")
+ return cmd
+}
+
+func newSheetDeleteFloatImageCommand(runner executor.Runner) *cobra.Command {
+ return newSheetFloatImageIDCommand(runner, "delete-float-image", "删除浮动图片", "delete_float_image")
+}
+
+func newSheetMediaUploadCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("media-upload", "上传附件到表格", func(cmd *cobra.Command, _ []string) error {
+ return runSheetAttachmentUpload(cmd, runner, false)
+ })
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("file", "", "本地文件路径 (必填)")
+ cmd.Flags().String("name", "", "附件显示名称")
+ cmd.Flags().String("mime-type", "", "文件 MIME 类型")
+ return cmd
+}
+
+func newSheetWriteImageCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("write-image", "上传图片并写入表格单元格", func(cmd *cobra.Command, _ []string) error {
+ return runSheetAttachmentUpload(cmd, runner, true)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "目标单元格区域地址 (必填)")
+ cmd.Flags().String("file", "", "本地图片文件路径 (必填)")
+ cmd.Flags().String("name", "", "图片显示名称")
+ cmd.Flags().String("mime-type", "", "文件 MIME 类型")
+ cmd.Flags().Int("width", 0, "图片显示宽度")
+ cmd.Flags().Int("height", 0, "图片显示高度")
+ return cmd
+}
+
+func newSheetExportCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("export", "导出表格为 xlsx", func(cmd *cobra.Command, _ []string) error {
+ node, err := sheetRequiredFlag(cmd, "node")
+ if err != nil {
+ return err
+ }
+ outputPath := sheetStringFlag(cmd, "output")
+ if commandDryRun(cmd) {
+ return runSheetTool(cmd, runner, "submit_export_job", map[string]any{"nodeId": node, "exportFormat": "xlsx"})
+ }
+ submit, err := sheetInvocationResult(cmd, runner, "sheet", "submit_export_job", map[string]any{"nodeId": node, "exportFormat": "xlsx"})
+ if err != nil {
+ return err
+ }
+ jobID := sheetStringFromResponse(submit.Response, "jobId")
+ if jobID == "" {
+ return writeCommandPayload(cmd, submit)
+ }
+ downloadURL, status, err := pollSheetExport(cmd, runner, jobID)
+ if err != nil {
+ return err
+ }
+ out := map[string]any{"jobId": jobID, "status": status}
+ if downloadURL != "" {
+ out["downloadUrl"] = downloadURL
+ }
+ if outputPath != "" && downloadURL != "" {
+ if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
+ outputPath = filepath.Join(outputPath, sheetExportFilename(downloadURL, jobID))
+ }
+ if err := sheetHTTPGet(cmd, downloadURL, outputPath); err != nil {
+ return err
+ }
+ out["output"] = outputPath
+ }
+ return writeCommandPayload(cmd, out)
+ })
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("output", "", "本地保存路径")
+ return cmd
+}
+
+func newSheetRangeToolCommand(runner executor.Runner, use, short, tool, rangeFlag string) *cobra.Command {
+ cmd := newSheetLeaf(use, short, func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", rangeFlag); err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "range": sheetStringFlag(cmd, rangeFlag),
+ }
+ return runSheetTool(cmd, runner, tool, params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String(rangeFlag, "", "范围,A1 表示法 (必填)")
+ return cmd
+}
+
+func newSheetBaseToolCommand(runner executor.Runner, use, short, tool string) *cobra.Command {
+ cmd := newSheetLeaf(use, short, func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, tool, params)
+ })
+ addSheetBaseFlags(cmd)
+ return cmd
+}
+
+func newSheetColumnCommand(runner executor.Runner, use, short, tool string) *cobra.Command {
+ cmd := newSheetLeaf(use, short, func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(use, "criteria") && strings.Contains(tool, "filter_view") {
+ filterViewID, err := sheetRequiredFlag(cmd, "filter-view-id")
+ if err != nil {
+ return err
+ }
+ params["filterViewId"] = filterViewID
+ }
+ column, _ := cmd.Flags().GetInt("column")
+ if column < 0 {
+ return apperrors.NewValidation("--column must be >= 0")
+ }
+ params["column"] = column
+ return runSheetTool(cmd, runner, tool, params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().Int("column", 0, "列偏移量,从 0 开始")
+ return cmd
+}
+
+func newSheetFloatImageIDCommand(runner executor.Runner, use, short, tool string) *cobra.Command {
+ cmd := newSheetLeaf(use, short, func(cmd *cobra.Command, _ []string) error {
+ params, err := sheetFloatImageBaseParams(cmd)
+ if err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, tool, params)
+ })
+ addSheetFloatImageBaseFlags(cmd)
+ return cmd
+}
+
+func newSheetGroup(use, short string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: use,
+ Short: short,
+ Args: cobra.NoArgs,
+ TraverseChildren: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return cmd.Help()
+ },
+ }
+ preferLegacyLeaf(cmd)
+ return cmd
+}
+
+func newSheetLeaf(use, short string, run func(*cobra.Command, []string) error) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: use,
+ Short: short,
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: run,
+ }
+ preferLegacyLeaf(cmd)
+ return cmd
+}
+
+func runSheetTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
+ result, err := sheetInvocationResult(cmd, runner, "sheet", tool, params)
+ if err != nil {
+ return err
+ }
+ return writeCommandPayload(cmd, result)
+}
+
+func sheetInvocationResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
+ invocation := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), product, tool, params)
+ invocation.DryRun = commandDryRun(cmd)
+ return runner.Run(cmd.Context(), invocation)
+}
+
+func addSheetNodeFlags(cmd *cobra.Command) {
+ cmd.Flags().String("node", "", "表格文档 ID 或 URL")
+ addSheetHiddenStringFlag(cmd, "url", "--node alias")
+ addSheetHiddenStringFlag(cmd, "id", "--node alias")
+ addSheetHiddenStringFlag(cmd, "node-id", "--node alias")
+ addSheetHiddenStringFlag(cmd, "doc-id", "--node alias")
+ addSheetHiddenStringFlag(cmd, "file-id", "--node alias")
+}
+
+func addSheetBaseFlags(cmd *cobra.Command) {
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("sheet-id", "", "工作表 ID 或名称")
+}
+
+func addSheetFilterViewBaseFlags(cmd *cobra.Command) {
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("filter-view-id", "", "筛选视图 ID")
+}
+
+func addSheetFloatImageBaseFlags(cmd *cobra.Command) {
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("float-image-id", "", "浮动图片 ID")
+}
+
+func addSheetRangeTransferFlags(cmd *cobra.Command) {
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("source-range", "", "源范围,A1 表示法 (必填)")
+ cmd.Flags().String("target-range", "", "目标位置,A1 表示法 (必填)")
+ cmd.Flags().String("target-sheet-id", "", "目标工作表 ID 或名称")
+}
+
+func addSheetHiddenStringFlag(cmd *cobra.Command, name, usage string) {
+ cmd.Flags().String(name, "", usage)
+ _ = cmd.Flags().MarkHidden(name)
+}
+
+func sheetBaseParams(cmd *cobra.Command) (map[string]any, error) {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id"); err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ }, nil
+}
+
+func sheetFilterViewBaseParams(cmd *cobra.Command) map[string]any {
+ return map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "filterViewId": sheetStringFlag(cmd, "filter-view-id"),
+ }
+}
+
+func sheetFloatImageBaseParams(cmd *cobra.Command) (map[string]any, error) {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "float-image-id"); err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "floatImageId": sheetStringFlag(cmd, "float-image-id"),
+ }, nil
+}
+
+func sheetRangeAddressParams(cmd *cobra.Command) (map[string]any, error) {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "rangeAddress": sheetStringFlag(cmd, "range"),
+ }, nil
+}
+
+func sheetRangeTransferParams(cmd *cobra.Command) (map[string]any, error) {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "source-range", "target-range"); err != nil {
+ return nil, err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "sourceRange": sheetStringFlag(cmd, "source-range"),
+ "destinationRange": sheetStringFlag(cmd, "target-range"),
+ }
+ sheetAddStringParam(cmd, params, "targetSheetId", "target-sheet-id")
+ return params, nil
+}
+
+func sheetNodeAndName(cmd *cobra.Command) (string, string, error) {
+ node, err := sheetRequiredFlag(cmd, "node")
+ if err != nil {
+ return "", "", err
+ }
+ name, err := sheetRequiredFlag(cmd, "name")
+ if err != nil {
+ return "", "", err
+ }
+ return node, name, nil
+}
+
+func sheetNameFlag(cmd *cobra.Command) string {
+ if v := sheetStringFlag(cmd, "name"); v != "" {
+ return v
+ }
+ return sheetStringFlag(cmd, "title")
+}
+
+func sheetStringFlag(cmd *cobra.Command, name string) string {
+ if v, _ := cmd.Flags().GetString(name); v != "" {
+ return v
+ }
+ if name == "node" {
+ for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
+ if flag := cmd.Flags().Lookup(alias); flag == nil {
+ continue
+ }
+ if v, _ := cmd.Flags().GetString(alias); v != "" {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+func sheetFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
+ for _, name := range append([]string{primary}, aliases...) {
+ if flag := cmd.Flags().Lookup(name); flag == nil {
+ continue
+ }
+ if v := sheetStringFlag(cmd, name); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func sheetRequiredFlag(cmd *cobra.Command, name string) (string, error) {
+ if v := sheetStringFlag(cmd, name); v != "" {
+ return v, nil
+ }
+ return "", apperrors.NewValidation("--" + name + " is required")
+}
+
+func sheetRequiredFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
+ if v := sheetFlagOrFallback(cmd, primary, aliases...); v != "" {
+ return v, nil
+ }
+ return "", apperrors.NewValidation("--" + primary + " is required")
+}
+
+func sheetValidateRequired(cmd *cobra.Command, names ...string) error {
+ for _, name := range names {
+ if sheetStringFlag(cmd, name) == "" {
+ return apperrors.NewValidation("--" + name + " is required")
+ }
+ }
+ return nil
+}
+
+func sheetAddStringParam(cmd *cobra.Command, params map[string]any, key string, flags ...string) {
+ if v := sheetFlagOrFallback(cmd, flags[0], flags[1:]...); v != "" {
+ params[key] = v
+ }
+}
+
+func sheetAddChangedIntParam(cmd *cobra.Command, params map[string]any, key, flag string) {
+ if cmd.Flags().Changed(flag) {
+ v, _ := cmd.Flags().GetInt(flag)
+ params[key] = v
+ }
+}
+
+func sheetParseJSONFlag(cmd *cobra.Command, flag string, out any) error {
+ raw := sheetStringFlag(cmd, flag)
+ if err := json.Unmarshal([]byte(raw), out); err != nil {
+ return fmt.Errorf("--%s JSON parse failed: %w", flag, err)
+ }
+ return nil
+}
+
+func sheetDimension(cmd *cobra.Command) (string, error) {
+ dimension := sheetStringFlag(cmd, "dimension")
+ switch dimension {
+ case "ROW":
+ dimension = "ROWS"
+ case "COLUMN":
+ dimension = "COLUMNS"
+ }
+ if dimension != "ROWS" && dimension != "COLUMNS" {
+ return "", apperrors.NewValidation("--dimension must be ROWS or COLUMNS")
+ }
+ return dimension, nil
+}
+
+func sheetPositiveIntStringFlag(cmd *cobra.Command, flag string, max int) (int, error) {
+ raw := sheetStringFlag(cmd, flag)
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 1 {
+ return 0, apperrors.NewValidation("--" + flag + " must be a positive integer")
+ }
+ if max > 0 && n > max {
+ return 0, apperrors.NewValidation(fmt.Sprintf("--%s must be <= %d", flag, max))
+ }
+ return n, nil
+}
+
+func sheetPositiveSize(cmd *cobra.Command, required bool) (int, int, error) {
+ width, _ := cmd.Flags().GetInt("width")
+ height, _ := cmd.Flags().GetInt("height")
+ if required || cmd.Flags().Changed("width") {
+ if width <= 0 {
+ return 0, 0, apperrors.NewValidation("--width must be > 0")
+ }
+ }
+ if required || cmd.Flags().Changed("height") {
+ if height <= 0 {
+ return 0, 0, apperrors.NewValidation("--height must be > 0")
+ }
+ }
+ return width, height, nil
+}
+
+func sheetCondFormatMutationBase(cmd *cobra.Command, update bool) (map[string]any, error) {
+ params, err := sheetBaseParams(cmd)
+ if err != nil {
+ return nil, err
+ }
+ if update {
+ ruleID, err := sheetRequiredFlag(cmd, "rule-id")
+ if err != nil {
+ return nil, err
+ }
+ params["ruleId"] = ruleID
+ }
+ if cmd.Flags().Changed("ranges") || !update {
+ var ranges []string
+ if err := sheetParseJSONFlag(cmd, "ranges", &ranges); err != nil {
+ return nil, err
+ }
+ if len(ranges) == 0 {
+ return nil, apperrors.NewValidation("--ranges must contain at least one range")
+ }
+ params["ranges"] = ranges
+ }
+ if cmd.Flags().Changed("condition") || !update {
+ var condition map[string]any
+ if err := sheetParseJSONFlag(cmd, "condition", &condition); err != nil {
+ return nil, err
+ }
+ for k, v := range condition {
+ params[k] = v
+ }
+ }
+ if cmd.Flags().Changed("cell-style") {
+ var cellStyle map[string]any
+ if err := sheetParseJSONFlag(cmd, "cell-style", &cellStyle); err != nil {
+ return nil, err
+ }
+ params["cellStyle"] = cellStyle
+ }
+ if cmd.Flags().Changed("data-bar-style") {
+ var dataBarStyle map[string]any
+ if err := sheetParseJSONFlag(cmd, "data-bar-style", &dataBarStyle); err != nil {
+ return nil, err
+ }
+ params["dataBarStyle"] = dataBarStyle
+ }
+ return params, nil
+}
+
+func runSheetAttachmentUpload(cmd *cobra.Command, runner executor.Runner, writeImage bool) error {
+ node, err := sheetRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
+ if err != nil {
+ return err
+ }
+ filePath, err := sheetRequiredFlag(cmd, "file")
+ if err != nil {
+ return err
+ }
+ if writeImage {
+ if err := sheetValidateRequired(cmd, "sheet-id", "range"); err != nil {
+ return err
+ }
+ }
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return fmt.Errorf("cannot read file %s: %w", filePath, err)
+ }
+ if info.IsDir() {
+ return apperrors.NewValidation(filePath + " is a directory")
+ }
+ fileName := sheetStringFlag(cmd, "name")
+ if fileName == "" {
+ fileName = filepath.Base(filePath)
+ } else if filepath.Ext(fileName) == "" {
+ if ext := filepath.Ext(filePath); ext != "" {
+ fileName += ext
+ }
+ }
+ mimeType := sheetStringFlag(cmd, "mime-type")
+ if mimeType == "" {
+ mimeType = detectMIME(fileName)
+ }
+ params := map[string]any{
+ "nodeId": node,
+ "fileName": fileName,
+ "fileSize": float64(info.Size()),
+ "mimeType": mimeType,
+ }
+ if commandDryRun(cmd) {
+ return runSheetDocTool(cmd, runner, "get_doc_attachment_upload_info", params)
+ }
+ credResult, err := sheetInvocationResult(cmd, runner, "doc", "get_doc_attachment_upload_info", params)
+ if err != nil {
+ return err
+ }
+ uploadURL, resourceID, resourceURL, err := extractDocAttachmentUploadInfo(credResult.Response)
+ if err != nil {
+ return err
+ }
+ if err := sheetHTTPPut(cmd, uploadURL, filePath, info.Size(), mimeType); err != nil {
+ return err
+ }
+ if !writeImage {
+ return writeCommandPayload(cmd, map[string]any{
+ "resourceId": resourceID,
+ "resourceUrl": resourceURL,
+ "fileName": fileName,
+ "mimeType": mimeType,
+ "fileSize": info.Size(),
+ })
+ }
+ writeArgs := map[string]any{
+ "nodeId": node,
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "rangeAddress": sheetStringFlag(cmd, "range"),
+ "resourceId": resourceID,
+ "resourceUrl": resourceURL,
+ }
+ sheetAddChangedIntParam(cmd, writeArgs, "width", "width")
+ sheetAddChangedIntParam(cmd, writeArgs, "height", "height")
+ return runSheetTool(cmd, runner, "write_image", writeArgs)
+}
+
+func runSheetDocTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
+ result, err := sheetInvocationResult(cmd, runner, "doc", tool, params)
+ if err != nil {
+ return err
+ }
+ return writeCommandPayload(cmd, result)
+}
+
+func sheetHTTPPut(cmd *cobra.Command, uploadURL, filePath string, size int64, mimeType string) error {
+ f, err := os.Open(filePath)
+ if err != nil {
+ return fmt.Errorf("open file failed: %w", err)
+ }
+ defer f.Close()
+ req, err := http.NewRequestWithContext(cmd.Context(), http.MethodPut, uploadURL, f)
+ if err != nil {
+ return fmt.Errorf("build upload request failed: %w", err)
+ }
+ req.ContentLength = size
+ if mimeType != "" {
+ req.Header.Set("Content-Type", mimeType)
+ }
+ resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
+ if err != nil {
+ return fmt.Errorf("OSS upload failed: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("OSS upload failed HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ return nil
+}
+
+func sheetHTTPGet(cmd *cobra.Command, rawURL, outputPath string) error {
+ req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, rawURL, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("download failed HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
+ return err
+ }
+ f, err := os.Create(outputPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = io.Copy(f, resp.Body)
+ return err
+}
+
+func pollSheetExport(cmd *cobra.Command, runner executor.Runner, jobID string) (downloadURL, status string, err error) {
+ intervals := make([]time.Duration, 0, 30)
+ for i := 0; i < 5; i++ {
+ intervals = append(intervals, 2*time.Second)
+ }
+ for i := 0; i < 5; i++ {
+ intervals = append(intervals, 5*time.Second)
+ }
+ for i := 0; i < 10; i++ {
+ intervals = append(intervals, 10*time.Second)
+ }
+ for i := 0; i < 10; i++ {
+ intervals = append(intervals, 15*time.Second)
+ }
+ for _, wait := range intervals {
+ timer := time.NewTimer(wait)
+ select {
+ case <-cmd.Context().Done():
+ timer.Stop()
+ return "", "", cmd.Context().Err()
+ case <-timer.C:
+ }
+ result, err := sheetInvocationResult(cmd, runner, "sheet", "query_export_job", map[string]any{"jobId": jobID})
+ if err != nil {
+ continue
+ }
+ status = strings.ToUpper(strings.TrimSpace(sheetStringFromResponse(result.Response, "status")))
+ downloadURL = sheetStringFromResponse(result.Response, "downloadUrl")
+ switch status {
+ case "SUCCESS":
+ return downloadURL, status, nil
+ case "FAILED", "FAIL", "ERROR":
+ msg := sheetStringFromResponse(result.Response, "message")
+ if msg == "" {
+ msg = "export failed"
+ }
+ return "", status, apperrors.NewValidation(msg)
+ }
+ }
+ return "", status, apperrors.NewValidation("export timed out")
+}
+
+func sheetStringFromResponse(resp map[string]any, key string) string {
+ data := sheetResponseData(resp)
+ if v, ok := data[key].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func sheetResponseData(resp map[string]any) map[string]any {
+ if resp == nil {
+ return map[string]any{}
+ }
+ data := resp
+ for {
+ next, ok := data["result"].(map[string]any)
+ if !ok {
+ next, ok = data["data"].(map[string]any)
+ }
+ if !ok {
+ next, ok = data["content"].(map[string]any)
+ }
+ if !ok || len(next) == 0 {
+ return data
+ }
+ data = next
+ }
+}
+
+func sheetResultFilterViews(resp map[string]any) []map[string]any {
+ data := sheetResponseData(resp)
+ raw, _ := data["filterViews"].([]any)
+ out := make([]map[string]any, 0, len(raw))
+ for _, item := range raw {
+ if m, ok := item.(map[string]any); ok {
+ out = append(out, m)
+ }
+ }
+ return out
+}
+
+func sheetFindFilterView(views []map[string]any, id string) (map[string]any, error) {
+ for _, view := range views {
+ if v, _ := view["id"].(string); v == id {
+ return view, nil
+ }
+ if v, _ := view["filterViewId"].(string); v == id {
+ return view, nil
+ }
+ }
+ return nil, apperrors.NewValidation("filter view not found: " + id)
+}
+
+func sheetExportFilename(downloadURL, jobID string) string {
+ if parsed, err := url.Parse(downloadURL); err == nil {
+ name := filepath.Base(parsed.Path)
+ if decoded, decodeErr := url.PathUnescape(name); decodeErr == nil && decoded != "" {
+ name = decoded
+ }
+ name = strings.ReplaceAll(name, "\\", "/")
+ name = filepath.Base(name)
+ if name != "" && name != "." && name != "/" {
+ return name
+ }
+ }
+ return "sheet-export-" + jobID + ".xlsx"
+}
+
+type sheetStyleSpec struct {
+ BgColor string `json:"bgColor,omitempty"`
+ BgColorsJSON string `json:"bgColorsJson,omitempty"`
+ FontSize int `json:"fontSize,omitempty"`
+ FontSizesJSON string `json:"fontSizesJson,omitempty"`
+ HAlign string `json:"hAlign,omitempty"`
+ HAlignsJSON string `json:"hAlignsJson,omitempty"`
+ VAlign string `json:"vAlign,omitempty"`
+ VAlignsJSON string `json:"vAlignsJson,omitempty"`
+ FontColor string `json:"fontColor,omitempty"`
+ FontColorsJSON string `json:"fontColorsJson,omitempty"`
+ FontWeight string `json:"fontWeight,omitempty"`
+ FontWeightsJSON string `json:"fontWeightsJson,omitempty"`
+ WordWrap string `json:"wordWrap,omitempty"`
+ NumberFormat string `json:"numberFormat,omitempty"`
+}
+
+type sheetBatchStyleItem struct {
+ SheetID string `json:"sheetId"`
+ Range string `json:"range"`
+ sheetStyleSpec
+}
+
+func newSheetRangeSetStyleCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("set-style", "设置指定单元格区域的样式", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
+ return err
+ }
+ rows, cols, err := sheetParseA1Range(sheetStringFlag(cmd, "range"))
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": sheetStringFlag(cmd, "node"),
+ "sheetId": sheetStringFlag(cmd, "sheet-id"),
+ "rangeAddress": sheetStringFlag(cmd, "range"),
+ }
+ if err := sheetApplyStyleSpec(sheetReadStyleSpec(cmd), rows, cols, params); err != nil {
+ return err
+ }
+ return runSheetTool(cmd, runner, "update_range", params)
+ })
+ addSheetBaseFlags(cmd)
+ cmd.Flags().String("range", "", "目标单元格区域地址 (必填)")
+ bindSheetStyleFlags(cmd)
+ return cmd
+}
+
+func newSheetRangeBatchSetStyleCommand(runner executor.Runner) *cobra.Command {
+ cmd := newSheetLeaf("batch-set-style", "按配置文件批量设置样式", func(cmd *cobra.Command, _ []string) error {
+ if err := sheetValidateRequired(cmd, "node", "batch"); err != nil {
+ return err
+ }
+ data, err := os.ReadFile(sheetStringFlag(cmd, "batch"))
+ if err != nil {
+ return err
+ }
+ var items []sheetBatchStyleItem
+ if err := json.Unmarshal(data, &items); err != nil {
+ return err
+ }
+ continueOnErr, _ := cmd.Flags().GetBool("continue-on-error")
+ var outputs []executor.Result
+ var firstErr error
+ for _, item := range items {
+ if item.SheetID == "" || item.Range == "" {
+ firstErr = apperrors.NewValidation("batch item requires sheetId and range")
+ if !continueOnErr {
+ return firstErr
+ }
+ continue
+ }
+ rows, cols, err := sheetParseA1Range(item.Range)
+ if err != nil {
+ firstErr = err
+ if !continueOnErr {
+ return err
+ }
+ continue
+ }
+ params := map[string]any{"nodeId": sheetStringFlag(cmd, "node"), "sheetId": item.SheetID, "rangeAddress": item.Range}
+ if err := sheetApplyStyleSpec(&item.sheetStyleSpec, rows, cols, params); err != nil {
+ firstErr = err
+ if !continueOnErr {
+ return err
+ }
+ continue
+ }
+ result, err := sheetInvocationResult(cmd, runner, "sheet", "update_range", params)
+ if err != nil {
+ firstErr = err
+ if !continueOnErr {
+ return err
+ }
+ continue
+ }
+ outputs = append(outputs, result)
+ }
+ if firstErr != nil && !continueOnErr {
+ return firstErr
+ }
+ return writeCommandPayload(cmd, map[string]any{"count": len(outputs), "results": outputs})
+ })
+ addSheetNodeFlags(cmd)
+ cmd.Flags().String("batch", "", "批次配置 JSON 文件路径 (必填)")
+ cmd.Flags().Bool("continue-on-error", false, "遇到失败时继续执行")
+ return cmd
+}
+
+func bindSheetStyleFlags(cmd *cobra.Command) {
+ cmd.Flags().String("bg-color", "", "背景色")
+ cmd.Flags().String("bg-colors-json", "", "背景色二维 JSON 数组")
+ cmd.Flags().Int("font-size", 0, "字号")
+ cmd.Flags().String("font-sizes-json", "", "字号二维 JSON 数组")
+ cmd.Flags().String("h-align", "", "水平对齐")
+ cmd.Flags().String("h-aligns-json", "", "水平对齐二维 JSON 数组")
+ cmd.Flags().String("v-align", "", "垂直对齐")
+ cmd.Flags().String("v-aligns-json", "", "垂直对齐二维 JSON 数组")
+ cmd.Flags().String("font-color", "", "字体颜色")
+ cmd.Flags().String("font-colors-json", "", "字体颜色二维 JSON 数组")
+ cmd.Flags().String("font-weight", "", "字体粗细")
+ cmd.Flags().String("font-weights-json", "", "字体粗细二维 JSON 数组")
+ cmd.Flags().String("word-wrap", "", "换行方式")
+ cmd.Flags().String("number-format", "", "数字格式 code")
+}
+
+func sheetReadStyleSpec(cmd *cobra.Command) *sheetStyleSpec {
+ spec := &sheetStyleSpec{}
+ spec.BgColor, _ = cmd.Flags().GetString("bg-color")
+ spec.BgColorsJSON, _ = cmd.Flags().GetString("bg-colors-json")
+ spec.FontSize, _ = cmd.Flags().GetInt("font-size")
+ spec.FontSizesJSON, _ = cmd.Flags().GetString("font-sizes-json")
+ spec.HAlign, _ = cmd.Flags().GetString("h-align")
+ spec.HAlignsJSON, _ = cmd.Flags().GetString("h-aligns-json")
+ spec.VAlign, _ = cmd.Flags().GetString("v-align")
+ spec.VAlignsJSON, _ = cmd.Flags().GetString("v-aligns-json")
+ spec.FontColor, _ = cmd.Flags().GetString("font-color")
+ spec.FontColorsJSON, _ = cmd.Flags().GetString("font-colors-json")
+ spec.FontWeight, _ = cmd.Flags().GetString("font-weight")
+ spec.FontWeightsJSON, _ = cmd.Flags().GetString("font-weights-json")
+ spec.WordWrap, _ = cmd.Flags().GetString("word-wrap")
+ spec.NumberFormat, _ = cmd.Flags().GetString("number-format")
+ return spec
+}
+
+func sheetApplyStyleSpec(spec *sheetStyleSpec, rows, cols int, params map[string]any) error {
+ if rows > 1000 || rows*cols > 30000 {
+ return apperrors.NewValidation("style range is too large")
+ }
+ if err := sheetApply2DString(spec.BgColor, spec.BgColorsJSON, rows, cols, "bg-color", "backgroundColors", nil, params); err != nil {
+ return err
+ }
+ if err := sheetApplyFontSize(spec, rows, cols, params); err != nil {
+ return err
+ }
+ if err := sheetApply2DString(spec.HAlign, spec.HAlignsJSON, rows, cols, "h-align", "horizontalAlignments", map[string]bool{"left": true, "center": true, "right": true, "general": true}, params); err != nil {
+ return err
+ }
+ if err := sheetApply2DString(spec.VAlign, spec.VAlignsJSON, rows, cols, "v-align", "verticalAlignments", map[string]bool{"top": true, "middle": true, "bottom": true}, params); err != nil {
+ return err
+ }
+ if err := sheetApply2DString(spec.FontColor, spec.FontColorsJSON, rows, cols, "font-color", "fontColors", nil, params); err != nil {
+ return err
+ }
+ if err := sheetApply2DString(spec.FontWeight, spec.FontWeightsJSON, rows, cols, "font-weight", "fontWeights", map[string]bool{"bold": true, "normal": true}, params); err != nil {
+ return err
+ }
+ if spec.WordWrap != "" {
+ if !map[string]bool{"overflow": true, "clip": true, "autoWrap": true}[spec.WordWrap] {
+ return apperrors.NewValidation("--word-wrap has invalid value")
+ }
+ params["wordWrap"] = spec.WordWrap
+ }
+ if spec.NumberFormat != "" {
+ params["numberFormat"] = spec.NumberFormat
+ }
+ for _, key := range []string{"backgroundColors", "fontSizes", "horizontalAlignments", "verticalAlignments", "fontColors", "fontWeights", "wordWrap", "numberFormat"} {
+ if _, ok := params[key]; ok {
+ return nil
+ }
+ }
+ return apperrors.NewValidation("at least one style flag is required")
+}
+
+func sheetApplyFontSize(spec *sheetStyleSpec, rows, cols int, params map[string]any) error {
+ if spec.FontSize != 0 && spec.FontSizesJSON != "" {
+ return apperrors.NewValidation("--font-size and --font-sizes-json are mutually exclusive")
+ }
+ if spec.FontSize != 0 {
+ if spec.FontSize < 0 {
+ return apperrors.NewValidation("--font-size must be positive")
+ }
+ params["fontSizes"] = sheetFillIntMatrix(rows, cols, spec.FontSize)
+ return nil
+ }
+ if spec.FontSizesJSON != "" {
+ var m [][]int
+ if err := json.Unmarshal([]byte(spec.FontSizesJSON), &m); err != nil {
+ return err
+ }
+ if !sheetMatrixIntShape(m, rows, cols) {
+ return apperrors.NewValidation("--font-sizes-json shape does not match range")
+ }
+ params["fontSizes"] = m
+ }
+ return nil
+}
+
+func sheetApply2DString(scalar, jsonStr string, rows, cols int, flagName, key string, enum map[string]bool, params map[string]any) error {
+ if scalar != "" && jsonStr != "" {
+ return apperrors.NewValidation("--" + flagName + " and --" + flagName + "s-json are mutually exclusive")
+ }
+ if scalar != "" {
+ if enum != nil && !enum[scalar] {
+ return apperrors.NewValidation("--" + flagName + " has invalid value")
+ }
+ params[key] = sheetFillStringMatrix(rows, cols, scalar)
+ return nil
+ }
+ if jsonStr == "" {
+ return nil
+ }
+ var m [][]string
+ if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
+ return err
+ }
+ if !sheetMatrixStringShape(m, rows, cols) {
+ return apperrors.NewValidation("--" + flagName + "-json shape does not match range")
+ }
+ if enum != nil {
+ for _, row := range m {
+ for _, v := range row {
+ if v != "" && !enum[v] {
+ return apperrors.NewValidation("--" + flagName + "-json has invalid value")
+ }
+ }
+ }
+ }
+ params[key] = m
+ return nil
+}
+
+func sheetParseA1Range(addr string) (rows, cols int, err error) {
+ if i := strings.Index(addr, "!"); i >= 0 {
+ addr = addr[i+1:]
+ }
+ addr = strings.TrimSpace(strings.ToUpper(addr))
+ parts := strings.SplitN(addr, ":", 2)
+ c1, r1, err := sheetParseA1Cell(parts[0])
+ if err != nil {
+ return 0, 0, err
+ }
+ c2, r2 := c1, r1
+ if len(parts) == 2 {
+ c2, r2, err = sheetParseA1Cell(parts[1])
+ if err != nil {
+ return 0, 0, err
+ }
+ }
+ if c2 < c1 {
+ c1, c2 = c2, c1
+ }
+ if r2 < r1 {
+ r1, r2 = r2, r1
+ }
+ return r2 - r1 + 1, c2 - c1 + 1, nil
+}
+
+func sheetParseA1Cell(s string) (col, row int, err error) {
+ for len(s) > 0 && s[0] >= 'A' && s[0] <= 'Z' {
+ col = col*26 + int(s[0]-'A'+1)
+ s = s[1:]
+ }
+ if col == 0 || s == "" {
+ return 0, 0, apperrors.NewValidation("invalid A1 cell")
+ }
+ row, err = strconv.Atoi(s)
+ if err != nil || row <= 0 {
+ return 0, 0, apperrors.NewValidation("invalid A1 cell")
+ }
+ return col, row, nil
+}
+
+func sheetFillStringMatrix(rows, cols int, v string) [][]string {
+ out := make([][]string, rows)
+ for i := range out {
+ out[i] = make([]string, cols)
+ for j := range out[i] {
+ out[i][j] = v
+ }
+ }
+ return out
+}
+
+func sheetFillIntMatrix(rows, cols, v int) [][]int {
+ out := make([][]int, rows)
+ for i := range out {
+ out[i] = make([]int, cols)
+ for j := range out[i] {
+ out[i][j] = v
+ }
+ }
+ return out
+}
+
+func sheetMatrixStringShape(m [][]string, rows, cols int) bool {
+ if len(m) != rows {
+ return false
+ }
+ for _, row := range m {
+ if len(row) != cols {
+ return false
+ }
+ }
+ return true
+}
+
+func sheetMatrixIntShape(m [][]int, rows, cols int) bool {
+ if len(m) != rows {
+ return false
+ }
+ for _, row := range m {
+ if len(row) != cols {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/helpers/sheet_test.go b/internal/helpers/sheet_test.go
new file mode 100644
index 00000000..9255a858
--- /dev/null
+++ b/internal/helpers/sheet_test.go
@@ -0,0 +1,217 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+)
+
+type sheetCommandRunner struct {
+ last executor.Invocation
+ calls []executor.Invocation
+}
+
+func (r *sheetCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
+ r.last = invocation
+ r.calls = append(r.calls, invocation)
+ return executor.Result{Invocation: invocation}, nil
+}
+
+func executeSheetCommand(t *testing.T, runner *sheetCommandRunner, args ...string) {
+ t.Helper()
+ cmd := sheetHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+}
+
+func TestSheetCreateCallsCreateWorkspaceSheet(t *testing.T) {
+ t.Parallel()
+
+ runner := &sheetCommandRunner{}
+ executeSheetCommand(t, runner, "create", "--name", "销售数据", "--folder", "FOLDER_001", "--workspace", "WS_001")
+
+ if runner.last.Tool != "create_workspace_sheet" {
+ t.Fatalf("tool = %q, want create_workspace_sheet", runner.last.Tool)
+ }
+ if got := runner.last.Params["name"]; got != "销售数据" {
+ t.Fatalf("name = %#v", got)
+ }
+ if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
+ t.Fatalf("folderId = %#v", got)
+ }
+ if got := runner.last.Params["workspaceId"]; got != "WS_001" {
+ t.Fatalf("workspaceId = %#v", got)
+ }
+}
+
+func TestSheetRangeUpdateCallsSetCellRange(t *testing.T) {
+ t.Parallel()
+
+ runner := &sheetCommandRunner{}
+ executeSheetCommand(t, runner,
+ "range", "update",
+ "--node", "NODE_001",
+ "--sheet-id", "SHEET_001",
+ "--range", "A1:B1",
+ "--values", `[[{"type":"text","text":"姓名"},{"type":"text","text":"分数"}]]`,
+ )
+
+ if runner.last.Tool != "set_cell_range" {
+ t.Fatalf("tool = %q, want set_cell_range", runner.last.Tool)
+ }
+ if got := runner.last.Params["rangeAddress"]; got != "A1:B1" {
+ t.Fatalf("rangeAddress = %#v", got)
+ }
+ cells, ok := runner.last.Params["cells"].([][]any)
+ if !ok || len(cells) != 1 || len(cells[0]) != 2 {
+ t.Fatalf("cells = %#v", runner.last.Params["cells"])
+ }
+}
+
+func TestSheetRangeSetStyleExpandsScalarStyle(t *testing.T) {
+ t.Parallel()
+
+ runner := &sheetCommandRunner{}
+ executeSheetCommand(t, runner,
+ "range", "set-style",
+ "--node", "NODE_001",
+ "--sheet-id", "SHEET_001",
+ "--range", "A1:B2",
+ "--bg-color", "#FFF2CC",
+ "--font-weight", "bold",
+ )
+
+ if runner.last.Tool != "update_range" {
+ t.Fatalf("tool = %q, want update_range", runner.last.Tool)
+ }
+ bg, ok := runner.last.Params["backgroundColors"].([][]string)
+ if !ok || len(bg) != 2 || len(bg[0]) != 2 || bg[1][1] != "#FFF2CC" {
+ t.Fatalf("backgroundColors = %#v", runner.last.Params["backgroundColors"])
+ }
+ weights, ok := runner.last.Params["fontWeights"].([][]string)
+ if !ok || weights[0][0] != "bold" {
+ t.Fatalf("fontWeights = %#v", runner.last.Params["fontWeights"])
+ }
+}
+
+func TestSheetFilterViewUpdateCriteriaCallsSetCriteria(t *testing.T) {
+ t.Parallel()
+
+ runner := &sheetCommandRunner{}
+ executeSheetCommand(t, runner,
+ "filter-view", "update-criteria",
+ "--node", "NODE_001",
+ "--sheet-id", "SHEET_001",
+ "--filter-view-id", "FV_001",
+ "--column", "2",
+ "--filter-criteria", `{"filterType":"values","visibleValues":["销售部"]}`,
+ )
+
+ if runner.last.Tool != "set_filter_view_criteria" {
+ t.Fatalf("tool = %q, want set_filter_view_criteria", runner.last.Tool)
+ }
+ if got := runner.last.Params["filterViewId"]; got != "FV_001" {
+ t.Fatalf("filterViewId = %#v", got)
+ }
+ if got := runner.last.Params["column"]; got != 2 {
+ t.Fatalf("column = %#v", got)
+ }
+}
+
+func TestSheetCondFormatCreateExpandsCondition(t *testing.T) {
+ t.Parallel()
+
+ runner := &sheetCommandRunner{}
+ executeSheetCommand(t, runner,
+ "cond-format", "create",
+ "--node", "NODE_001",
+ "--sheet-id", "SHEET_001",
+ "--ranges", `["A1:A10"]`,
+ "--condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`,
+ "--cell-style", `{"backgroundColor":"#FFCDD2"}`,
+ )
+
+ if runner.last.Tool != "create_cond_format" {
+ t.Fatalf("tool = %q, want create_cond_format", runner.last.Tool)
+ }
+ if _, ok := runner.last.Params["numberCondition"]; !ok {
+ t.Fatalf("numberCondition missing from %#v", runner.last.Params)
+ }
+ if _, ok := runner.last.Params["cellStyle"]; !ok {
+ t.Fatalf("cellStyle missing from %#v", runner.last.Params)
+ }
+}
+
+func TestSheetCSVCommandsAcceptFileIDAlias(t *testing.T) {
+ t.Parallel()
+
+ runner := &sheetCommandRunner{}
+ executeSheetCommand(t, runner,
+ "csv-get",
+ "--file-id", "NODE_001",
+ "--sheet-id", "SHEET_001",
+ "--range", "A1:B2",
+ )
+
+ if runner.last.Tool != "get_range_as_csv" {
+ t.Fatalf("tool = %q, want get_range_as_csv", runner.last.Tool)
+ }
+ if got := runner.last.Params["nodeId"]; got != "NODE_001" {
+ t.Fatalf("csv-get nodeId = %#v", got)
+ }
+
+ executeSheetCommand(t, runner,
+ "csv-put",
+ "--file-id", "NODE_002",
+ "--sheet-id", "SHEET_002",
+ "--csv", "a,b\n1,2",
+ "--start-cell", "A1",
+ )
+
+ if runner.last.Tool != "set_range_from_csv" {
+ t.Fatalf("tool = %q, want set_range_from_csv", runner.last.Tool)
+ }
+ if got := runner.last.Params["nodeId"]; got != "NODE_002" {
+ t.Fatalf("csv-put nodeId = %#v", got)
+ }
+}
+
+func TestSheetReplaceAllowsEmptyReplacement(t *testing.T) {
+ t.Parallel()
+
+ runner := &sheetCommandRunner{}
+ executeSheetCommand(t, runner,
+ "replace",
+ "--node", "NODE_001",
+ "--sheet-id", "SHEET_001",
+ "--find", "临时",
+ "--replacement", "",
+ )
+
+ if runner.last.Tool != "replace_all" {
+ t.Fatalf("tool = %q, want replace_all", runner.last.Tool)
+ }
+ if got := runner.last.Params["replaceText"]; got != "" {
+ t.Fatalf("replaceText = %#v, want empty string", got)
+ }
+}
diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go
index 05811223..692fe3bc 100644
--- a/internal/helpers/wiki.go
+++ b/internal/helpers/wiki.go
@@ -14,6 +14,8 @@
package helpers
import (
+ "fmt"
+ "strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
@@ -64,6 +66,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
newWikiSpaceGetCommand(runner),
newWikiSpaceListCommand(runner),
newWikiSpaceSearchCommand(runner),
+ newWikiSpaceDeleteCommand(runner),
)
root.AddCommand(space)
@@ -81,6 +84,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
newWikiMemberAddCommand(runner),
newWikiMemberUpdateCommand(runner),
newWikiMemberListCommand(runner),
+ newWikiMemberRemoveCommand(runner),
)
root.AddCommand(member)
return root
@@ -150,6 +154,20 @@ func newWikiSpaceListCommand(runner executor.Runner) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{}
if spaceType := wikiFlagOrFallback(cmd, "type"); spaceType != "" {
+ if spaceType == "orgSpace" || spaceType == "mySpace" {
+ driveParams := map[string]any{"spaceType": spaceType}
+ if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
+ if n, err := strconv.Atoi(limit); err == nil {
+ driveParams["maxResults"] = n
+ } else {
+ driveParams["maxResults"] = limit
+ }
+ }
+ if pageToken := wikiFlagOrFallback(cmd, "cursor", "page-token"); pageToken != "" {
+ driveParams["nextToken"] = pageToken
+ }
+ return runWikiProductTool(cmd, runner, "drive", "list_spaces", driveParams)
+ }
params["wikiSpaceType"] = spaceType
}
if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
@@ -211,6 +229,32 @@ func newWikiSpaceSearchCommand(runner executor.Runner) *cobra.Command {
return cmd
}
+func newWikiSpaceDeleteCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "删除知识库",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
+ if err != nil {
+ return err
+ }
+ if !confirmDeletePrompt(cmd, "知识库", workspaceID) {
+ return nil
+ }
+ return runWikiTool(cmd, runner, "delete_wikiSpace", map[string]any{
+ "workspaceId": workspaceID,
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
+ addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
+ return cmd
+}
+
func newWikiMemberAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add",
@@ -313,10 +357,233 @@ func newWikiMemberListCommand(runner executor.Runner) *cobra.Command {
return cmd
}
+func newWikiMemberRemoveCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "remove",
+ Aliases: []string{"rm"},
+ Short: "移除知识库成员",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
+ if err != nil {
+ return err
+ }
+ user, err := wikiRequiredFlagOrFallback(cmd, "users", "user", "uid")
+ if err != nil {
+ return err
+ }
+ return runWikiTool(cmd, runner, "remove_member", map[string]any{
+ "workspaceId": workspaceID,
+ "userIds": wikiCSV(user),
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addWikiMemberListWorkspaceFlag(cmd)
+ cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
+ addWikiHiddenStringFlag(cmd, "user", "--users 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
+ return cmd
+}
+
+func newWikiNodeListCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Aliases: []string{"ls"},
+ Short: "列出知识库节点",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{"workspaceId": workspaceID}
+ if folder := wikiFlagOrFallback(cmd, "folder", "node", "parent-id"); folder != "" {
+ params["folderId"] = normalizeDocNodeID(folder)
+ }
+ if limit := wikiIntFlagOrFallback(cmd, "limit", "page-size"); limit > 0 {
+ params["pageSize"] = limit
+ }
+ if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
+ params["pageToken"] = cursor
+ }
+ return runWikiProductTool(cmd, runner, "doc", "list_nodes", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addWikiNodeWorkspaceFlag(cmd)
+ cmd.Flags().String("folder", "", "父节点 nodeId")
+ addWikiHiddenStringFlag(cmd, "node", "--folder 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
+ cmd.Flags().Int("limit", 0, "每页数量")
+ cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
+ _ = cmd.Flags().MarkHidden("page-size")
+ cmd.Flags().String("cursor", "", "分页游标")
+ addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
+ return cmd
+}
+
+func newWikiNodeCreateCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "在知识库中创建节点",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
+ if err != nil {
+ return err
+ }
+ name, err := wikiRequiredFlag(cmd, "name")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "workspaceId": workspaceID,
+ "name": name,
+ }
+ if nodeType := wikiFlagOrFallback(cmd, "type"); nodeType != "" {
+ params["type"] = nodeType
+ }
+ if folder := wikiFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
+ params["folderId"] = normalizeDocNodeID(folder)
+ }
+ return runWikiProductTool(cmd, runner, "doc", "create_file", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addWikiNodeWorkspaceFlag(cmd)
+ cmd.Flags().String("name", "", "节点名称 (必填)")
+ cmd.Flags().String("type", "adoc", "节点类型: adoc / asheet / folder / axls")
+ cmd.Flags().String("folder", "", "父节点 nodeId")
+ addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
+ return cmd
+}
+
+func newWikiNodeCopyCommand(runner executor.Runner) *cobra.Command {
+ return newWikiNodeTransferCommand(runner, "copy", "copy_document")
+}
+
+func newWikiNodeMoveCommand(runner executor.Runner) *cobra.Command {
+ return newWikiNodeTransferCommand(runner, "move", "move_document")
+}
+
+func newWikiNodeTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: use,
+ Short: use + " 知识库节点",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
+ if err != nil {
+ return err
+ }
+ nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "nodeId": normalizeDocNodeID(nodeID),
+ "workspaceId": workspaceID,
+ }
+ if folder := wikiFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
+ params["targetFolderId"] = normalizeDocNodeID(folder)
+ }
+ return runWikiProductTool(cmd, runner, "doc", tool, params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addWikiNodeWorkspaceFlag(cmd)
+ addWikiNodeIDFlags(cmd)
+ cmd.Flags().String("folder", "", "目标文件夹 nodeId")
+ addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
+ return cmd
+}
+
+func newWikiNodeDeleteCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "删除知识库节点",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if _, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId"); err != nil {
+ return err
+ }
+ nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
+ if err != nil {
+ return err
+ }
+ if !confirmDeletePrompt(cmd, "知识库节点", nodeID) {
+ return nil
+ }
+ return runWikiProductTool(cmd, runner, "doc", "delete_document", map[string]any{
+ "nodeId": normalizeDocNodeID(nodeID),
+ })
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addWikiNodeWorkspaceFlag(cmd)
+ addWikiNodeIDFlags(cmd)
+ cmd.Flags().BoolP("yes", "y", false, "跳过确认直接删除")
+ return cmd
+}
+
+func newWikiNodeSearchCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "search",
+ Short: "在知识库中搜索节点",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
+ if err != nil {
+ return err
+ }
+ query := wikiFlagOrFallback(cmd, "query", "keyword")
+ if query == "" {
+ return apperrors.NewValidation("--query is required")
+ }
+ params := map[string]any{
+ "keyword": query,
+ "workspaceIds": []string{workspaceID},
+ }
+ if values, _ := cmd.Flags().GetStringSlice("extensions"); len(values) > 0 {
+ params["extensions"] = splitWikiStringSlice(values)
+ }
+ if limit := wikiIntFlagOrFallback(cmd, "limit"); limit > 0 {
+ params["pageSize"] = limit
+ }
+ if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
+ params["pageToken"] = cursor
+ }
+ return runWikiProductTool(cmd, runner, "doc", "search_documents", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ addWikiNodeWorkspaceFlag(cmd)
+ cmd.Flags().String("query", "", "搜索关键词 (必填)")
+ addWikiHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
+ cmd.Flags().StringSlice("extensions", nil, "按扩展名过滤,逗号分隔")
+ cmd.Flags().Int("limit", 0, "每页数量")
+ cmd.Flags().String("cursor", "", "分页游标")
+ addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
+ return cmd
+}
+
func runWikiTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
+ return runWikiProductTool(cmd, runner, "wiki", tool, params)
+}
+
+func runWikiProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
- "wiki",
+ product,
tool,
params,
)
@@ -344,6 +611,19 @@ func addWikiMemberListWorkspaceFlag(cmd *cobra.Command) {
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
}
+func addWikiNodeWorkspaceFlag(cmd *cobra.Command) {
+ cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
+ addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
+}
+
+func addWikiNodeIDFlags(cmd *cobra.Command) {
+ cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
+ addWikiHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
+ addWikiHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
+}
+
func addWikiHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
@@ -397,3 +677,19 @@ func wikiCSV(raw string) []string {
}
return values
}
+
+func splitWikiStringSlice(values []string) []string {
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ for _, part := range strings.Split(value, ",") {
+ if item := strings.TrimSpace(part); item != "" {
+ out = append(out, item)
+ }
+ }
+ }
+ return out
+}
+
+func wikiUnsupportedCommand(name string) error {
+ return fmt.Errorf("unsupported wiki command: %s", name)
+}
diff --git a/internal/helpers/wiki_proxy.go b/internal/helpers/wiki_proxy.go
index 11dd187a..f7811610 100644
--- a/internal/helpers/wiki_proxy.go
+++ b/internal/helpers/wiki_proxy.go
@@ -38,15 +38,19 @@ func addWikiProxyCommands(root *cobra.Command, runner executor.Runner) {
newWikiProxyLeaf(runner, "search", wikiProxyTargetSpace, []string{"space", "search"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "create", wikiProxyTargetSpace, []string{"space", "create"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "get", wikiProxyTargetSpace, []string{"space", "get"}, wikiProxyOptions{}),
+ newWikiProxyLeaf(runner, "delete", wikiProxyTargetSpace, []string{"space", "delete"}, wikiProxyOptions{}),
)
node := newWikiProxyGroup("node", "知识库节点兼容入口")
node.AddCommand(
- newWikiProxyLeaf(runner, "list", wikiProxyTargetDoc, []string{"list"}, wikiProxyOptions{}),
+ newWikiNodeListCommand(runner),
newWikiProxyLeaf(runner, "read", wikiProxyTargetDoc, []string{"read"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "info", wikiProxyTargetDoc, []string{"info"}, wikiProxyOptions{}),
- newWikiProxyLeaf(runner, "create", wikiProxyTargetDoc, []string{"create"}, wikiProxyOptions{}),
- newWikiProxyLeaf(runner, "search", wikiProxyTargetDoc, []string{"search"}, wikiProxyOptions{workspaceToWorkspaceIDs: true}),
+ newWikiNodeCreateCommand(runner),
+ newWikiNodeCopyCommand(runner),
+ newWikiNodeMoveCommand(runner),
+ newWikiNodeDeleteCommand(runner),
+ newWikiNodeSearchCommand(runner),
)
file := newWikiProxyGroup("file", "知识库文件兼容入口")
@@ -164,6 +168,7 @@ func newWikiProxySpaceTargetRoot(runner executor.Runner) *cobra.Command {
newWikiSpaceGetCommand(runner),
newWikiSpaceListCommand(runner),
newWikiSpaceSearchCommand(runner),
+ newWikiSpaceDeleteCommand(runner),
)
root.AddCommand(space)
return root
diff --git a/internal/helpers/wiki_test.go b/internal/helpers/wiki_test.go
index ad7a3c48..b7cc2502 100644
--- a/internal/helpers/wiki_test.go
+++ b/internal/helpers/wiki_test.go
@@ -16,6 +16,7 @@ package helpers
import (
"bytes"
"context"
+ "reflect"
"strconv"
"strings"
"testing"
@@ -259,6 +260,94 @@ func TestWikiMemberAddUsesWorkspaceIDAlias(t *testing.T) {
}
}
+func TestWikiSpaceListDriveTypesRouteToDrive(t *testing.T) {
+ t.Parallel()
+
+ runner := &wikiCommandRunner{}
+ cmd := wikiHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"space", "list", "--type", "orgSpace", "--limit", "10"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if runner.last.CanonicalProduct != "drive" {
+ t.Fatalf("product = %q, want drive", runner.last.CanonicalProduct)
+ }
+ if runner.last.Tool != "list_spaces" {
+ t.Fatalf("tool = %q, want list_spaces", runner.last.Tool)
+ }
+ if got := runner.last.Params["spaceType"]; got != "orgSpace" {
+ t.Fatalf("spaceType = %#v, want orgSpace", got)
+ }
+ if got := runner.last.Params["maxResults"]; got != 10 {
+ t.Fatalf("maxResults = %#v, want 10", got)
+ }
+}
+
+func TestWikiNodeSearchRoutesToDoc(t *testing.T) {
+ t.Parallel()
+
+ runner := &wikiCommandRunner{}
+ cmd := wikiHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{
+ "node", "search",
+ "--workspace-id", "WS_001",
+ "--keyword", "方案",
+ "--extensions", "adoc,asheet",
+ "--limit", "5",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "search_documents" {
+ t.Fatalf("invocation = %#v, want doc search_documents", runner.last)
+ }
+ if got := runner.last.Params["keyword"]; got != "方案" {
+ t.Fatalf("keyword = %#v, want 方案", got)
+ }
+ if got := runner.last.Params["workspaceIds"]; !reflect.DeepEqual(got, []string{"WS_001"}) {
+ t.Fatalf("workspaceIds = %#v, want []string{WS_001}", got)
+ }
+ if got := runner.last.Params["extensions"]; !reflect.DeepEqual(got, []string{"adoc", "asheet"}) {
+ t.Fatalf("extensions = %#v, want adoc/asheet", got)
+ }
+ if got := runner.last.Params["pageSize"]; got != 5 {
+ t.Fatalf("pageSize = %#v, want 5", got)
+ }
+}
+
+func TestWikiMemberRemoveRoutesToWiki(t *testing.T) {
+ t.Parallel()
+
+ runner := &wikiCommandRunner{}
+ cmd := wikiHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"member", "remove", "--workspace-id", "WS_001", "--user", "uid1,uid2"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if runner.last.CanonicalProduct != "wiki" || runner.last.Tool != "remove_member" {
+ t.Fatalf("invocation = %#v, want wiki remove_member", runner.last)
+ }
+ if got := runner.last.Params["workspaceId"]; got != "WS_001" {
+ t.Fatalf("workspaceId = %#v, want WS_001", got)
+ }
+ users, ok := runner.last.Params["userIds"].([]string)
+ if !ok || strings.Join(users, ",") != "uid1,uid2" {
+ t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
+ }
+}
+
func TestWikiMemberUpdateAcceptsWukongUsersAlias(t *testing.T) {
t.Parallel()
diff --git a/skills/mono/references/products/aitable.md b/skills/mono/references/products/aitable.md
index 37361df5..f49cfb0c 100644
--- a/skills/mono/references/products/aitable.md
+++ b/skills/mono/references/products/aitable.md
@@ -7,9 +7,15 @@
| 资源 | URI 格式 |
|------|----------|
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
+| 指定数据表 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}` |
+| 指定数据表+视图 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}` |
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
-> **操作后请返回文档 URI**:每次执行 base list/search/create/get 操作后,从返回数据中提取 `baseId`,拼接为 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
+> **操作后请返回文档 URI**:返回链接时必须带上当前操作的数据表 tableId,让用户点击后直接看到目标数据表,而不是落在空白的默认表。
+> - 已知 tableId + viewId 时(view create 返回、view get 中提取):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}`
+> - 已知 tableId 时(table create 返回、base get 中提取、record 操作所用的 tableId):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}`
+> - 仅有 baseId、无明确 tableId 时(如 base list/search):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}`
+>
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
## 命令索引表
@@ -19,10 +25,9 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
-| `base search` | 搜索 Base;不传关键词时列出最近 Base | — | 可选 `--query`;不传时走 list_bases |
+| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
-| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
-| `base copy` | 复制 Base 到目标文件夹 | `--base-id` `--target-folder-id` | 目标必须是 `dws doc folder create/list` 返回的文档文件夹 `nodeId`;不要传钉盘数字 `dentryId`,也不要用手工新建 base/table 代替 |
+| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId;**默认新建的 base 自带一个空白「数据表」(含 3 行空记录)和一个空白仪表盘**,如需干净的空 base,传 `--template-id 1743` |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
@@ -31,8 +36,8 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
-| `table create` | 创建数据表 | `--base-id` `--name` | `--fields` 可选;不传时创建空字段表 |
-| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
+| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
+| `table update` | 修改表名 / 备注 / 行命名规则 | `--base-id` `--table-id` + 三选一(`--name` / `--description` / `--record-name-key`) | `--record-name-key` 是固定枚举(如 task/project/event/customer/ji_lu 等),非字段 ID |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
@@ -44,6 +49,30 @@
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
+#### 搜索字段选项
+```
+Usage:
+ dws aitable field search-options [flags]
+Example:
+ dws aitable field search-options --base-id --table-id --field-id
+ dws aitable field search-options --base-id --table-id --field-id --keyword 已完成
+ dws aitable field search-options --base-id --table-id --field-id --limit 100
+Flags:
+ --base-id string Base ID (必填)
+ --field-id string 目标字段 ID,必须是 singleSelect / multipleSelect 类型 (必填)
+ --keyword string 模糊搜索关键词,大小写不敏感、contains 匹配 option name;不传返回全部
+ --limit int 返回的最大 option 数量,默认 3000(全量),最大 3000
+ --table-id string Table ID (必填)
+```
+
+仅适用于 **singleSelect / multipleSelect** 字段。其他类型(text/number/date/...)调用会返回错误。
+
+适用场景:
+- options 较多,只想要含某关键词的子集(避免 `field get` 拉取整个字段配置带回所有 options)。
+- 写入 record 前预览选项 id ↔ name 的映射,确认要使用的选项确实存在。
+
+> **写 record 时**:`record create / update` 对 singleSelect/multipleSelect 可直接传 option **name**,不需要用本命令。本命令主要用于 **filter** 写法(filters 优先用 option **id**)或选项较多需要精确定位时。
+
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
@@ -51,36 +80,66 @@
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
-| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组;同一组值批量更新也用此命令展开 records |
+| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组 |
+| `record batch-update` | 批量更新(同一 cells 应用到多条 recordId) | [aitable-record-update.md](./aitable/aitable-record-update.md)、[aitable-cell-value.md](./aitable/aitable-cell-value.md) | 适合"统一标记完成/统一改负责人"等共享 patch 场景;`--cells` 是 JSON object(key=fieldId,value 按字段类型见 cell-value.md),与 record update 的单条 cells 结构完全一致;必填 `--record-ids` `--cells`;单次最多 100 条 |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
+| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | 必填 `--record-id`;分页 `--offset --limit`,limit 范围 [1,50] 默认 20 |
+| `record query-empty` | 查询完全没填用户字段的空行 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 一页扫描 `--limit` [1,100] 默认 100;扫完前需用 `--cursor` 翻页(nextCursor 为空才表扫完) |
+| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | 必填 `--record-ids`(CSV,单次最多 20 条);可选 `--view-id` 带视图上下文 |
+| `record upsert` | 批量创建或更新(按 recordId 是否存在自动拆分) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | --records 同 record update 格式;带 recordId 走 update,不带走 create;单次最多 100 |
+| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 返回的 nodeId 可直接用于 `dws doc read/update --node` |
+| `record primary-doc-create` | 为记录创建主键文档(幂等) | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | fieldId 必须是 primaryDoc 类型;已存在则返回已有 nodeId |
### view (视图管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
-| `view get` | 获取视图配置 | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
-| `view list` | 列出全部视图(`view get` 不传 `--view-ids` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价;只需视图列表时优先 |
-| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;可选 `--name` 指定视图名称(未传时自动生成)、`--config` 传初始配置 JSON |
-| `view update` | 更新视图(**调整字段顺序的入口**) | `--base-id` `--table-id` `--view-id` | `visibleFieldIds` 重排字段顺序 |
+| `view get` | 获取视图配置(不传子命令) | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
+| `view get ` | 获取视图某个属性 | `--view-id` | 12 个:card/timebar/aggregate/filter/sort/group/visible-fields/field-widths(详见 [aitable-view-config.md](./aitable/aitable-view-config.md))+ lock/frozen-cols/row-height/fill-color-rule(详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)) |
+| `view list` | 列出全部视图(`view get` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价 |
+| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;**Gantt 创建后必须 `view update timebar` 绑定日期字段** |
+| `view update` | 整体更新视图 / 多属性合并更新 | `--base-id` `--table-id` `--view-id` | 可传 `--name --desc --config '{...}'`,**`--config` 路径继续保留** |
+| `view update ` | 按属性局部更新(推荐)| `--view-id` + typed flag / `--json` | 12 个:card/timebar/aggregate/field-widths/visible-fields/filter/sort/group/name + frozen-cols/row-height/fill-color-rule |
+| `view lock [--off]` | 锁定/解锁视图 | `--base-id` `--table-id` `--view-id` | 默认锁定;`--off` 解锁。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
+| `view duplicate` | 复制视图 | `--base-id` `--table-id` `--view-id` | 可选 `--new-name`;保留源视图全部配置。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
-> **"移动字段/调整字段顺序"** 在 AI 表格里没有 `field reorder` 命令,必须通过 `view update --config '{"visibleFieldIds":[...]}'` 完成。
+> **优先用 `view get ` / `view update ` 子命令**:每个属性独立命令,typed flag 友好,agent 不必拼 JSON。**`view update --config '{...}'` 仍可用**,适合一次性多属性更新或脚本场景。
-> **view update --config 支持的 key 白名单**(传入其他 key 会报错):
-> - `visibleFieldIds` — 视图可见字段列表及顺序(首列字段必须保留在第一位)
-> - `filter` — 筛选规则**数组**(⚠️ 注意是数组 `[...]`,不是对象 `{...}`)
-> - `sort` — 排序规则**数组**
-> - `group` — 分组规则**数组**
-> - `fieldWidths` — 列宽映射(仅 Grid 视图有效)
->
-> **filter/sort/group 必须传数组格式**,不要和 `record query --filters`(对象格式)混淆。详见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md) § view update 章节。
-> CLI 会自动容错(对象→数组 wrap),但建议直接使用正确格式。
->
-> 不支持 `formInfo`、`requiredFields`、`conditionalRules` 等 FormDesigner 高级配置,这些 key 会被服务端忽略。
+> **属性按 attr 分类,决定该读哪份子文档**:
+> - card / timebar / aggregate / filter / sort / group / visible-fields / field-widths → [aitable-view-config.md](./aitable/aitable-view-config.md)
+> - lock / frozen-cols / row-height / fill-color-rule / duplicate → [aitable-view-extras.md](./aitable/aitable-view-extras.md)
+> 后一类**不能**塞进 `view update --config '{...}'`,必须用各自专属子命令;如果错传 `flags` / `frozenColCount` / `cellHeight` / `conditionalFormats` 等 key 进 `--config`,CLI 会在 stderr 提示应改用的命令。
-### 表单视图 → 详见 [aitable-form.md](./aitable/aitable-form.md)
+> **`view update --config` 支持的 9 个 key**:
+> `visibleFieldIds` / `filter` / `sort` / `group` / `fieldWidths`(Grid) / `aggregate`(Grid) / `kanbanCard`(Kanban) / `ganttTimebar`(Gantt) / `galleryCard`(Gallery)。
+> filter/sort/group 必须传**数组**格式(与 `record query --filters` 的对象格式不同;CLI 会自动容错)。其他 key 会被服务端忽略并打 warning。
-悟空命令面不暴露 `form` 命令组;表单按 `viewType=FormDesigner` 的视图处理,创建/查看/更新/删除都使用 `view` 命令。
+### form (表单管理) → 详见 [aitable-form.md](./aitable/aitable-form.md)
+
+| 命令 | 用途 | 必填参数 | 路由提醒 |
+|------|------|----------|----------|
+| `form list` | 列出表单视图 | `--base-id` `--table-id` | 详情见 [aitable-form.md](./aitable/aitable-form.md) |
+| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | — |
+| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | — |
+| `form update` | 更新表单配置 | `--base-id` `--table-id` `--view-id` | title/name/description 至少一项 |
+| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` | 不可逆 |
+| `form field list/update/hide` | 表单字段管理 | — | 详情见子文档 |
+| `form questions create/delete` | 题目管理(=field create/delete) | — | 详情见子文档 |
+| `form share get/update` | 表单分享配置 | — | 详情见子文档 |
+
+> **创建表单**有两种等价方式:`form create --name "..."`(推荐)或 `view create --view-type FormDesigner --name "..."`。
+
+### workflow (自动化工作流) → 详见 [aitable-workflow.md](./aitable/aitable-workflow.md)
+
+| 命令 | 用途 | 必填参数 | 路由提醒 |
+|------|------|----------|----------|
+| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
+| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
+| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
+| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
+
+> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
@@ -88,6 +147,7 @@
|------|------|
| `dashboard get/create/update/delete` | 仪表盘管理 |
| `dashboard config-example` | 查看仪表盘配置模板 |
+| `dashboard arrange` | 自动重排仪表盘图表布局(智能填满网格,避免空缺) |
| `chart get/create/update/delete` | 图表管理 |
| `chart widgets-example` | 查看图表 widgets 配置模板 |
@@ -111,15 +171,162 @@
|------|------|----------|
| `template search` | 搜索模板 | `--query` |
-## 评测执行硬约束
+### advperm (高级权限/自定义角色) → 详见 [aitable-advperm.md](./aitable/aitable-advperm.md)
+
+| 命令 | 用途 | 必填参数 | 路由提醒 |
+|------|------|----------|----------|
+| `advperm enable` | 开启 Base 高级权限总开关 | `--base-id` | 不开启时角色规则不生效 |
+| `advperm disable` | 关闭 Base 高级权限总开关(高危) | `--base-id` `--yes` | 关闭后全员回退默认权限 |
+| `advperm role-list` | 列出 Base 下所有角色 | `--base-id` | 同时返回自定义角色和系统角色;`roleType == "custom"` 是自定义,前缀 `system_` 是系统角色 |
+| `advperm role-get` | 获取单角色完整配置 | `--base-id` `--role-id` | 含 subRoles 与字段/行级规则 |
+| `advperm role-create` | 创建自定义角色 | `--base-id` `--name` | 可选 `--sub-roles` 同时指定子角色权限规则 |
+| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 |
+| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR |
+
+> **角色 CRUD 已全支持**:create/get/list/update/delete 都可走 CLI。
+> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。
+> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。
+
+### section (文件夹与节点管理)
+
+> 用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。
+> 操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
+
+#### 创建文件夹
+```
+Usage:
+ dws aitable section create [flags]
+Example:
+ dws aitable section create --base-id --name 我的文件夹
+ dws aitable section create --base-id --name 子文件夹 --parent-section-id --index 0
+Flags:
+ --base-id string Base ID (必填)
+ --name string 文件夹名称 (必填)
+ --parent-section-id string 父文件夹 ID;不传或空字符串表示创建在 Base 根目录下
+ --index int 在父文件夹下的目标位置(0-based);不传则追加到末尾
+```
+
+返回 `data.sectionId` 与 `data.name`。
+
+#### 重命名文件夹
+```
+Usage:
+ dws aitable section rename [flags]
+Example:
+ dws aitable section rename --base-id --section-id --new-name 新名称
+Flags:
+ --base-id string Base ID (必填)
+ --section-id string 目标文件夹 ID (必填)
+ --new-name string 新的文件夹名称 (必填)
+```
+
+#### 删除文件夹
+```
+Usage:
+ dws aitable section delete [flags]
+Example:
+ dws aitable section delete --base-id --section-id
+Flags:
+ --base-id string Base ID (必填)
+ --section-id string 目标文件夹 ID (必填)
+```
+
+> **注意**:删除不可逆;删除前可先用 `section list-empty` 确认是否为空文件夹。
+
+#### 调整文件夹顺序
+```
+Usage:
+ dws aitable section reorder [flags]
+Example:
+ dws aitable section reorder --base-id --section-id --target-index 0
+Flags:
+ --base-id string Base ID (必填)
+ --section-id string 目标文件夹 ID (必填)
+ --target-index int 目标位置(0-based)(必填)
+```
-- 多轮任务必须执行到用户要求的最后一步;不要只回复"现在开始/下一步执行",也不要在创建 base/table/field 后提前结束。
-- 每个写操作后用 `base get`、`table get`、`field get`、`record query` 或对应 `view get/list` 读回验证真实 ID 与结果。
-- 字段批量 JSON 推荐 `fieldName`;CLI 兼容 `name`,但 skill 生成时不要主动使用 `name`。字段类型统一用小写/规范值,如 `text`、`number`、`singleSelect`、`attachment`。
-- 成员/负责人字段类型使用 `user`,不要生成 `member`。
-- 复制 AI 表格必须调用 `dws aitable base copy --base-id --target-folder-id --format json`。目标目录必须是 `dws doc folder create` 或 `dws doc list` 返回的文档文件夹 `nodeId`;不要传 `drive list` 返回的数字 `dentryId`,不要用新建 base/table 的手工方式代替 `base copy`。
-- 用户未指定目标文件夹时:先 `dws doc info --node --format json` 取 `workspaceId`,再 `dws doc folder create --workspace --name "AI表格副本" --format json` 创建目标文件夹,最后把返回的 `nodeId` 传给 `base copy`。
-- 导入 Excel/CSV 前先用 `find` 或 `ls` 确认真实文件路径;遇到中文文件名乱码或路径不匹配时,重新查找实际文件,不要停在解释阶段。
+> 在**当前父文件夹下**调整展示顺序。跨父级移动请用 `section move-node`。
+
+#### 列出空文件夹
+```
+Usage:
+ dws aitable section list-empty [flags]
+Example:
+ dws aitable section list-empty --base-id
+Flags:
+ --base-id string Base ID (必填)
+```
+
+返回 `data.items: [{sectionId, name, parentSectionId}]` 与 `data.total`,用于清理或诊断导航树(parentSectionId 为空串表示在根目录下)。
+
+#### 列出全部节点
+```
+Usage:
+ dws aitable section list-nodes [flags]
+Example:
+ dws aitable section list-nodes --base-id
+Flags:
+ --base-id string Base ID (必填)
+```
+
+返回 `data.items: [{nodeId, nodeType, parentSectionId, name?}]` 与 `data.total`,涵盖文件夹 / AI 表格 / 表单视图 / 仪表盘 / 文档 / 查询视图。
+
+> **与其他命令的关联**:是 `section move-node` / `section reorder` 的前置定位命令——先用它拿到 nodeId 与 parentSectionId。
+
+#### 移动节点
+```
+Usage:
+ dws aitable section move-node [flags]
+Example:
+ dws aitable section move-node --base-id --node-id --new-parent-section-id
+ dws aitable section move-node --base-id --node-id --new-parent-section-id "" --target-index 0
+Flags:
+ --base-id string Base ID (必填)
+ --node-id string 要移动的节点 ID(文件夹/AI表格/表单视图/仪表盘/文档/查询视图)(必填)
+ --new-parent-section-id string 目标父文件夹 ID;空字符串表示移到 Base 根目录 (必填)
+ --target-index int Base 内节点的全局位置(0-based);不传则不调整
+```
+
+> 服务端自动识别节点类型,无需区分文件夹与非文件夹。返回 `data.nodeId / newParentSectionId / nodeType`。
+> 对文件夹节点带 `--target-index` 时会先 move 再 reorder,中间失败会返回 `MOVE_OK_REORDER_FAILED`,可用 `section reorder` 重试。
+
+## 复杂操作
+
+### 仪表盘 / 图表(建议顺序)
+
+```bash
+# 1) 先看配置模板(JSONC)
+dws aitable dashboard config-example --format json
+dws aitable chart widgets-example --format json
+
+# 2) 先拿 dashboard,再拿 chart 详情
+dws aitable dashboard get --base-id --dashboard-id --format json
+dws aitable chart get --base-id --dashboard-id --chart-id --format json
+```
+
+要点:
+
+- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
+- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
+- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
+
+### 导出数据(两阶段轮询)
+
+`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
+
+```bash
+# 第一步:创建任务(按 scope 传必要参数)
+dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000
+
+# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
+dws aitable export data --base-id --task-id --timeout-ms 3000
+```
+
+参数约束
+
+- `scope=all`:只需 `base-id`
+- `scope=table`:必须 `table-id`
+- `scope=view`:必须同时 `table-id + view-id`
## 意图判断
@@ -127,14 +334,14 @@
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 详情 → `base get`
- 创建 → `base create`
-- 复制 → `base copy`,必须调用 `dws aitable base copy --base-id --target-folder-id --format json`;若无目标文件夹,先 `doc info --node ` 取 `workspaceId`,再 `doc folder create --workspace ` 创建文档文件夹作为目标。服务端返回 `Invalid target folder ID` 时,改用 `doc folder create` 新建目标文件夹后重试一次;不要手工重建副本。
- 修改 → `base update`
- 删除 → `base delete`
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
-- 重命名 → `table update`
+- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
+- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
- 删除 → `table delete`
用户说"字段/列/column":
@@ -145,19 +352,35 @@
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
+- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
-- **批量更新同一字段值**(统一标记/统一改值) → `record update --records '[{"recordId":"rec1","cells":{...}},{"recordId":"rec2","cells":{...}}]'`
+- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
- 删除 → `record delete`
+- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
+- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
+- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
用户说"视图/view":
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
- 看某个视图详情 → `view get --view-ids `
- 创建 → `view create`
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
+- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update `(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
+- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
- 删除 → `view delete`
+用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
+
+用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
+
+用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
+
+用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
+
+用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
+
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md)
@@ -166,16 +389,33 @@
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
-用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md),使用 `view create --view-type FormDesigner`
+用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
+
+用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
+- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
+- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
+- 启用流程 → `workflow enable`
+- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
+- **新建 / 修改 / 删除流程**:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
+用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md))
+
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
用户说"模板" → `template search`
+用户说"高级权限/角色/权限控制/谁能看/谁能改" → 读 [aitable-advperm.md](./aitable/aitable-advperm.md)
+- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
+- 看角色配置 → `advperm role-list` 或 `advperm role-get`
+- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
+- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
+- 删角色 → `advperm role-delete --yes`
+- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
+
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
@@ -190,7 +430,7 @@ dws aitable base search --query "项目" --format json
dws aitable base get --base-id --format json
# 3. 获取表结构 — 提取 fieldId
-dws aitable table get --base-id --table-ids --format json
+dws aitable table get --base-id --table-id --format json
# 4. 查询记录
dws aitable record query --base-id --table-id --format json
@@ -206,7 +446,8 @@ dws aitable record create --base-id --table-id \
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
-| `base get` | `tables[].tableId` | --table-id |
+| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
+| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
diff --git a/skills/mono/references/products/aitable/aitable-advperm.md b/skills/mono/references/products/aitable/aitable-advperm.md
new file mode 100644
index 00000000..84d9dfe6
--- /dev/null
+++ b/skills/mono/references/products/aitable/aitable-advperm.md
@@ -0,0 +1,251 @@
+# advperm — 高级权限管理
+
+控制 Base 的高级权限总开关,并管理自定义角色(增删改查 + 子角色权限规则)。
+适用场景:"如何控制谁能看/改 Base 数据"、"开启/关闭高级权限"、"新建/修改/删除角色"、"按字段或行配置权限"。
+
+## 命令一览
+
+| 命令 | 用途 |
+|------|------|
+| `advperm enable` | 开启 Base 高级权限总开关 |
+| `advperm disable` | 关闭 Base 高级权限总开关(高危) |
+| `advperm role-list` | 列出 Base 下全部角色 |
+| `advperm role-get` | 获取单角色完整配置 |
+| `advperm role-create` | 创建自定义角色 |
+| `advperm role-update` | 增量更新自定义角色(PATCH 语义) |
+| `advperm role-delete` | 删除自定义角色(不可逆) |
+
+> 所有子命令的 `--base-id` 必填,可用隐藏别名 `--base`。
+
+## 命令详情
+
+### advperm enable — 开启高级权限
+
+```bash
+dws aitable advperm enable --base-id BASE_ID --format json
+```
+
+返回 `{baseId, enabled: true}`。
+
+只有开启后角色配置才会真正限制成员的可访问范围;关闭状态下角色配置仍可读但不生效。
+
+### advperm disable — 关闭高级权限(高危)
+
+```bash
+dws aitable advperm disable --base-id BASE_ID --yes --format json
+```
+
+返回 `{baseId, enabled: false}`。关闭后所有角色配置即刻失效,全员回退到默认权限。涉及多人协作或敏感数据务必和用户二次确认,建议先 `role-list` 留底。
+
+### advperm role-list — 列出全部角色
+
+```bash
+dws aitable advperm role-list --base-id BASE_ID --format json
+```
+
+返回结构:
+
+```json
+{
+ "data": {
+ "enabled": true,
+ "defaultRole": { "mode": 0 },
+ "roles": [
+ {
+ "roleId": "10685308981",
+ "name": "可查看角色",
+ "roleType": "custom",
+ "system": false,
+ "subRoles": [
+ {
+ "authLevel": "read",
+ "targetId": "HMEaRQ4",
+ "targetType": "sheet",
+ "config": { "actions": 268435455 },
+ "display": {
+ "authLevelLabel": "仅查看",
+ "targetTypeLabel": "数据表",
+ "permissionScopeNote": "...",
+ "actionsLabels": ["新增视图", "删除视图", "修改视图"],
+ "actionsNote": "..."
+ }
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+关键字段:
+
+- `roleType`:`custom`(自定义) / `system_editor` / `system_reader` / `5000`(owner) / `4000`(manager)。
+- `system`:boolean,true 表示系统角色(不可删)。
+- `subRoles[].display.*`:服务端返回的人类可读标签,可直接拼接给用户阅读,无需自行映射枚举。
+- 不返回角色成员列表;如需"成员-角色"映射请去 AI 表格 Web 端。
+- 新建 Base 默认 `enabled=false`,开启后只有 `owner` / `manager` 两个 meta 角色;`system_editor` / `system_reader` 需要在 Web UI 给成员授权"可编辑/可查看"后才会被服务端自动生成。
+
+`role-list` / `role-get` 不需要管理员权限,普通成员也可读。
+
+### advperm role-get — 获取单角色配置
+
+```bash
+dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
+```
+
+返回结构同 `role-list` 中单个 role 对象(含完整 `subRoles[].config` 字段/行级规则与 `display.*` 标签)。
+
+### advperm role-create — 创建自定义角色
+
+```bash
+# 仅指定 name,子角色由服务端按默认(none)填充
+dws aitable advperm role-create --base-id BASE_ID --name "市场可读" --format json
+
+# 创建时即指定 sub-roles(推荐——避免再走一次 role-update)
+dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
+ --sub-roles '[{"targetId":"","targetType":"sheet","authLevel":"read"}]' --format json
+```
+
+| flag | 必填 | 说明 |
+|------|:---:|------|
+| `--name` | ✅ | 角色名称 |
+| `--role-type` | | 角色类型字符串(留空由服务端决定默认值,如 `custom`) |
+| `--flow-type` | | 流程类型字符串(按业务需要) |
+| `--sub-roles` | | JSON 数组:`[{targetId, targetType, authLevel, appId?, config?}]`,详见下方"sub-roles 子字段"段 |
+
+返回新建角色的完整配置(同 `role-get` 出参格式,含自动生成的 default subRoles)。
+系统角色无法通过本命令创建。
+
+### advperm role-update — 增量更新自定义角色(PATCH 语义)
+
+```bash
+# 只改名
+dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
+
+# 只改 sheet 子角色 authLevel,name 不传保持不变
+dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
+ --sub-roles '[{"targetId":"","targetType":"sheet","authLevel":"edit-own"}]'
+```
+
+| flag | 必填 | 说明 |
+|------|:---:|------|
+| `--role-id` | ✅ | 目标自定义角色 ID(数字 long 字符串) |
+| `--name` | | 新角色名称;不传不修改 |
+| `--role-type` / `--flow-type` | | 可选 |
+| `--sub-roles` | | JSON 数组,**PATCH 合并语义**:按 `(targetId, targetType)` 合并到现有 subRoles,入参中的 sub 整体替换该 sub,**入参未提及的 sub 保留不变**(无需先调 `role-get` 自行 merge) |
+
+**系统角色禁止更新**(包括 owner / manager / system_editor / system_reader)。
+
+### sub-roles 子字段
+
+每个 sub-role 描述「角色对某个权限目标的访问粒度」:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `targetId` | string | 目标资源 ID(数据表 → `tableId`;仪表盘 → `dashboardId`;应用 → `appId`) |
+| `targetType` | string | `sheet` / `dashboard` / `app` |
+| `authLevel` | string | `manage` / `edit-own` / `edit-custom-field` / `edit-field-range` / `read` / `none` |
+| `appId` | string(可选) | 仅 `targetType=app` 时使用 |
+| `config` | object(可选) | 字段/行级细化规则;含 `actions`(位图)/ `rows` / `cells`。结构与 `role-get` 出参 `subRoles[].config` 对齐 |
+
+### advperm role-delete — 删除自定义角色(不可逆)
+
+```bash
+dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
+```
+
+要求同时满足:
+
+1. 该 Base 已开启高级权限(`role-list` 返回 `enabled=true`)。
+2. 当前 dws 登录用户是该 Base 的管理员/Owner。
+3. `--role-id` 是 `role-list` 返回的数字 long 字符串(如 `"10685308981"`),且对应角色 `system=false`。
+
+不可逆,删前先 `role-get` 留底。
+
+## 能力边界
+
+| 能力 | 状态 |
+|------|------|
+| 开/关高级权限 | ✅ 需管理员 |
+| 列出 / 读取角色 | ✅ 普通成员也可读 |
+| 创建自定义角色 | ✅ 需管理员 |
+| 增量修改角色(PATCH 语义,不清空未传字段) | ✅ 需管理员 |
+| 删除自定义角色 | ✅ 需管理员 |
+| 修改/删除系统角色 | ❌ 服务端禁止;只能在 AI 表格 Web 端操作 |
+| 角色 ↔ 成员绑定 | ❌ CLI 暂不支持,需在 AI 表格 Web 端 → Base 设置 → 高级权限 → 角色管理面板手动完成 |
+
+## 错误码速查
+
+| 场景 | code | type | message |
+|------|------|------|---------|
+| advperm 关闭时调用写接口(如 `role-delete` / `role-create` / `role-update`) | `ADVANCED_PERMISSION_DISABLED` | `USER_ERROR` | `Advanced permission is disabled for base , please enable it via setAdvancedPermission before managing roles` |
+| 非管理员调用 `enable` / `disable` / `role-create` / `role-update` / `role-delete` | `401` | `AUTH_ERROR` | `the current user must be a manager (administrator) of this base to manage roles or advanced permission` |
+| 删除/更新系统角色(`system=true`) | `600` | `USER_ERROR` | `Illegal argument` |
+| 操作不存在的数字 roleId(get/update/delete) | `600` | `USER_ERROR` | `Illegal argument` |
+| 传非数字 roleId(如 `owner` / `manager`) | `INVALID_PARAMS` | `INPUT_ERROR` | `roleId is required` |
+| `role-create` 缺 `--name` | `INVALID_PARAMS` | `INPUT_ERROR` | `name is required` |
+| `--sub-roles` JSON 不是数组 / 解析失败 | (CLI 层拦截) | — | `--sub-roles 解析失败 ...` / `--sub-roles 必须是 JSON 数组` |
+| `--base-id` 无法解析 | `INVALID_BASE_ID` | `INPUT_ERROR` | `baseId cannot be resolved to docId` |
+
+> `600 / Illegal argument` 同时覆盖"操作系统角色"和"操作不存在 roleId"两种情况。拿到 `600` 时先 `role-list` 自查目标 roleId 是否存在、是否 `system=true`,再据此引导用户。
+
+## 典型工作流
+
+### 排查"成员看不到某些字段/记录"
+
+```bash
+dws aitable advperm role-list --base-id BASE_ID --format json
+# 若 enabled=false:高级权限未开,所有规则不生效,与用户确认是否需要 enable
+
+dws aitable advperm enable --base-id BASE_ID --format json
+dws aitable advperm role-list --base-id BASE_ID --format json
+# 看 roles[] 里有哪些自定义角色
+
+dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
+# 检查 subRoles[].config 中的字段/行级权限规则
+```
+
+### 新建一个"市场可读"角色
+
+```bash
+# 1. 确保高级权限已开
+dws aitable advperm enable --base-id BASE_ID --format json
+
+# 2. 拿目标 sheet 的 tableId
+dws aitable table get --base-id BASE_ID --format json
+
+# 3. 创建角色 + 指定 sheet 子角色 authLevel=read
+dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
+ --sub-roles '[{"targetId":"","targetType":"sheet","authLevel":"read"}]' \
+ --format json
+# → 返回新角色完整配置,含 roleId,记下后续 patch / delete 使用
+```
+
+### 升级角色权限(read → edit-own),保留其他配置
+
+```bash
+# 只传 sub-roles,name 等其他字段保持不变(PATCH 语义)
+dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
+ --sub-roles '[{"targetId":"","targetType":"sheet","authLevel":"edit-own"}]' \
+ --format json
+```
+
+### 改角色名(不影响权限规则)
+
+```bash
+dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
+```
+
+### 清理废弃角色
+
+```bash
+dws aitable advperm role-list --base-id BASE_ID --format json
+dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
+```
+
+### 关闭高级权限(恢复全员可见)
+
+```bash
+dws aitable advperm role-list --base-id BASE_ID --format json > /tmp/roles-backup.json
+dws aitable advperm disable --base-id BASE_ID --yes --format json
+```
diff --git a/skills/mono/references/products/aitable/aitable-attachment.md b/skills/mono/references/products/aitable/aitable-attachment.md
index 1648fc87..45e9c2ae 100644
--- a/skills/mono/references/products/aitable/aitable-attachment.md
+++ b/skills/mono/references/products/aitable/aitable-attachment.md
@@ -1,6 +1,8 @@
# attachment — 附件上传
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
+>
+> **STOP — 严禁在 record create/update 的 cells 里直接传图片 URL!** 直传 `{"url":"https://..."}` 会导致服务端同步下载图片,批量写入时触发 TIMEOUT_ERROR。正确做法:先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。
## 准备附件上传
@@ -38,8 +40,8 @@ dws aitable record create --base-id --table-id \
dws aitable attachment upload --base-id --file-name report.pdf --size 204800 --format json
# → 返回 uploadUrl、fileToken
-# 2. PUT 上传(Content-Type 留空)
-curl -X PUT "" -H "Content-Type:" --data-binary @report.pdf
+# 2. PUT 上传(Content-Type 必须是文件的具体 MIME type)
+curl -X PUT "" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 3. 写入记录
dws aitable record update --base-id --table-id \
diff --git a/skills/mono/references/products/aitable/aitable-best-practices.md b/skills/mono/references/products/aitable/aitable-best-practices.md
index 2c9b2200..db63f634 100644
--- a/skills/mono/references/products/aitable/aitable-best-practices.md
+++ b/skills/mono/references/products/aitable/aitable-best-practices.md
@@ -4,35 +4,30 @@
| 字段类型 | 可写 | 正确方式 |
|----------|------|----------|
-| 文本/数字/日期/单选/多选/复选框/URL | 是 | `dws aitable record create` / `dws aitable record update` |
-| 附件 | 是,但需先上传 | 先 `dws aitable attachment upload` 取 `uploadUrl/fileToken`,PUT 后把 `fileToken` 写入记录 |
-| 创建人/修改人/创建时间/修改时间 | 否 | 系统字段,只读 |
-| 公式/查找引用 | 否 | 由系统计算,只读 |
-| AI 字段 | 否 | 由 AI 自动计算,只读 |
+| 文本/数字/日期/单选/多选/复选框/URL | ✅ | record create/update |
+| 附件 | ⚠️ | 必须先走 [attachment upload 流程](./aitable-attachment.md) |
+| 创建人/修改人/创建时间/修改时间 | ❌ | 系统字段,只读 |
+| 公式/查找引用 | ❌ | 只读,由系统计算 |
+| AI 字段 | ❌ | 只读,由 AI 自动计算 |
## 2. 查询执行契约
-1. 优先用 `dws aitable record query --filters` 在服务端过滤,不要先拉全量再在上下文里手动筛选。
-2. 返回 `has_more=true` 时不能做全局结论,数据可能不完整。
-3. 查询前先用 `dws aitable table get --base-id --table-ids ` 获取真实 fieldId,不要猜字段 ID。
-4. 只需要部分字段时,用 `dws aitable record query --field-ids fld1,fld2` 降低响应体积。
-5. 已知 recordId 时,用 `dws aitable record get --record-ids rec1,rec2`,不要构造无意义 filters。
+1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤
+2. **has_more=true 时不能做全局结论** — 数据可能不完整
+3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep
+4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId
+5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段
## 3. 任务选路
| 用户诉求 | 优先方案 | 不要误走 |
|---------|----------|----------|
-| 查看几条数据 | `dws aitable record query --base-id --table-id ` | 不要默认 `--all` |
-| 全量拉取/统计 | `dws aitable record query --base-id --table-id --all` | 不要手动循环 cursor |
-| 全量导出 | `dws aitable export data --base-id --scope all --format excel` | 不要 `--all` 拉全量再写文件 |
-| 文件级导入 | `dws aitable import upload --base-id --file-name data.xlsx --file-size <字节数>` + `dws aitable import data --import-id ` | 不要手动解析 xlsx 再逐条写入 |
-| 批量写入多条不同数据 | `dws aitable record create --base-id --table-id --records '[{"cells":{"":"值"}}]'` | 不要一次超过 100 条 |
-| 批量给多条记录写同一组值 | `dws aitable record update --base-id --table-id --records '[{"recordId":"rec1","cells":{"":"值"}},{"recordId":"rec2","cells":{"":"值"}}]'` | 不要使用隐藏兼容命令 |
-| 附件上传 | `dws aitable attachment upload --base-id --file-name report.pdf --size <字节数>` + PUT + `record create/update` | 不要用钉盘 drive 上传 |
-| 调整字段顺序 | `dws aitable view update --base-id --table-id --view-id --config '{"visibleFieldIds":["fld1","fld2"]}'` | 没有 `field reorder` 命令 |
-| 查看视图列表 | `dws aitable view list --base-id --table-id ` | 不需要用 `view get --view-ids` |
-| 创建收集表/问卷 | `dws aitable view create --base-id --table-id --view-type FormDesigner --name "表单名"` | 不要使用隐藏兼容命令 |
-| 仪表盘/图表 | 先 `dashboard config-example` / `chart widgets-example`,再 create/update | 不要猜 config 结构 |
+| 查看几条数据 | `record query` | 不要用 `--all` |
+| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor |
+| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 |
+| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 |
+| 附件/图片上传 | `attachment upload` 获取 fileToken → `record create/update` 用 fileToken 写入 | **严禁直接传图片 URL 到附件字段**(服务端同步下载会超时) |
+| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 |
## 4. 创建/修改后回读确认
@@ -40,34 +35,12 @@
| 写操作 | 建议回读命令 | 确认内容 |
|--------|-------------|----------|
-| `dws aitable base create` | `dws aitable base get --base-id ` | base 名称、tables 列表 |
-| `dws aitable table create` | `dws aitable table get --base-id --table-ids ` | 表名、字段列表是否符合预期 |
-| `dws aitable field create` | `dws aitable field get --base-id --table-id ` | 新字段是否出现在字段列表中 |
-| `dws aitable record create/update` | `dws aitable record get --base-id --table-id --record-ids ` | 写入值是否正确 |
-| `dws aitable view update` | `dws aitable view get --base-id --table-id --view-ids ` | `visibleFieldIds` 顺序是否正确 |
-| `dws aitable view create/update` | `dws aitable view get --base-id --table-id --view-ids ` | 表单视图名称、描述和配置 |
+| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 |
+| `field create` | `table get --table-ids ` | 新字段是否出现在字段列表中 |
+| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 |
-## 5. 导入导出与异步任务
+## 5. AI 字段注意事项
-- `export data` 的 `--format` 是导出格式,不要在此命令上追加全局 `--format json`。
-- 创建导出任务:
- ```bash
- dws aitable export data --base-id --scope table --table-id \
- --format excel --timeout-ms 1000
- ```
-- 续等已有导出任务:
- ```bash
- dws aitable export data --base-id --task-id --timeout-ms 3000
- ```
-- 导入本地文件:
- ```bash
- dws aitable import upload --base-id --file-name data.xlsx --file-size <字节数> --format json
- curl -X PUT "" -H "Content-Type:" --data-binary @data.xlsx
- dws aitable import data --import-id --format json
- ```
-
-## 6. AI 字段注意事项
-
-- AI 字段的 prompt 必须至少包含一个 `fieldRef` 引用,纯文本 prompt 会被后端拒绝。
-- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用。
-- `outputType` 必须与字段类型一致,例如 `outputType=text` 配 `--type text`。
+- AI 字段的 prompt **必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
+- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用
+- `outputType` 必须与字段类型一致(如 `outputType=text` 配 `--type text`)
diff --git a/skills/mono/references/products/aitable/aitable-cell-value.md b/skills/mono/references/products/aitable/aitable-cell-value.md
index 9f747bf9..bc3a8125 100644
--- a/skills/mono/references/products/aitable/aitable-cell-value.md
+++ b/skills/mono/references/products/aitable/aitable-cell-value.md
@@ -86,11 +86,16 @@
{"fldDateId": "2026-03-15T09:00+08:00"}
```
-**读取**:RFC3339 字符串
+**读取**:RFC3339 字符串(带时区)
```json
{"fldDateId": "2026-03-15T09:00:00+08:00"}
```
+**过滤**(`record query --filters`):日期字段**只能用日期专用操作符** `date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`,比较值用日期字符串(如 `"2026-03-15"`)。
+- ❌ 通用 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对日期字段无效,会静默返回 0 条;
+- ❌ 不支持区间 `date_between` 与相对 `from_now`(CLI 会直接拒绝),范围查询用 `not_before` + `not_after` 组合。
+- 详见 [aitable-filter-sort.md](./aitable-filter-sort.md) §日期字段过滤。
+
---
### currency(货币)
@@ -236,15 +241,14 @@
### attachment(附件)
-**写入**:对象数组,支持 `fileToken` 或 `url` 形式
+**写入**:对象数组,**必须使用 `fileToken`**
```json
{"fldAttachId": [{"fileToken": "ft_xxx"}]}
-{"fldAttachId": [{"url": "https://example.com/file.pdf"}]}
```
-> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 获取 `fileToken`**。
-> URL 形式是 best-effort 异步转存,不保证立即可用。
+> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 上传文件获取 `fileToken`,再将 `fileToken` 写入 cells。**
+> ❌ **严禁直接传 `{"url": "https://..."}` 形式写入附件/图片字段** — 服务端会同步下载图片,10 条记录即触发 TIMEOUT_ERROR 超时。
> 写入会**整体覆盖**原附件列表,不是追加。
**读取**:对象数组(含下载链接、文件名、大小)
@@ -335,7 +339,7 @@
|------|----------|
| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` |
| progress 写入 `75` | 写入 `0.75`(范围 0~1) |
-| attachment 直接传文件路径 | 必须先 upload 获取 fileToken |
+| attachment 直接传文件路径或图片 URL | 必须先 `attachment upload` 获取 fileToken,再用 fileToken 写入(直传 URL 会超时) |
| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` |
| group 字段用 `openConversationId` | 用 `cid` |
| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 |
diff --git a/skills/mono/references/products/aitable/aitable-dashboard-chart.md b/skills/mono/references/products/aitable/aitable-dashboard-chart.md
index ae9a3739..94d576e2 100644
--- a/skills/mono/references/products/aitable/aitable-dashboard-chart.md
+++ b/skills/mono/references/products/aitable/aitable-dashboard-chart.md
@@ -27,17 +27,18 @@ dws aitable chart get --base-id --dashboard-id --chart-
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
+| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
## chart 子命令
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
-| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
+| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
## 配置获取流程
-创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置;同时必须传 `--layout` 指定图表位置和尺寸,例如 `--layout '{"x":0,"y":0,"w":6,"h":4}'`。
+创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
diff --git a/skills/mono/references/products/aitable/aitable-error-recovery.md b/skills/mono/references/products/aitable/aitable-error-recovery.md
index 947b8fcc..e62fb0a4 100644
--- a/skills/mono/references/products/aitable/aitable-error-recovery.md
+++ b/skills/mono/references/products/aitable/aitable-error-recovery.md
@@ -63,7 +63,7 @@
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
-| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --base-id --task-id ` 轮询直到完成 |
+| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --task-id ` 轮询直到完成 |
| 导入文件格式错误 | 不支持的文件格式或文件损坏 | 确认文件为 .xlsx 格式且未加密 |
## 3. 重试策略
diff --git a/skills/mono/references/products/aitable/aitable-export-import.md b/skills/mono/references/products/aitable/aitable-export-import.md
index 2fc9b469..fc755451 100644
--- a/skills/mono/references/products/aitable/aitable-export-import.md
+++ b/skills/mono/references/products/aitable/aitable-export-import.md
@@ -4,10 +4,10 @@
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
-> ⚠️ **`export data` 的 `--format` 是导出格式**:需要导出 xlsx/附件时写 `--format excel` / `excel_and_attachment`。不要在这个命令上追加全局 `--format json`。
+> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
```bash
-# 第一步:创建任务(按 scope 传必要参数)
+# 第一步:创建任务(按 scope 传必要参数)——注意:不要加 --format json!
dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
diff --git a/skills/mono/references/products/aitable/aitable-field-properties.md b/skills/mono/references/products/aitable/aitable-field-properties.md
index 586d58cc..710d685e 100644
--- a/skills/mono/references/products/aitable/aitable-field-properties.md
+++ b/skills/mono/references/products/aitable/aitable-field-properties.md
@@ -10,7 +10,6 @@
- `field create --name --type --config` 中 config 单独传 JSON 字符串
- `field update --config` 只传 config 部分
- 不需要 config 的类型(如 text、checkbox、attachment)可省略 config 字段
-- 成员/负责人字段类型使用 `user`,不要使用 `member`;字段类型不要写 `Text`/`Number`,统一写规范值 `text`/`number`
## 2. 字段类型速查
@@ -235,6 +234,5 @@ AI 字段不使用 config,而使用独立的 `--ai-config` 参数。详见 [ai
| options 更新时只传新增项 | 全量覆盖,旧选项全部丢失 |
| formula 字段尝试写入值 | 只读字段,record create/update 会报错 |
| linkedTableId 传表名而非 ID | 必须传 tableId(如 `tblXXX`),不接受表名 |
-| 成员字段使用 `member` | 不支持;人员/负责人/成员字段统一使用 `user` |
| progress 值写入 50 表示 50% | 实际应写入 0.5(range 0~1) |
| rating 值超出 max | 写入会报错 |
diff --git a/skills/mono/references/products/aitable/aitable-filter-sort.md b/skills/mono/references/products/aitable/aitable-filter-sort.md
index 6728fc26..8fabf8b4 100644
--- a/skills/mono/references/products/aitable/aitable-filter-sort.md
+++ b/skills/mono/references/products/aitable/aitable-filter-sort.md
@@ -1,5 +1,7 @@
# filters & sort — 筛选排序语法参考
+> 视图(view)配置的 filter/sort/group **整体写入**请优先用 `view update filter` / `view update sort` / `view update group` 子命令,详见 [aitable-view-config.md](./aitable-view-config.md)。本文件聚焦于 `record query --filters` 与 view config filter 的语法和差异。
+
## filters 结构规范
### 强制规则
@@ -49,11 +51,34 @@ CLI 同时兼容两种子条件写法(推荐格式 A):
| `exist` / `un_exist` | 有值 / 为空 | `["fieldId"]`(无需第二项) |
| `any_of` / `none_of` / `all_of` | 包含任一 / 不包含任一 / 全包含(多选字段) | `["fieldId", "optionName"]` |
| `date_eq` / `before` / `after` | 日期等于 / 早于 / 晚于 | `["fieldId", "dateStr"]` |
-| `not_before` / `not_after` | 不早于 / 不晚于 | `["fieldId", "dateStr"]` |
-| `from_now` | 从现在起 N 天内 | `["fieldId", "天数"]` |
-| `date_between` | 日期区间 | `["fieldId", "[startTs, endTs]"]` |
+| `not_before` / `not_after` | 不早于(≥) / 不晚于(≤) | `["fieldId", "2026-05-22"]` |
> **操作符拼写必须严格匹配上表**,CLI 会在调用前校验,错误拼写会被拒绝。
+>
+> **没有 `date_between`(区间)操作符**,也**不支持 `from_now`**——date 字段不支持区间/相对过滤,传了会被 CLI 拒绝。范围查询用 `not_before` + `not_after` 组合,见下方专节。
+
+### 日期字段过滤(date / 创建时间 / 修改时间)
+
+日期类字段的过滤规则与其它字段**不同**,是线上反馈最高频的踩坑点。**经集成测试实测**确认的规则:
+
+1. **只能用日期专用操作符**:`date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`(与前端筛选 UI 的「等于 / 早于 / 晚于 / 早于或等于 / 晚于或等于 / 不为空 / 为空」一一对应)。
+2. **比较值用日期字符串**,如 `"2026-05-22"`(也接受 RFC3339 / 毫秒时间戳,内部统一转成毫秒比较)。读取返回的是带时区 RFC3339(如 `"2026-05-22T00:00:00+08:00"`)。
+3. **通用操作符 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对 date 字段无效**——无论传 ISO 字符串还是毫秒时间戳,都会**静默返回 0 条**。这是后端 date 字段的比较规则,不是 bug,CLI 也无法在本地拦截(不知道字段类型),务必用对操作符。
+4. **没有区间操作符 `date_between`**,也**不支持 `from_now`(相对天数)**——均会静默返回 0 条,CLI 已直接拒绝。范围查询用 `not_before`(≥起点)+ `not_after`(≤终点)两个条件 `and` 组合。
+
+| 需求 | 操作符 | 示例 operands |
+|------|--------|--------------|
+| 等于某天 | `date_eq` | `["fldDate", "2026-05-22"]` |
+| 早于 / 晚于(不含当天) | `before` / `after` | `["fldDate", "2026-05-22"]` |
+| 不早于(≥) / 不晚于(≤) | `not_before` / `not_after` | `["fldDate", "2026-05-22"]` |
+| 有值 / 为空 | `exist` / `un_exist` | `["fldDate"]` |
+
+**日期区间查询(替代 between)**——查 `2026-05-01 ~ 2026-05-31`(含端点):
+
+```bash
+dws aitable record query --base-id X --table-id Y \
+ --filters '{"operator":"and","operands":[{"operator":"not_before","operands":["fldDate","2026-05-01"]},{"operator":"not_after","operands":["fldDate","2026-05-31"]}]}'
+```
### 常见错误拼写(CLI 会自动提示纠正)
diff --git a/skills/mono/references/products/aitable/aitable-form.md b/skills/mono/references/products/aitable/aitable-form.md
index 83528cd0..2b126d8f 100644
--- a/skills/mono/references/products/aitable/aitable-form.md
+++ b/skills/mono/references/products/aitable/aitable-form.md
@@ -1,37 +1,120 @@
-# 表单视图 — 使用 view(FormDesigner)
+# form — 表单管理
-悟空命令面不暴露独立表单命令组。表单在 AI 表格里按 `viewType=FormDesigner` 的视图处理,所有生成命令都走 `view` 和 `field`。
+## 命令一览
-## 命令路线
+| 命令 | 用途 |
+|------|------|
+| `form list` | 列出数据表下所有表单视图 |
+| `form get` | 按 viewId 取单个表单详情(list_form_views + viewIds 过滤) |
+| `form create` | 创建表单视图(等价于 `view create --view-type FormDesigner`) |
+| `form update` | 更新表单标题或描述 |
+| `form delete` | 删除表单视图(不可逆) |
+| `form field list` | 列出表单可见字段 |
+| `form field update` | 更新字段必填/描述 |
+| `form field hide` | 在表单中隐藏/显示字段(不影响底层数据表字段) |
+| `form share get` | 获取分享配置 |
+| `form share update` | 开启/关闭分享 |
+| `form questions create` | 添加题目(等价于 `field create`,命令位置上的别名) |
+| `form questions delete` | 删除题目(等价于 `field delete`,命令位置上的别名) |
-| 诉求 | 使用命令 | 说明 |
-|------|----------|------|
-| 列出表单视图 | `dws aitable view list --base-id --table-id ` | 从返回视图中过滤 `viewType=FormDesigner` |
-| 查看表单视图 | `dws aitable view get --base-id --table-id --view-ids ` | 按 viewId 获取 |
-| 创建表单视图 | `dws aitable view create --base-id --table-id --view-type FormDesigner --name "表单名"` | 返回 `viewId` |
-| 更新表单视图 | `dws aitable view update --base-id --table-id --view-id --name "新名称"` | 描述用 `--desc` JSON |
-| 删除表单视图 | `dws aitable view delete --base-id --table-id --view-id --yes` | 不可逆 |
-| 添加题目 | `dws aitable field create --base-id --table-id --fields '[...]'` | 题目本质是字段 |
-| 删除题目 | `dws aitable field delete --base-id --table-id --field-id --yes` | 不可逆 |
+## 建议操作顺序
-## 创建工作流
+```bash
+# 1) 列出数据表下的表单视图
+dws aitable form list --base-id BASE_ID --table-id TABLE_ID --format json
+
+# 2) 查看单个表单详情
+dws aitable form get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
+
+# 3) 查看表单字段配置
+dws aitable form field list --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
+
+# 4) 查看分享配置
+dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
+```
+
+## 要点
+
+- **创建表单**有两种等价方式:
+ - `form create --name "表单名"`(推荐,语义清晰)
+ - `view create --view-type FormDesigner --name "表单名"`(底层一致)
+- `form update` 支持 `--title` 与 `--name` 两个等价参数;至少需传一项
+- `form field update` 必须传 `--required` 或 `--field-description` 至少一项
+- `form field hide` 仅控制字段在表单中的可见性,不影响底层数据表字段
+- **题目管理**与字段管理本质相同(题目 = 表格字段):
+ - `form questions create` 与 `field create` 入参完全一致(`--fields` JSON 或 `--name --type`)
+ - `form questions delete` 与 `field delete` 入参完全一致(必传 `--field-id`)
+ - 设置必填要在 create 后用 `form field update --required true` 单独调一次
+
+## form 子命令
+
+| 命令 | 用途 | 必填参数 | 说明 |
+|------|------|----------|------|
+| `form list` | 列出表单视图 | `--base-id` `--table-id` | 返回 viewId/name/title/createdAt |
+| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 内部基于 list_form_views 过滤 |
+| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | viewType=FormDesigner |
+| `form update` | 更新表单 | `--base-id` `--table-id` `--view-id` | `--title`/`--name`(等价)和 `--description` 至少传一项;同时传 title/name 时 title 优先 |
+| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` `--yes` | 不可逆 |
+
+## form field 子命令
+
+| 命令 | 用途 | 必填参数 | 说明 |
+|------|------|----------|------|
+| `form field list` | 列出表单字段 | `--base-id` `--table-id` `--view-id` | 返回 fieldId/name/type/required/hidden/description(hidden=true 的字段不在此返回) |
+| `form field update` | 更新表单字段 | `--base-id` `--table-id` `--view-id` `--field-id` | `--required` 或 `--field-description` 至少一项 |
+| `form field hide` | 切换字段隐藏 | `--base-id` `--table-id` `--view-id` `--field-id` `--hidden` | `--hidden true` 隐藏 / `--hidden false` 显示 |
+
+## form questions 子命令
+
+`form questions create/delete` 与 `field create/delete` 入参、行为完全一致,只是命令位置归属于 `form` 命令组,方便从表单视角操作题目。
+
+| 命令 | 用途 | 必填参数 | 说明 |
+|------|------|----------|------|
+| `form questions create` | 添加题目 | `--base-id` `--table-id` + (`--fields` 或 `--name --type`) | 入参与 `field create` 完全一致 |
+| `form questions delete` | 删除题目 | `--base-id` `--table-id` `--field-id` `--yes` | 入参与 `field delete` 完全一致;不可逆;批量需多次调用 |
+
+## form share 子命令
+
+| 命令 | 用途 | 必填参数 | 说明 |
+|------|------|----------|------|
+| `form share get` | 获取分享配置 | `--base-id` `--table-id` `--view-id` | 返回 enabled/status/shareFormUuid |
+| `form share update` | 开启/关闭分享 | `--base-id` `--table-id` `--view-id` `--enabled` | `--enabled true` 开启 / `--enabled false` 关闭。注意:UI 上"发布并分享"按钮是另一概念,本命令只切换内部 enabled 标志,开启后需在 UI 刷新页面才会看到分享面板 |
+
+## 完整工作流示例
+
+> **占位符约定**:
+> - `BASE_ID` 来自 `dws aitable base list` / `base search` 返回的 `data.bases[].baseId`
+> - `TABLE_ID` 来自 `dws aitable base get --base-id BASE_ID` 返回的 `data.tables[].tableId`
+> - `VIEW_ID` 来自步骤 1 `form create` 返回的 `data.viewId`
+> - `FIELD_ID` 来自步骤 2 `form questions create` 返回的 `data.results[].fieldId`
```bash
-# 1. 创建表单视图
-dws aitable view create --base-id BASE_ID --table-id TABLE_ID \
- --view-type FormDesigner --name "员工信息收集" --format json
+# 1) 创建表单 → 取返回的 data.viewId 作为 VIEW_ID
+dws aitable form create --base-id BASE_ID --table-id TABLE_ID --name "员工信息收集" --format json
-# 2. 添加题目字段
-dws aitable field create --base-id BASE_ID --table-id TABLE_ID \
+# 2) 添加题目 → 取返回的 data.results[].fieldId 作为 FIELD_ID
+dws aitable form questions create --base-id BASE_ID --table-id TABLE_ID \
--fields '[{"fieldName":"姓名","type":"text"},{"fieldName":"邮箱","type":"text"}]' --format json
-# 3. 回读表单视图
-dws aitable view get --base-id BASE_ID --table-id TABLE_ID --view-ids VIEW_ID --format json
+# 3) 配置表单标题与描述
+dws aitable form update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
+ --title "员工信息收集" --description "请填写您的基本信息" --format json
+
+# 4) 设置题目必填(FIELD_ID 来自步骤 2)
+dws aitable form field update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
+ --field-id FIELD_ID --required true --format json
+
+# 5) 隐藏不需要的题目
+dws aitable form field hide --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
+ --field-id FIELD_ID --hidden true --format json
+
+# 6) 开启分享(注意:开启后需 UI 刷新页面才会看到分享面板)
+dws aitable form share update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
+ --enabled true --format json
```
-## 注意
+## 返回结构补充
-- 不要生成隐藏兼容的独立表单命令;它不属于悟空对齐的公开命令面。
-- `VIEW_ID` 来自 `view create` 返回。
-- `FIELD_ID` 来自 `field create` 或 `field get` 返回。
-- 字段必填、字段隐藏、分享开关等表单高级配置没有公开的悟空命令入口;不要用隐藏兼容命令替代。
+- `form list` 返回 `data.formViews[]`,**每条仅含** `viewId/name/title/createdAt`;`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
+- `form get` 返回结构与 `form list` 完全一致(`data.formViews[]`),仅含一条记录(与请求 viewId 一致)。Agent 提取时仍走 `data.formViews[0]`。
+- `form field list` 仅返回**未隐藏**的字段;`hidden=true` 的字段不在此返回,如需查看全部字段请用 `field get`。
diff --git a/skills/mono/references/products/aitable/aitable-primary-doc.md b/skills/mono/references/products/aitable/aitable-primary-doc.md
new file mode 100644
index 00000000..eefda277
--- /dev/null
+++ b/skills/mono/references/products/aitable/aitable-primary-doc.md
@@ -0,0 +1,55 @@
+# 主键文档管理
+
+## 适用场景
+
+当需要为 AI 表格中的记录创建或查询关联的主键文档时使用。主键文档是 primaryDoc 类型字段对应的钉钉在线文档,可通过 `dws doc` 进行内容读写。
+
+## 命令
+
+### 查询主键文档
+
+```bash
+dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID
+```
+
+**参数:**
+- `--base-id`(必填):Base ID
+- `--table-id`(必填):Table ID
+- `--record-id`(必填):Record ID
+
+**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。若该记录尚未创建主键文档,`nodeId` 为 null。
+
+### 创建主键文档
+
+```bash
+dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
+```
+
+**参数:**
+- `--base-id`(必填):Base ID
+- `--table-id`(必填):Table ID
+- `--field-id`(必填):主键字段 ID,必须是 primaryDoc 类型(通过 `dws aitable table get` 查看字段类型)
+- `--record-id`(必填):Record ID
+
+**返回:** `data.nodeId` — 创建或已存在的主键文档 nodeId。
+
+**幂等性:** 若该记录已有主键文档,直接返回已有文档的 nodeId,不会重复创建。
+
+## 注意事项
+
+- `fieldId` 必须是 primaryDoc 类型,否则返回 `INVALID_FIELD_TYPE` 错误
+- 传入不存在的 `recordId` 会返回 `RECORD_NOT_FOUND` 错误
+- 创建后可通过 `dws doc update --node ` 写入文档内容,或 `dws doc read --node ` 读取
+
+## 典型工作流
+
+```bash
+# 1. 查询表结构,拿到 primaryDoc 字段的 fieldId
+dws aitable table get --base-id BASE_ID --table-ids TABLE_ID
+
+# 2. 为某条记录创建主键文档
+dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
+
+# 3. 拿到返回的 nodeId,用 dws doc 写入内容
+dws doc update --node --content "# 项目方案\n\n文档正文内容..."
+```
diff --git a/skills/mono/references/products/aitable/aitable-record-create.md b/skills/mono/references/products/aitable/aitable-record-create.md
index e64f1865..bf605899 100644
--- a/skills/mono/references/products/aitable/aitable-record-create.md
+++ b/skills/mono/references/products/aitable/aitable-record-create.md
@@ -27,12 +27,13 @@ Flags:
| cells key 用字段名 | ❌ cells key 必须是 fieldId(如 `fldXXX`),不是字段名称(如 `"课程名称"`) |
| 不先获取 fieldId | ❌ 必须先 `table get` 获取 fieldId,再写入记录 |
| 单次超 100 条 | ❌ 单次最多 100 条,超过需分批 |
+| 附件/图片字段直传 URL | ❌ 严禁 `{"url":"https://..."}` — 会触发 TIMEOUT_ERROR。必须先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。详见 [aitable-attachment.md](./aitable-attachment.md) |
## 正确流程
```bash
# 先获取 fieldId
-dws aitable table get --base-id --table-ids --format json
+dws aitable table get --base-id --table-id --format json
# 从返回中提取 fieldId(如 fldABC123)
# 再用 fieldId 写入记录
diff --git a/skills/mono/references/products/aitable/aitable-record-history.md b/skills/mono/references/products/aitable/aitable-record-history.md
new file mode 100644
index 00000000..f02d3d87
--- /dev/null
+++ b/skills/mono/references/products/aitable/aitable-record-history.md
@@ -0,0 +1,97 @@
+# 行记录变更历史(record history-list)
+
+按 recordId 查询单条记录的全部变更历史,用于审计、回溯字段变更、定位操作人。
+
+## 命令
+
+```
+dws aitable record history-list \
+ --base-id BASE_ID --table-id TABLE_ID --record-id REC_ID \
+ [--offset N] [--limit M]
+```
+
+| flag | 说明 |
+|------|------|
+| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
+| `--table-id` | 所属 Table ID(必填) |
+| `--record-id` | 目标记录 ID(必填,单条;不支持批量) |
+| `--offset` | 分页偏移量,默认 0 |
+| `--limit` | 每页返回数量,范围 [1, 50],默认 20 |
+
+## 返回结构
+
+```jsonc
+{
+ "data": {
+ "histories": [
+ {
+ "type": "field_change", // 变更类型
+ "action": "update", // 操作动作: create / update / delete
+ "newValue": "{\"...\":\"...\"}", // 变更后的值(JSON 字符串)
+ "oldValue": "{\"...\":\"...\"}", // 变更前的值(JSON 字符串)
+ "operateTime": 1733123456789, // 操作时间(毫秒级时间戳)
+ "typeChangedFields": "{...}", // 类型变更的字段信息(JSON 字符串)
+ "version": 7 // 版本号(单调递增)
+ }
+ ]
+ }
+}
+```
+
+`newValue` / `oldValue` / `typeChangedFields` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值。
+
+## 字段含义速查
+
+| 字段 | 用途 |
+|------|------|
+| `type` | 高层分类:`record_create` / `field_change` / `record_delete` 等。先按 type 过滤大类。 |
+| `action` | 三态:`create` / `update` / `delete`。比 type 粗,但便于按"动作"统计。 |
+| `version` | 单调递增整数;同一 record 越新值越大。**用作"上一条 vs 这一条"的稳定排序键**。 |
+| `operateTime` | 毫秒时间戳;可格式化成可读时间。多条同 version 的极端场景用 operateTime 兜底排序。 |
+
+## 典型用法
+
+### 1. 看一条记录被改过几次
+
+```bash
+dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
+ | jq '.data.histories[] | {version, action, operateTime}'
+```
+
+### 2. 翻页拉全量历史
+
+```bash
+# 第 1 页(最新 20 条)
+dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 0
+
+# 第 2 页
+dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 50
+```
+
+`limit` 上限 50,需要更多请增加 `offset` 翻页。
+
+### 3. 回溯某字段最近一次值
+
+```bash
+dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --format json \
+ | jq '[.data.histories[] | select(.action == "update")][0].oldValue'
+```
+
+### 4. 找出删除事件(如果存在 delete history)
+
+```bash
+dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
+ | jq '.data.histories[] | select(.action == "delete") | {version, operateTime}'
+```
+
+## 注意事项
+
+- 一次只能查一条 record;如需批量审计多条记录请循环调用。
+- 仅返回**字段值变更**与**记录生命周期事件**;视图、字段定义、表结构变更不在此 history 里。
+- 历史保留时长由 server 决定,过老的记录可能不再返回。
+
+## 与其他 record 命令的关系
+
+- 想看记录"现在长什么样" → `record query` / `record get`
+- 想看记录"过去长什么样、什么时候改的" → `record history-list`(本命令)
+- 想看"这张表整体改过什么" → 当前 CLI 不支持表级 history;只能逐 record 查
diff --git a/skills/mono/references/products/aitable/aitable-record-name-key.md b/skills/mono/references/products/aitable/aitable-record-name-key.md
new file mode 100644
index 00000000..65a7fe89
--- /dev/null
+++ b/skills/mono/references/products/aitable/aitable-record-name-key.md
@@ -0,0 +1,47 @@
+# 行命名规则枚举键(recordNameKey)映射
+
+`dws aitable table update --record-name-key <枚举键>` 用于设置数据表的"行命名规则"——卡片/详情页里"行"的展示别名。**取值是固定枚举,不是字段 ID**;传非法值服务端返回 `INVALID_RECORD_NAME_KEY`。
+
+## 中文 → 枚举键(按 UI 下拉顺序)
+
+| 用户说 | --record-name-key | 用户说 | --record-name-key |
+|---|---|---|---|
+| 记录 | `ji_lu`(默认) | 项目 | `project` |
+| 任务 | `task` | 事件 | `event` |
+| 请求 | `request` | 活动 | `campaign` |
+| 目标 | `objective` | 交付物 | `deliverable` |
+| 资产 | `asset` | 客户 | `customer` |
+| 订单 | `order` | 联系人 | `contact` |
+| 物料/物品 | `item` | 问题 | `question` 或 `issue` |
+| 工单 | `ticket` | 候选人 | `candidate` |
+| 商机/机会 | `opportunity` | 会议 | `meeting` |
+| 成员 | `member` | OKR | `okr` |
+
+## 其他常用键(按场景分组)
+
+- **业务流程**:`approval` / `application` / `case` / `decision` / `delivery` / `payment` / `purchase_order` / `quote` / `release`
+- **HR / 财务**:`employee` / `expense` / `budget` / `invoice`
+- **产品 / 研发**:`feature` / `feedback` / `idea` / `bug` / `requirement` / `risk` / `sprint` / `story` / `subtask` / `epic`
+- **CRM**:`account` / `lead` / `prospect` / `deal`
+- **运营 / 支持**:`note` / `report` / `topic` / `session` / `service`
+- **资源 / 通用**:`file` / `document` / `product` / `team` / `user` / `vendor` / `key_result` / `metric`
+
+完整集合较大(共 273 个),服务端校验;以上未列出的合法键也可直接传(如 `goal` / `okr` / `pillar` / `phase` / `milestone` 等)。
+
+## 使用示例
+
+```bash
+# 用户说"把这张表的行叫'任务'吧" → 传 task
+dws aitable table update --base-id BASE --table-id TBL --record-name-key task
+
+# 用户说"换成项目" → 传 project
+dws aitable table update --base-id BASE --table-id TBL --record-name-key project
+
+# 用户说"恢复成默认(记录)" → 传 ji_lu
+dws aitable table update --base-id BASE --table-id TBL --record-name-key ji_lu
+```
+
+## 注意
+
+- recordNameKey **不会在 `table get` 响应里回显**(`get_tables` DTO 设计上不暴露该字段);写入是否成功以 `table update` 的 set response 是否回填 `recordNameKey` 字段为准。
+- 中文别名是 server 内置 i18n,UI 显示用户对应的国际化文案,CLI 必须传英文枚举键。
diff --git a/skills/mono/references/products/aitable/aitable-record-query.md b/skills/mono/references/products/aitable/aitable-record-query.md
index 8d04ab1b..20ed92da 100644
--- a/skills/mono/references/products/aitable/aitable-record-query.md
+++ b/skills/mono/references/products/aitable/aitable-record-query.md
@@ -72,3 +72,49 @@ dws aitable record query --base-id X --table-id Y --all --cursor "上次返回
- `--sort` 用 `"order":"desc"` → 必须用 `"direction":"desc"`
- 不加 `--field-ids` 拉全字段 → 大表响应体积过大
- 全量拉取后在 context 里手动统计 → 应优先用 `--filters` 服务端过滤
+
+## record query-empty — 找空行
+
+`record query-empty` 是与 `record query` 平行的独立子命令,专门按表内顺序扫描出"完全没填用户字段"的空行。
+
+```bash
+dws aitable record query-empty --base-id BASE_ID --table-id TABLE_ID
+```
+
+| flag | 说明 |
+|------|------|
+| `--base-id` / `--base` | 必填 |
+| `--table-id` | 必填 |
+| `--limit` | 单次**扫描预算**(不是返回数);范围 [1, 100],默认 100 |
+| `--cursor` | 分页游标。响应中 `nextCursor` 非空 → 用它翻页继续扫;nextCursor 为空(或不存在)→ 已扫完整表 |
+
+返回结构:
+
+```jsonc
+{ "data": { "records": [...], "nextCursor": "..." } }
+```
+
+### 关键语义
+
+1. **`--limit` 是扫描预算不是返回数**:可能扫了 100 条但全部非空,本页 `records: []`。
+2. **本页空 records ≠ 全表无空行**:必须看 `nextCursor`,nextCursor 还在就要继续翻。
+3. **空行定义**:除系统字段(recordId / 创建人 / 创建时间 / 修改人 / 修改时间)外,所有 cell 都是 null、空字符串、空集合或空 Map。一般是用户在 UI 上"插入空行"产生的。
+
+### 典型用法
+
+```bash
+# 扫一页,看本页有没有空行
+dws aitable record query-empty --base-id BASE --table-id TBL
+
+# 翻页
+dws aitable record query-empty --base-id BASE --table-id TBL --cursor <上次的nextCursor>
+
+# 把整表扫完(手动循环 cursor)
+NC=""
+while : ; do
+ R=$(dws aitable record query-empty --base-id BASE --table-id TBL ${NC:+--cursor "$NC"} --format json)
+ echo "$R" | jq '.data.records[] | .recordId'
+ NC=$(echo "$R" | jq -r '.data.nextCursor // empty')
+ [ -z "$NC" ] && break
+done
+```
diff --git a/skills/mono/references/products/aitable/aitable-record-share.md b/skills/mono/references/products/aitable/aitable-record-share.md
new file mode 100644
index 00000000..6e5eea51
--- /dev/null
+++ b/skills/mono/references/products/aitable/aitable-record-share.md
@@ -0,0 +1,54 @@
+# 行记录分享链接(record share-url)
+
+按 recordId 批量获取记录的分享链接,把某行单独发给同事查看。
+
+## 命令
+
+```
+dws aitable record share-url \
+ --base-id BASE_ID --table-id TABLE_ID \
+ --record-ids rec1,rec2,rec3 \
+ [--view-id VIEW_ID]
+```
+
+| flag | 说明 |
+|------|------|
+| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
+| `--table-id` | 所属 Table ID(必填) |
+| `--record-ids` | 目标 Record ID 列表,CSV 逗号分隔,**单次最多 20 条**(必填) |
+| `--view-id` | 视图 ID(可选)。带上后链接打开会落在该视图上下文里 |
+
+## 返回结构
+
+```jsonc
+{
+ "data": {
+ "items": [
+ { "recordId": "rec1", "shareUrl": "https://..." },
+ { "recordId": "rec2", "shareUrl": "https://..." }
+ ]
+ }
+}
+```
+
+`shareUrl` 为 null 表示该条获取失败(不影响其他条目)。
+
+## 典型用法
+
+```bash
+# 一次拿一条记录的链接
+dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1
+
+# 批量拿,配合 jq 过滤出 url
+dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1,rec2,rec3 --format json \
+ | jq '.data.items[] | {recordId, shareUrl}'
+
+# 带视图上下文(链接打开时落在指定视图)
+dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1 --view-id viw_VIP
+```
+
+## 注意事项
+
+- **单次最多 20 条**,超出请客户端拆批。
+- 该链接是分享链接(不是源文档链接),打开后看到的是该 record 的只读详情页。
+- 取消单条分享 / 关闭整表分享当前 CLI 不支持,需要在 AI 表格 Web 端操作。
diff --git a/skills/mono/references/products/aitable/aitable-record-upsert.md b/skills/mono/references/products/aitable/aitable-record-upsert.md
new file mode 100644
index 00000000..1642282b
--- /dev/null
+++ b/skills/mono/references/products/aitable/aitable-record-upsert.md
@@ -0,0 +1,89 @@
+# 行记录 Upsert(record upsert)
+
+按 `recordId` 是否存在,自动把入参拆分到 update 链路或 create 链路:批次混合"已存在改 + 新出现建"时用,省掉客户端按 ID 分批的逻辑。
+
+## 命令
+
+```
+dws aitable record upsert \
+ --base-id BASE_ID --table-id TABLE_ID \
+ --records '[{"recordId":"<可选>","cells":{...}}, ...]'
+```
+
+| flag | 说明 |
+|------|------|
+| `--base-id` | 必填(可用 `--base` 别名) |
+| `--table-id` | 必填 |
+| `--records` | 待 upsert 的记录 JSON 数组,**单次最多 100 条**(必填)|
+| `--records-file` | 从文件读入(命令行 JSON 太长时用),与 `--records` 互斥优先级更高 |
+
+## --records 结构
+
+每项 JSON:
+
+```jsonc
+{
+ "recordId": "rec1", // 可选;带 → update,缺省 → create
+ "cells": { // 必填;key 是 fieldId,value 按字段类型
+ "fldTitleId": "新标题",
+ "fldNumberId": 42
+ }
+}
+```
+
+`cells` 写入格式与 `record create` / `record update` **完全一致**(key 必须是 fieldId 不是字段名;按字段类型见 [aitable-cell-value.md](./aitable-cell-value.md))。
+
+## 返回结构
+
+```jsonc
+{
+ "data": {
+ "createdRecordIds": ["recX", "recY"], // 不带 recordId 的项产出
+ "updatedRecordIds": ["recA", "recB"] // 带 recordId 的项产出
+ }
+}
+```
+
+`createdRecordIds` 顺序对应入参里**不带 recordId**的项(按出现顺序汇总),同理 `updatedRecordIds` 对应**带 recordId**的项。
+
+## 典型用法
+
+```bash
+# 1) 全部新建:所有项都不带 recordId
+dws aitable record upsert --base-id BASE --table-id TBL --records '[
+ {"cells":{"fldTitleId":"任务1","fldStatusId":"待办"}},
+ {"cells":{"fldTitleId":"任务2","fldStatusId":"待办"}}
+]'
+
+# 2) 全部更新:所有项都带 recordId
+dws aitable record upsert --base-id BASE --table-id TBL --records '[
+ {"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
+ {"recordId":"rec2","cells":{"fldStatusId":"已完成"}}
+]'
+
+# 3) 混合:第 1 条更新(带 recordId),第 2 条创建(不带)
+dws aitable record upsert --base-id BASE --table-id TBL --records '[
+ {"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
+ {"cells":{"fldTitleId":"新增任务","fldStatusId":"待办"}}
+]'
+
+# 4) 长 JSON 用文件
+dws aitable record upsert --base-id BASE --table-id TBL --records-file ./batch.json
+```
+
+## 与 record create / record update 的关系
+
+| 场景 | 命令 |
+|------|------|
+| 确定全是新增 | `record create` |
+| 确定全是更新(每条独立 cells) | `record update` |
+| 确定全是更新(共享同一 cells) | `record batch-update` |
+| **不确定有没有,按 recordId 自动分流** | `record upsert`(本命令) |
+
+`record upsert` 的 `--records` 入参格式与 `record update` 完全相同,唯一差别是 `recordId` 字段在 upsert 里是可选的。如果批次确定全是更新或全是新建,用专用命令更清晰;批次混合时(典型场景:定时同步外部数据,源里既有已存在的也有新出现的),用 upsert。
+
+## 注意事项
+
+- **单次最多 100 条**(创建 + 更新合计),超出请客户端拆批。
+- `cells` 的 key 必须是 fieldId 不是字段名(先用 `record query` 或 `field get` 拿 fieldId)。
+- 只读字段(formula / lookup / 系统字段)不能写入 — upsert 链路与 update 链路同样限制。
diff --git a/skills/mono/references/products/aitable/aitable-view-config.md b/skills/mono/references/products/aitable/aitable-view-config.md
new file mode 100644
index 00000000..6b22ec03
--- /dev/null
+++ b/skills/mono/references/products/aitable/aitable-view-config.md
@@ -0,0 +1,203 @@
+# 视图配置(view get/update )
+
+按属性局部读/写视图配置。每个属性独立子命令,typed flag 友好,agent 不必拼 JSON。
+向后兼容:`view update --config '{...}'` 一次多属性入口仍可用。
+
+## viewType × 支持矩阵
+
+| viewType | card | timebar | aggregate | filter / sort / group | visible-fields | field-widths | name |
+|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
+| Grid | | | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Kanban | ✅ | | | ✅ | ✅ | | ✅ |
+| Gallery | ✅ | | | ✅ | ✅ | | ✅ |
+| Gantt | | ✅ | | ✅ | ✅ | | ✅ |
+| Calendar | | | | ✅ | ✅ | | ✅ |
+| FormDesigner | (走 `form` 系列命令) | | | | | | |
+
+> card 在 Kanban 走 `kanbanCard`,在 Gallery 走 `galleryCard`,CLI 自动按 viewType dispatch(preflight 1 次 `get_views`)。timebar 仅 Gantt 支持;Calendar 服务端未暴露任何 timebar 配置。
+
+> **Gantt 视图必须两步创建**:`view create --view-type Gantt` 只创建空壳(`ganttTimebar: {}`),**必须**紧跟 `view update timebar --start-field <日期字段ID>` 绑定时间轴字段,否则视图打开是空白。`create_view` 的 `--config` 中传入 `ganttTimebar` 会被服务端忽略。
+
+## 读取:view get
+
+所有 `view get ` 共用 `--base-id` / `--table-id` / `--view-id`,输出是该属性子块的 JSON(不存在时输出 `{}`)。viewType 不匹配会报错并指明应该选哪种视图。
+
+```bash
+dws aitable view get card --view-id VIEW_ID --format json # Kanban / Gallery
+dws aitable view get timebar --view-id VIEW_ID --format json # Gantt
+dws aitable view get aggregate --view-id VIEW_ID --format json # Grid
+dws aitable view get filter --view-id VIEW_ID --format json # 所有
+dws aitable view get sort --view-id VIEW_ID --format json
+dws aitable view get group --view-id VIEW_ID --format json
+dws aitable view get visible-fields --view-id VIEW_ID --format json
+dws aitable view get field-widths --view-id VIEW_ID --format json # Grid
+```
+
+## 写入:view update
+
+所有 `view update ` 共用 `--base-id` / `--table-id` / `--view-id`。
+**typed flag + `--json` 可混用**;冲突时 typed flag 优先并 stderr 提示。
+card / timebar / aggregate 三类写入有 viewType 校验(preflight 1 次 get_views)。
+
+### view update card(Kanban / Gallery)
+
+服务端按 viewType 分发到 `kanbanCard` 或 `galleryCard`。typed flag 共享。
+
+| flag | 类型 | 说明 |
+|------|------|------|
+| `--cover-field-id` | string | 封面字段 ID(Kanban / Gallery 通用),与 `--no-cover` 互斥 |
+| `--no-cover` | bool | 清除封面(等价 `coverFieldId="NONE"`) |
+| `--cover-resize-mode` | string | `cover` / `contain` / `stretch` |
+| `--hidden-field-title` | bool | 隐藏字段名标题(仅 Kanban 生效) |
+| `--cover-mode` | string | `none` / `auto` / `custom`(仅 Gallery 生效) |
+| `--display-field-name` | bool | 是否显示字段名(仅 Gallery 生效) |
+| `--json` | JSON | 完整 card 子块对象 |
+
+```bash
+dws aitable view update card --view-id KANBAN_ID --cover-field-id fldAttachment --cover-resize-mode contain
+dws aitable view update card --view-id KANBAN_ID --no-cover
+dws aitable view update card --view-id GALLERY_ID --cover-mode auto
+dws aitable view update card --view-id GALLERY_ID --json '{"coverMode":"custom","coverFieldId":"fldX","displayFieldName":true}'
+```
+
+### view update timebar(仅 Gantt)
+
+| flag | 类型 | 说明 |
+|------|------|------|
+| `--start-field` | string (date fieldId) | 开始日期字段 |
+| `--end-field` | string (date fieldId) | 结束日期字段 |
+| `--display-field-id` | string | 时间条上显示的标题字段 |
+| `--timeline-scale` | string | `year` / `quarter` / `month` / `weeks` |
+| `--color-configs` | JSON 数组 | 颜色配置数组(结构由下游协议定义;清空传 `[]`) |
+| `--official-holiday` | bool | 是否标注法定节假日 |
+| `--json` | JSON | 完整 ganttTimebar 子块 |
+
+```bash
+dws aitable view update timebar --view-id GANTT_ID --start-field fldStart --end-field fldEnd --timeline-scale month
+dws aitable view update timebar --view-id GANTT_ID --official-holiday=true
+```
+
+### view update aggregate(仅 Grid)
+
+值是 `map[fieldId]→AggregateAction string`;传 null 清除某个字段聚合。
+
+| flag | 类型 | 说明 |
+|------|------|------|
+| `--field-id` | string | 配合 `--action` 设置**单字段**聚合 |
+| `--action` | string | `SUM`/`AVG`/`MAX`/`MIN`/`MEDIAN`/`RANGE`/`TOTAL`/`DISTINCT`/`EXIST`/`UN_EXIST`/`CHECKED`/`EARLIEST_DATE` 等(按字段类型可用) |
+| `--clear-field-id` | string (CSV) | 一/多个字段 ID,清除其聚合 |
+| `--json` | JSON | 完整 aggregate map |
+
+```bash
+dws aitable view update aggregate --view-id GRID_ID --field-id fldX --action SUM
+dws aitable view update aggregate --view-id GRID_ID --clear-field-id fldA,fldB
+dws aitable view update aggregate --view-id GRID_ID --json '{"fldX":"AVG","fldY":null}'
+```
+
+### view update field-widths(仅 Grid)
+
+| flag | 类型 |
+|------|------|
+| `--field-id` + `--width` | string + int(单字段) |
+| `--json` | `{fldId: width, ...}` |
+
+```bash
+dws aitable view update field-widths --view-id GRID_ID --field-id fldX --width 200
+dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB":200}'
+```
+
+### view update visible-fields(通用)
+
+整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
+
+> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
+
+| flag | 类型 |
+|------|------|
+| `--field-ids` | string (CSV) |
+| `--json` | string 数组 JSON(与 `--field-ids` 同传时 `--json` 优先) |
+
+```bash
+dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA,fldB
+dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
+```
+
+### view update filter / sort / group(通用,纯 --json)
+
+```bash
+dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'
+dws aitable view update sort --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
+dws aitable view update group --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
+```
+
+> filter/sort/group 入参格式与 `record query --filters`(对象格式)**不同**:view config 这边外层必须是数组。传对象 CLI 会自动 wrap,建议直接用数组。详见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
+
+### view update name(重命名)
+
+```bash
+dws aitable view update name --view-id VIEW_ID --name "新视图名"
+```
+
+等价于 `dws aitable view update --view-id VIEW_ID --name "新视图名"`,无 `config` 参数。
+
+## 服务端字段速查(与 dws CLI 关系)
+
+| dws 子命令 | 服务端 `update_view.config` 子键 | 服务端 Java 模型 |
+|---|---|---|
+| `view update card`(Kanban) | `kanbanCard` | `KanbanCardUpdateInput` |
+| `view update card`(Gallery) | `galleryCard` | `GalleryCardUpdateInput` |
+| `view update timebar` | `ganttTimebar` | `GanttTimebarUpdateInput` |
+| `view update aggregate` | `aggregate` | `Map` |
+| `view update visible-fields` | `visibleFieldIds` | `List` |
+| `view update filter / sort / group` | `filter` / `sort` / `group` | `List