Files
larksuite__cli/shortcuts/base/app_ops.go
xiaomi-bytedance 723f884e9b feat(base): support BaseApp application mode (#2231)
* feat(base): add BaseApp workspace, page and block shortcuts

Implement the CLI layer of the BaseApp CLI/OpenAPI protocol design: 17 new
shortcuts covering workspace entities, blank app creation, page CRUD and page
block CRUD, plus skill references and dry-run E2E for each.

The data_config validator moves to a neutral block_data_config.go with chart
logic unchanged; list and richText dispatch are new branches, so dashboard
behaviour is untouched. Command spaces stay separate — dashboard commands never
take --app-token and app block commands never take --dashboard-id. The one
exception is +app-block-get-data, which shares the dashboard endpoint, execute
and dry-run hooks and therefore takes --base-token instead of --app-token.

This phase ships no +app-block-delete and no page arrange command; both the
help text and the skill docs spell out that a block type cannot be changed
after creation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(base): implement app mode shortcuts and list components

* feat: support deleting BaseApp via drive delete

* fix: 修复 workspace scope

* fix: correct BaseApp permission scopes

* feat: 新增 moveIn workspace 逻辑

* feat(base): support multi-datasource data_config for BaseApp charts

BaseApp page charts follow section 8 图表协议 of the App CLI RPC 协议,
which differs from dashboard charts by supporting multiple data sources:
base_token is a single top-level value shared by every source, while
table_name/series/count_all/group_by/filter move into each data_sources[]
element (plus top-level data_source_mode and sort). The per-source value
semantics are identical to dashboard charts, so each data_sources[] element
reuses normalizeDataConfig / validateChartDataConfig; the wrapper only adds
the top-level structure. Dashboard charts keep the flat shape; the list
protocol is untouched.

- block_data_config.go: add normalizeAppChartDataConfig /
  validateAppChartDataConfig / validateAppBlockDataConfig
- app_block_create/update: route chart blocks to the multi-datasource
  normalize/validate; refresh tips and examples
- reference doc: rewrite the chart section for the multi-datasource shape
- unit + e2e tests: migrate chart cases to data_sources; add a
  multi-datasource combo case

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(base): map BaseApp richText block type to the wire type "text"

The rich-text widget's API type is "text" (App CLI RPC 协议 §10), but the
CLI exposes the friendlier "richText" alias and was sending it verbatim, so
the backend rejected +app-block-create --type richText with "type is
invalid". Map richText -> text when building the request body; the
user-facing --type richText is unchanged. Add TestAppRichTextTypeMapsToText.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(base): remove internal protocol doc link/reference from skills

The BaseApp skill references pointed at an internal Lark doc (deep link with
a private token) as the source of truth, which must not ship in this repo.
Drop the link and the doc name entirely from the reference markdown and from
code comments; describe behavior in neutral terms ("服务端协议 / 服务端返回和校验")
instead. No functional change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: create workspace for BaseApp when omitted

* fix(base): align BaseApp block protocol

* fix: 删掉废弃的 workspace-entity-remove

* fix(base): align app get reference response

* fix(base): align app shortcuts with API contract

* fix(base): refine app mode shortcut contracts

* fix: use entity_type for workspace entity filtering

* fix: return workspace URLs for base workspace ops

* fix(base): enforce unique app block names

* fix(base): use chart token for app block data

* 明确baseapp边界,不导向到dashboard-arrange

* docs(base): clarify app copy is unsupported

* docs(base): define unsupported app page operations

* fix(base): align app block text type with dashboard

AppMode 的文本组件此前对外叫 richText,发送时再映射成 wire 上的 text,
而读取方向没有反向映射:写进去用 richText、读回来是 text,同一个 CLI
表面自相矛盾,回填或幂等复建时会被枚举校验拒掉。

统一成 text,与 Dashboard 文本组件同名同义:
- appBlockTypes/isAppBlockType/textBlockTypes 去掉 richText
- 删除 appBlockBody 里的 richText → text 发送期映射
- help、枚举、示例、tips 与 baseapp block data_config reference 同步
- 新增回归测试,确保 richText 不再被接受也不再出现在枚举里

richText 不保留别名:+app-* 尚未随已发布版本对外,无存量调用方。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(base): resolve BaseApp URLs

Migrate the net changes from bitable/base_cli!1308 onto the current BaseApp development branch.

* fix(base): remove app block list type filter

* docs: preserve explicit intent when reusing BaseApp blocks

* fix(base): route +app-block-get-data to base_apps endpoint

Move the shortcut off the dashboard route and onto the dedicated
BaseApp block-data endpoint:

- URL: /open-apis/base/v3/base_apps/:app_token/blocks/:block_id/data
- base_token is passed as a required query parameter per the new IDL
- Refresh --block-id description and tips to list all producers of the
  chart_token (create/list/get) and note the cht… prefix
- Update the dryrun test to expect the new URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: clarify unsupported BaseApp copy paths

* fix: front-load BaseApp copy stop rule

* fix: surface unsupported PageGroup operations

* fix: preserve PageGroup support boundary

* docs(base): clarify unsupported app block handling

* docs(base): clarify how to read text block content

Text blocks have no /data endpoint; calling +app-block-get-data on
one returns a generic server 500. Point readers at +app-block-get,
whose data_config.text carries the Markdown source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: clarify immutable BaseApp block types

* docs(base): correct chart date filter format

* docs(base): explain inaccessible app pages

* docs: require workspace removal lookup

* fix(base): return workspace move-in result faithfully

* chore(base): adapt BaseApp changes to upstream main

* fix(base): align app mode changes with upstream scope

* fix(base): address app mode review feedback

* test(base): assert app data transport error contract

* fix(base): satisfy app mode merge requirements

* refactor(base): align app mode filenames

* docs: fix BaseApp rename guidance

* fix(base): remove unsupported workspace icon

* docs(base): clarify app mode concepts and config reuse

---------

Co-authored-by: weibiao.x <weibiao.x@bytedance.com>
Co-authored-by: zhangbinkai.zbk <zhangbinkai.zbk@bytedance.com>
Co-authored-by: yurunjie <yurunjie.xx@bytedance.com>
Co-authored-by: Codex <codex@example.com>
2026-08-13 16:20:34 +08:00

640 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ── Shared flags ─────────────────────────────────────────────────────
func workspaceTokenFlag(required bool) common.Flag {
return common.Flag{Name: "workspace-token", Desc: "workspace token", Required: required}
}
func appTokenFlag(required bool) common.Flag {
return common.Flag{Name: "app-token", Desc: "BaseApp token", Required: required}
}
func pageIDFlag(required bool) common.Flag {
return common.Flag{Name: "page-id", Desc: "BaseApp page ID", Required: required}
}
func appBlockIDFlag(required bool) common.Flag {
return common.Flag{Name: "block-id", Desc: "BaseApp page block ID", Required: required}
}
// ── Shared helpers ───────────────────────────────────────────────────
// entityTypeValues are the entity types a workspace can hold.
var entityTypeValues = []string{"base", "baseapp"}
// normalizeEntityType lowercases and validates the workspace entity type.
func normalizeEntityType(raw string) (string, error) {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" {
return "", nil
}
for _, candidate := range entityTypeValues {
if trimmed == candidate {
return trimmed, nil
}
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type 仅支持 base|baseapp,当前值: %s", raw).WithParam("--type")
}
// appBlockBody builds the public create/update request body for app page
// blocks. Layout, position, size and display settings are intentionally not
// exposed because they are outside the public protocol.
func appBlockBody(runtime *common.RuntimeContext, includeType bool) (map[string]interface{}, error) {
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
body["name"] = name
}
if includeType {
if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" {
body["type"] = blockType
}
if strings.EqualFold(strings.TrimSpace(runtime.Str("type")), "list") {
rawSubType := strings.TrimSpace(runtime.Str("sub-type"))
if subType, ok := normalizeAppListSubType(rawSubType); ok && rawSubType != "" {
body["sub_type"] = subType
}
}
}
if raw := strings.TrimSpace(runtime.Str("data-config")); raw != "" {
pc := newParseCtx(runtime)
parsed, err := parseJSONObject(pc, raw, "data-config")
if err != nil {
return nil, err
}
body["data_config"] = parsed
}
return body, nil
}
func pagingParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
params["page_token"] = pageToken
}
return params
}
// ── Workspace: dry-run ───────────────────────────────────────────────
func dryRunWorkspaceCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/workspaces").
Body(workspaceCreateBody(runtime))
}
func workspaceCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}
}
func dryRunWorkspaceEntityList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := pagingParams(runtime)
if entityType, err := normalizeEntityType(runtime.Str("type")); err == nil && entityType != "" {
params["entity_type"] = entityType
}
return common.NewDryRunAPI().
GET("/open-apis/base/v3/workspaces/:workspace_token/entities").
Set("workspace_token", runtime.Str("workspace-token")).
Params(params)
}
func workspaceMoveInBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if token := strings.TrimSpace(runtime.Str("entity-token")); token != "" {
body["entity_token"] = token
}
return body
}
func dryRunWorkspaceMoveIn(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/workspaces/:workspace_token/move_in").
Set("workspace_token", runtime.Str("workspace-token")).
Body(workspaceMoveInBody(runtime))
}
// ── Workspace: execute ───────────────────────────────────────────────
func executeWorkspaceCreate(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("workspaces"), nil, workspaceCreateBody(runtime))
if err != nil {
return err
}
out := map[string]interface{}{"workspace": data, "created": true}
if workspaceToken := strings.TrimSpace(common.GetString(data, "workspace_token")); workspaceToken != "" {
out["workspace_token"] = workspaceToken
}
if workspaceURL := strings.TrimSpace(common.GetString(data, "url")); workspaceURL != "" {
out["url"] = workspaceURL
}
runtime.Out(out, nil)
return nil
}
func executeWorkspaceEntityList(runtime *common.RuntimeContext) error {
params := pagingParams(runtime)
entityType, err := normalizeEntityType(runtime.Str("type"))
if err != nil {
return err
}
if entityType != "" {
params["entity_type"] = entityType
}
data, err := baseV3Call(runtime, "GET", baseV3Path("workspaces", runtime.Str("workspace-token"), "entities"), params, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
func executeWorkspaceMoveIn(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("workspaces", runtime.Str("workspace-token"), "move_in"), nil, workspaceMoveInBody(runtime))
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
// ── BaseApp: dry-run ─────────────────────────────────────────────────
func baseappCreateBodyWithWorkspace(runtime *common.RuntimeContext, workspaceToken string) map[string]interface{} {
body := map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}
if workspaceToken != "" {
body["workspace_token"] = workspaceToken
}
if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" {
body["theme"] = map[string]interface{}{"theme_style": themeStyle}
}
return body
}
func dryRunBaseappCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/base_apps").
Body(baseappCreateBodyWithWorkspace(runtime, strings.TrimSpace(runtime.Str("workspace-token"))))
}
func dryRunBaseappGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/base_apps/:app_token").
Set("app_token", runtime.Str("app-token"))
}
// ── BaseApp: execute ─────────────────────────────────────────────────
func executeBaseappCreate(runtime *common.RuntimeContext) error {
workspaceToken := strings.TrimSpace(runtime.Str("workspace-token"))
app, err := baseV3Call(runtime, "POST", baseV3Path("base_apps"), nil, baseappCreateBodyWithWorkspace(runtime, workspaceToken))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"app": app, "created": true, "workspace_token": workspaceToken}, nil)
return nil
}
func executeBaseappGet(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
// ── Page: dry-run ────────────────────────────────────────────────────
func dryRunBaseappPageList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/base_apps/:app_token/pages").
Set("app_token", runtime.Str("app-token")).
Params(pagingParams(runtime))
}
func dryRunBaseappPageGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/base_apps/:app_token/pages/:page_id").
Set("app_token", runtime.Str("app-token")).
Set("page_id", runtime.Str("page-id"))
}
func baseappPageCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}
}
func dryRunBaseappPageCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/base_apps/:app_token/pages").
Set("app_token", runtime.Str("app-token")).
Body(baseappPageCreateBody(runtime))
}
func dryRunBaseappPageRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PATCH("/open-apis/base/v3/base_apps/:app_token/pages/:page_id").
Set("app_token", runtime.Str("app-token")).
Set("page_id", runtime.Str("page-id")).
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))})
}
func dryRunBaseappPageDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/base_apps/:app_token/pages/:page_id").
Set("app_token", runtime.Str("app-token")).
Set("page_id", runtime.Str("page-id"))
}
// ── Page: execute ────────────────────────────────────────────────────
func executeBaseappPageList(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token"), "pages"), pagingParams(runtime), nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
func executeBaseappPageGet(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"page": data}, nil)
return nil
}
func executeBaseappPageCreate(runtime *common.RuntimeContext) error {
if err := ensureUniqueAppPageName(runtime, strings.TrimSpace(runtime.Str("name")), ""); err != nil {
return err
}
data, err := baseV3Call(runtime, "POST", baseV3Path("base_apps", runtime.Str("app-token"), "pages"), nil, baseappPageCreateBody(runtime))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"page": data, "created": true}, nil)
return nil
}
func executeBaseappPageRename(runtime *common.RuntimeContext) error {
if err := ensureUniqueAppPageName(runtime, strings.TrimSpace(runtime.Str("name")), runtime.Str("page-id")); err != nil {
return err
}
body := map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}
data, err := baseV3Call(runtime, "PATCH", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id")), nil, body)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"page": data, "updated": true}, nil)
return nil
}
func ensureUniqueAppPageName(runtime *common.RuntimeContext, name, excludePageID string) error {
pageToken := ""
for {
params := map[string]interface{}{"page_size": 100}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token"), "pages"), params, nil)
if err != nil {
return err
}
for _, page := range appPageItems(data) {
pageID := firstNonEmpty(common.GetString(page, "page_id"), common.GetString(page, "id"))
if pageID == strings.TrimSpace(excludePageID) {
continue
}
if strings.EqualFold(strings.TrimSpace(common.GetString(page, "name")), name) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "同一应用内 Page 名称必须唯一,已存在名为 %q 的页面", name).WithParam("--name")
}
}
hasMore, _ := data["has_more"].(bool)
pageToken = firstNonEmpty(common.GetString(data, "page_token"), common.GetString(data, "next_page_token"))
if !hasMore || pageToken == "" {
return nil
}
}
}
func appPageItems(data map[string]interface{}) []map[string]interface{} {
for _, key := range []string{"items", "pages"} {
raw, ok := data[key].([]interface{})
if !ok {
continue
}
items := make([]map[string]interface{}, 0, len(raw))
for _, item := range raw {
if page, ok := item.(map[string]interface{}); ok {
items = append(items, page)
}
}
return items
}
return nil
}
func executeBaseappPageDelete(runtime *common.RuntimeContext) error {
_, err := baseV3Call(runtime, "DELETE", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"deleted": true, "page_id": runtime.Str("page-id")}, nil)
return nil
}
// ── App block: dry-run ───────────────────────────────────────────────
func dryRunAppBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/base_apps/:app_token/pages/:page_id/blocks").
Set("app_token", runtime.Str("app-token")).
Set("page_id", runtime.Str("page-id")).
Params(pagingParams(runtime))
}
func dryRunAppBlockGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/base_apps/:app_token/pages/:page_id/blocks/:block_id").
Set("app_token", runtime.Str("app-token")).
Set("page_id", runtime.Str("page-id")).
Set("block_id", runtime.Str("block-id"))
}
func dryRunAppBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := appBlockBody(runtime, true)
if err != nil {
body = map[string]interface{}{}
}
return common.NewDryRunAPI().
POST("/open-apis/base/v3/base_apps/:app_token/pages/:page_id/blocks").
Set("app_token", runtime.Str("app-token")).
Set("page_id", runtime.Str("page-id")).
Body(body)
}
func dryRunAppBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := appBlockBody(runtime, false)
if err != nil {
body = map[string]interface{}{}
}
return common.NewDryRunAPI().
PATCH("/open-apis/base/v3/base_apps/:app_token/pages/:page_id/blocks/:block_id").
Set("app_token", runtime.Str("app-token")).
Set("page_id", runtime.Str("page-id")).
Set("block_id", runtime.Str("block-id")).
Body(body)
}
// ── App block: execute ───────────────────────────────────────────────
func executeAppBlockList(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id"), "blocks"), pagingParams(runtime), nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
func executeAppBlockGet(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id"), "blocks", runtime.Str("block-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data}, nil)
return nil
}
func executeAppBlockCreate(runtime *common.RuntimeContext) error {
if err := ensureUniqueAppBlockName(runtime, strings.TrimSpace(runtime.Str("name")), ""); err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(runtime.Str("type")), "list") {
if err := validateListBaseWorkspace(runtime); err != nil {
return err
}
}
body, err := appBlockBody(runtime, true)
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id"), "blocks"), nil, body)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
return nil
}
func ensureUniqueAppBlockName(runtime *common.RuntimeContext, name, excludeBlockID string) error {
pageToken := ""
for {
params := map[string]interface{}{"page_size": 100}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := baseV3Call(runtime, "GET", baseV3Path(
"base_apps", runtime.Str("app-token"),
"pages", runtime.Str("page-id"),
"blocks",
), params, nil)
if err != nil {
return err
}
for _, block := range appBlockItems(data) {
blockID := firstNonEmpty(common.GetString(block, "block_id"), common.GetString(block, "id"), common.GetString(block, "widget_id"))
if excludeBlockID != "" && strings.TrimSpace(blockID) == excludeBlockID {
continue
}
if strings.EqualFold(strings.TrimSpace(common.GetString(block, "name")), name) {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"同一 Page 内组件名称必须唯一,已存在名为 %q 的组件",
name,
).WithParam("--name")
}
}
hasMore, _ := data["has_more"].(bool)
pageToken = firstNonEmpty(common.GetString(data, "page_token"), common.GetString(data, "next_page_token"))
if !hasMore || pageToken == "" {
return nil
}
}
}
func appBlockItems(data map[string]interface{}) []map[string]interface{} {
for _, key := range []string{"items", "blocks", "widgets"} {
raw, ok := data[key].([]interface{})
if !ok {
continue
}
items := make([]map[string]interface{}, 0, len(raw))
for _, item := range raw {
if block, ok := item.(map[string]interface{}); ok {
items = append(items, block)
}
}
return items
}
return nil
}
func validateListBaseWorkspace(runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("data-config"))
if raw == "" {
return nil
}
cfg, err := parseJSONObject(newParseCtx(runtime), raw, "data-config")
if err != nil {
return err
}
baseToken := strings.TrimSpace(common.GetString(cfg, "base_token"))
if baseToken == "" {
return nil
}
app, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token")), nil, nil)
if err != nil {
return err
}
if appRefContainsBase(app["ref"], baseToken) {
return nil
}
workspaceToken := firstNonEmpty(
strings.TrimSpace(common.GetString(app, "workspace_token")),
strings.TrimSpace(common.GetString(app, "workspace_id")),
)
if workspaceToken == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "无法确认列表 Base 与 App 是否位于同一 Workspace:App 响应缺少 workspace_token").WithParam("--data-config")
}
pageToken := ""
for {
params := map[string]interface{}{"page_size": 100, "entity_type": "base"}
if pageToken != "" {
params["page_token"] = pageToken
}
entities, err := baseV3Call(runtime, "GET", baseV3Path("workspaces", workspaceToken, "entities"), params, nil)
if err != nil {
return err
}
if workspaceContainsBase(entities, baseToken) {
return nil
}
hasMore, _ := entities["has_more"].(bool)
pageToken = firstNonEmpty(common.GetString(entities, "page_token"), common.GetString(entities, "next_page_token"))
if !hasMore || pageToken == "" {
break
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "列表组件只能选择 App 所在 Workspace 内的一个 Base;%s 不在当前 Workspace", baseToken).WithParam("--data-config")
}
func appRefContainsBase(raw interface{}, baseToken string) bool {
switch refs := raw.(type) {
case map[string]interface{}:
_, ok := refs[baseToken]
return ok
case map[string][]string:
_, ok := refs[baseToken]
return ok
default:
return false
}
}
func workspaceContainsBase(data map[string]interface{}, baseToken string) bool {
for _, key := range []string{"items", "entities"} {
items, _ := data[key].([]interface{})
for _, raw := range items {
entity, _ := raw.(map[string]interface{})
token := firstNonEmpty(common.GetString(entity, "token"), common.GetString(entity, "entity_token"))
if strings.TrimSpace(token) == baseToken {
return true
}
}
}
return false
}
func executeAppBlockUpdate(runtime *common.RuntimeContext) error {
blockID := strings.TrimSpace(runtime.Str("block-id"))
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
if err := ensureUniqueAppBlockName(runtime, name, blockID); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("data-config")) != "" {
current, err := baseV3Call(runtime, "GET", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id"), "blocks", blockID), nil, nil)
if err != nil {
return err
}
if err := validateAppBlockUpdateForCurrentBlock(runtime, current); err != nil {
return err
}
blockType := firstNonEmpty(common.GetString(current, "type"), common.GetString(current, "block_type"))
if strings.EqualFold(strings.TrimSpace(blockType), "list") {
if err := validateListBaseWorkspace(runtime); err != nil {
return err
}
}
}
body, err := appBlockBody(runtime, false)
if err != nil {
return err
}
data, err := baseV3Call(runtime, "PATCH", baseV3Path("base_apps", runtime.Str("app-token"), "pages", runtime.Str("page-id"), "blocks", blockID), nil, body)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "updated": true}, nil)
return nil
}
func validateAppBlockUpdateForCurrentBlock(runtime *common.RuntimeContext, current map[string]interface{}) error {
if runtime.Bool("no-validate") {
return nil
}
patch, err := parseJSONObject(newParseCtx(runtime), strings.TrimSpace(runtime.Str("data-config")), "data-config")
if err != nil {
return err
}
currentConfig := common.GetMap(current, "data_config")
merged := cloneMap(currentConfig)
if merged == nil {
merged = map[string]interface{}{}
}
for key, value := range patch {
merged[key] = value
}
blockType := strings.TrimSpace(firstNonEmpty(common.GetString(current, "type"), common.GetString(current, "block_type")))
var problems []string
switch {
case strings.EqualFold(blockType, "list"):
subType, ok := normalizeAppListSubType(firstNonEmpty(common.GetString(current, "sub_type"), common.GetString(current, "subType")))
if !ok {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "当前列表组件 sub_type 不受 CLI 支持: %s", subType).WithParam("--data-config")
}
problems = validateAppListDataConfig(subType, merged)
case isTextBlockType(blockType):
problems = validateAppBlockDataConfig(blockType, merged)
case isChartBlockType(blockType):
problems = validateAppBlockDataConfig(blockType, normalizeAppChartDataConfig(merged))
default:
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "当前组件类型 %q 不支持通过 CLI 更新 data_config", blockType).WithParam("--data-config")
}
return formatDataConfigErrors(problems)
}