mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
723f884e9b
* 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>
640 lines
23 KiB
Go
640 lines
23 KiB
Go
// 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)
|
||
}
|