mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:20:00 +08:00
feat: Add whiteboard +query shortcut and enhance +update with Mermaid/PlantUML support (#382)
Change-Id: I719935bb8fee337908ec99d59f1dfaae0df74874
This commit is contained in:
@@ -134,18 +134,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
domainSet := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domainSet[p] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domainSet[sc.Service] = true
|
||||
}
|
||||
selectedDomains = make([]string, 0, len(domainSet))
|
||||
for d := range domainSet {
|
||||
selectedDomains = append(selectedDomains, d)
|
||||
}
|
||||
sort.Strings(selectedDomains)
|
||||
selectedDomains = sortedKnownDomains()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -451,6 +440,8 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
|
||||
// collectScopesForDomains collects API scopes (from from_meta projects) and
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
@@ -459,11 +450,16 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
|
||||
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
// 2. Expand domains: include auth_domain children
|
||||
domainSet := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
domainSet[d] = true
|
||||
for _, child := range registry.GetAuthChildren(d) {
|
||||
domainSet[child] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
@@ -472,7 +468,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate and sort
|
||||
// 4. Deduplicate and sort
|
||||
result := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
result = append(result, s)
|
||||
@@ -481,14 +477,20 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domains[p] = true
|
||||
if !registry.HasAuthDomain(p) {
|
||||
domains[p] = true
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
seen := make(map[string]bool)
|
||||
var domains []domainMeta
|
||||
|
||||
// 1. Domains from from_meta projects
|
||||
// 1. Domains from from_meta projects (skip domains with auth_domain)
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
if registry.HasAuthDomain(project) {
|
||||
seen[project] = true
|
||||
continue
|
||||
}
|
||||
dm := buildDomainMeta(project, lang)
|
||||
domains = append(domains, dm)
|
||||
seen[project] = true
|
||||
@@ -52,13 +56,14 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
}
|
||||
|
||||
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
|
||||
// (skip domains with auth_domain — they are folded into their parent)
|
||||
shortcutOnlySet := make(map[string]bool)
|
||||
for _, n := range shortcutOnlyNames {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
|
||||
@@ -903,3 +903,37 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
if !domains["docs"] {
|
||||
t.Error("docs should still be a known auth domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
if strings.HasPrefix(s, "board:whiteboard:") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Name == "whiteboard" {
|
||||
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,3 +564,54 @@ func TestCollectScopesForProjects_NonexistentProject(t *testing.T) {
|
||||
t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes))
|
||||
}
|
||||
}
|
||||
|
||||
// --- auth_domain functions ---
|
||||
|
||||
func TestGetAuthDomain_Configured(t *testing.T) {
|
||||
// whiteboard has auth_domain: "docs" in service_descriptions.json
|
||||
if got := GetAuthDomain("whiteboard"); got != "docs" {
|
||||
t.Errorf("GetAuthDomain(whiteboard) = %q, want %q", got, "docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthDomain_NotConfigured(t *testing.T) {
|
||||
if got := GetAuthDomain("calendar"); got != "" {
|
||||
t.Errorf("GetAuthDomain(calendar) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthDomain_Unknown(t *testing.T) {
|
||||
if got := GetAuthDomain("nonexistent_xyz"); got != "" {
|
||||
t.Errorf("GetAuthDomain(nonexistent_xyz) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAuthDomain(t *testing.T) {
|
||||
if !HasAuthDomain("whiteboard") {
|
||||
t.Error("HasAuthDomain(whiteboard) = false, want true")
|
||||
}
|
||||
if HasAuthDomain("calendar") {
|
||||
t.Error("HasAuthDomain(calendar) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthChildren(t *testing.T) {
|
||||
children := GetAuthChildren("docs")
|
||||
found := false
|
||||
for _, c := range children {
|
||||
if c == "whiteboard" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetAuthChildren(docs) = %v, want to contain 'whiteboard'", children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthChildren_NoChildren(t *testing.T) {
|
||||
children := GetAuthChildren("calendar")
|
||||
if len(children) != 0 {
|
||||
t.Errorf("GetAuthChildren(calendar) = %v, want empty", children)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ type serviceDescLocale struct {
|
||||
|
||||
// serviceDescEntry holds bilingual descriptions for a service domain.
|
||||
type serviceDescEntry struct {
|
||||
En serviceDescLocale `json:"en"`
|
||||
Zh serviceDescLocale `json:"zh"`
|
||||
En serviceDescLocale `json:"en"`
|
||||
Zh serviceDescLocale `json:"zh"`
|
||||
AuthDomain string `json:"auth_domain,omitempty"`
|
||||
}
|
||||
|
||||
var serviceDescMap map[string]serviceDescEntry
|
||||
@@ -76,3 +77,31 @@ func GetServiceDetailDescription(name, lang string) string {
|
||||
}
|
||||
return loc.Description
|
||||
}
|
||||
|
||||
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
|
||||
// When auth_domain is set, the service's scopes are collected under the
|
||||
// parent domain during auth login.
|
||||
func GetAuthDomain(service string) string {
|
||||
m := loadServiceDescriptions()
|
||||
if entry, ok := m[service]; ok {
|
||||
return entry.AuthDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasAuthDomain reports whether the service has an auth_domain configured.
|
||||
func HasAuthDomain(service string) bool {
|
||||
return GetAuthDomain(service) != ""
|
||||
}
|
||||
|
||||
// GetAuthChildren returns all service names whose auth_domain equals parent.
|
||||
func GetAuthChildren(parent string) []string {
|
||||
m := loadServiceDescriptions()
|
||||
var children []string
|
||||
for name, entry := range m {
|
||||
if entry.AuthDomain == parent {
|
||||
children = append(children, name)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
},
|
||||
"whiteboard": {
|
||||
"en": { "title": "Whiteboard", "description": "Create and edit boards" },
|
||||
"zh": { "title": "画板", "description": "画板创建、编辑" }
|
||||
"zh": { "title": "画板", "description": "画板创建、编辑" },
|
||||
"auth_domain": "docs"
|
||||
},
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
|
||||
@@ -11,12 +11,15 @@ import (
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WhiteboardUpdate,
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardQuery,
|
||||
}
|
||||
}
|
||||
|
||||
type WbCliOutput struct {
|
||||
Code int `json:"code"`
|
||||
Data WbCliOutputData
|
||||
Code int `json:"code"`
|
||||
Data WbCliOutputData
|
||||
RawNodes []interface{} `json:"nodes"` // 从 whiteboard-cli -t openapi 输出的原始请求格式
|
||||
}
|
||||
|
||||
type WbCliOutputData struct {
|
||||
|
||||
376
shortcuts/whiteboard/whiteboard_query.go
Normal file
376
shortcuts/whiteboard/whiteboard_query.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
WhiteboardQueryAsImage = "image"
|
||||
WhiteboardQueryAsCode = "code"
|
||||
WhiteboardQueryAsRaw = "raw"
|
||||
)
|
||||
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | code | raw.", Required: true},
|
||||
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: true,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := validate.RejectControlChars(token, "whiteboard-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if err := runtime.ValidatePath(out); err != nil {
|
||||
return output.ErrValidation("invalid output path: %s", err)
|
||||
}
|
||||
}
|
||||
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
|
||||
return output.ErrValidation("need a output directory to query whiteboard as image")
|
||||
}
|
||||
|
||||
as := runtime.Str("output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return common.FlagErrorf("--output_as flag must be one of: image | code | raw")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
as := runtime.Str("output_as")
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardQueryAsCode:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml code from given whiteboard")
|
||||
case WhiteboardQueryAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
default:
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | code | raw")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 构建 API 请求
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
as := runtime.Str("output_as")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardQueryAsCode:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardQueryAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return output.ErrValidation("--as flag must be one of: image | code | raw")
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("get whiteboard preview failed: %v", err))
|
||||
}
|
||||
// Check response status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", wbToken),
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork(fmt.Sprintf("get whiteboard nodes failed: %v", err))
|
||||
}
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
err = json.Unmarshal(resp.RawBody, &nodes)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if nodes.Code != 0 {
|
||||
return nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "json_error", "cannot marshal whiteboard data: %s", err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if err := runtime.ValidatePath(finalPath); err != nil { // double check
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, output.ErrValidation(fmt.Sprintf("file already exists: %s (use --overwrite to overwrite)", finalPath))
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, output.Errorf(output.ExitInternal, "io_error", "cannot check file existence: %s", err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, common.WrapSaveError(err, "unsafe file path", "cannot create parent directory", "cannot create file")
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
749
shortcuts/whiteboard/whiteboard_query_test.go
Normal file
749
shortcuts/whiteboard/whiteboard_query_test.go
Normal file
@@ -0,0 +1,749 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSyntaxType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
st SyntaxType
|
||||
wantStr string
|
||||
wantExt string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "PlantUML",
|
||||
st: SyntaxTypePlantUML,
|
||||
wantStr: "plantuml",
|
||||
wantExt: ".puml",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "Mermaid",
|
||||
st: SyntaxTypeMermaid,
|
||||
wantStr: "mermaid",
|
||||
wantExt: ".mmd",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "invalid type 0",
|
||||
st: SyntaxType(0),
|
||||
wantStr: "",
|
||||
wantExt: "",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid type 3",
|
||||
st: SyntaxType(3),
|
||||
wantStr: "",
|
||||
wantExt: "",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.st.String(); got != tt.wantStr {
|
||||
t.Errorf("SyntaxType.String() = %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
if got := tt.st.ExtensionName(); got != tt.wantExt {
|
||||
t.Errorf("SyntaxType.ExtensionName() = %q, want %q", got, tt.wantExt)
|
||||
}
|
||||
if got := tt.st.IsValid(); got != tt.wantValid {
|
||||
t.Errorf("SyntaxType.IsValid() = %v, want %v", got, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQuery_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
boolFlags map[string]bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid: image with output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "image",
|
||||
"output": "output.png",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: code without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "code",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: raw without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "raw",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid: image without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "image",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid: bad output_as value",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid: with overwrite flag",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "code",
|
||||
"output": "output.puml",
|
||||
},
|
||||
boolFlags: map[string]bool{
|
||||
"overwrite": true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, tt.boolFlags)
|
||||
err := WhiteboardQuery.Validate(ctx, rt)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WhiteboardQuery.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantMethod string
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "dry run image",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "image",
|
||||
"output": "output.png",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
|
||||
},
|
||||
{
|
||||
name: "dry run code",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "code",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
},
|
||||
{
|
||||
name: "dry run raw",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "raw",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, nil)
|
||||
dryRun := WhiteboardQuery.DryRun(ctx, rt)
|
||||
if dryRun == nil {
|
||||
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify WhiteboardQuery is properly configured
|
||||
if WhiteboardQuery.Command != "+query" {
|
||||
t.Errorf("WhiteboardQuery.Command = %q, want \"+query\"", WhiteboardQuery.Command)
|
||||
}
|
||||
if WhiteboardQuery.Service != "whiteboard" {
|
||||
t.Errorf("WhiteboardQuery.Service = %q, want \"whiteboard\"", WhiteboardQuery.Service)
|
||||
}
|
||||
if len(WhiteboardQuery.Scopes) == 0 {
|
||||
t.Errorf("WhiteboardQuery.Scopes is empty, expected at least one scope")
|
||||
}
|
||||
if len(WhiteboardQuery.Flags) == 0 {
|
||||
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOutputFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temp dir and cd into it
|
||||
chdirTemp(t)
|
||||
|
||||
// Create a subdirectory for testing directory output
|
||||
err := os.Mkdir("testdir", 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
outPath string
|
||||
ext string
|
||||
token string
|
||||
overwrite bool
|
||||
setupFile bool
|
||||
wantPath string
|
||||
wantErr bool
|
||||
checkPath bool
|
||||
}{
|
||||
{
|
||||
name: "path is directory",
|
||||
outPath: "testdir",
|
||||
ext: ".puml",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: filepath.Join("testdir", "whiteboard_token123.puml"),
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "path has correct extension",
|
||||
outPath: "output.puml",
|
||||
ext: ".puml",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: "output.puml",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "path has different extension",
|
||||
outPath: "output.txt",
|
||||
ext: ".puml",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: "output.puml",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "path has no extension",
|
||||
outPath: "output",
|
||||
ext: ".json",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: "output.json",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "file exists without overwrite",
|
||||
outPath: "existing.txt",
|
||||
ext: ".txt",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: true,
|
||||
wantPath: "existing.txt",
|
||||
wantErr: true,
|
||||
checkPath: false,
|
||||
},
|
||||
{
|
||||
name: "file exists with overwrite",
|
||||
outPath: "overwrite.txt",
|
||||
ext: ".txt",
|
||||
token: "token123",
|
||||
overwrite: true,
|
||||
setupFile: true,
|
||||
wantPath: "overwrite.txt",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup test file if needed
|
||||
if tt.setupFile {
|
||||
err := os.WriteFile(tt.wantPath, []byte("existing content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
defer os.Remove(tt.wantPath)
|
||||
}
|
||||
|
||||
rt := newTestRuntime(nil, map[string]bool{"overwrite": tt.overwrite})
|
||||
testData := strings.NewReader("test content")
|
||||
|
||||
gotPath, size, err := saveOutputFile(tt.outPath, tt.ext, tt.token, rt, testData)
|
||||
defer func() {
|
||||
if gotPath != "" {
|
||||
os.Remove(gotPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("saveOutputFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if tt.checkPath {
|
||||
// Check if path is correct
|
||||
if tt.outPath == "testdir" {
|
||||
// For directory case, just check extension and dir
|
||||
if filepath.Ext(gotPath) != tt.ext {
|
||||
t.Errorf("saveOutputFile() extension = %q, want %q", filepath.Ext(gotPath), tt.ext)
|
||||
}
|
||||
if filepath.Dir(gotPath) != "testdir" {
|
||||
t.Errorf("saveOutputFile() dir = %q, want %q", filepath.Dir(gotPath), "testdir")
|
||||
}
|
||||
} else {
|
||||
// For file case, check exact path
|
||||
if gotPath != tt.wantPath {
|
||||
t.Errorf("saveOutputFile() path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
}
|
||||
// Check if file was written
|
||||
content, err := os.ReadFile(gotPath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read saved file: %v", err)
|
||||
}
|
||||
if string(content) != "test content" {
|
||||
t.Errorf("File content = %q, want %q", string(content), "test content")
|
||||
}
|
||||
if size != int64(len("test content")) {
|
||||
t.Errorf("File size = %d, want %d", size, len("test content"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
}
|
||||
|
||||
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
// Temporarily lower risk for testing
|
||||
originalRisk := shortcut.Risk
|
||||
shortcut.Risk = "read"
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
|
||||
parent := &cobra.Command{Use: "whiteboard"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
err := parent.ExecuteContext(context.Background())
|
||||
|
||||
// Restore original risk
|
||||
shortcut.Risk = originalRisk
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{"id": "node1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-123", "--output_as", "raw"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"nodes"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
// Mock nodes API response with code block
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "graph TD\nA-->B",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-123", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with empty nodes
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-empty/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": nil,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-empty", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with no syntax blocks
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-nocode/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{"id": "node1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-nocode", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with invalid syntax type
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-invalid-syntax/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "some code",
|
||||
"syntax_type": float64(999), // invalid type
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-invalid-syntax", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with multiple code blocks
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-multiple/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "graph TD\nA-->B",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "classDiagram\nclass A",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-multiple", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "multiple code blocks found") {
|
||||
t.Fatalf("stdout missing multiple blocks message: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with single PlantUML code block
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-single-plantuml/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "@startuml\n:start;\n:process;\n@enduml",
|
||||
"syntax_type": float64(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-single-plantuml", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "@startuml") {
|
||||
t.Fatalf("stdout missing plantuml code: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with single Mermaid code block
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-single-mermaid/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "flowchart TD\n A --> B",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-single-mermaid", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "flowchart TD") {
|
||||
t.Fatalf("stdout missing mermaid code: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
chdirTemp(t)
|
||||
|
||||
// Mock download preview image API response with RawBody
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
// Verify the file was written with .png extension
|
||||
data, err := os.ReadFile("output.png")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake PNG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake PNG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with empty nodes
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-empty/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": nil,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-raw-empty", "--output_as", "raw"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWhiteboardNodes_APIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with error code
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-api-error/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10001,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-api-error", "--output_as", "raw"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Fatalf("Expected API error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRuntime creates a RuntimeContext with string flags for testing.
|
||||
func newTestRuntime(flags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range flags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
// Parse empty args so flags have defaults, then set values.
|
||||
cmd.ParseFlags(nil)
|
||||
for name, val := range flags {
|
||||
cmd.Flags().Set(name, val)
|
||||
}
|
||||
for name, val := range boolFlags {
|
||||
if val {
|
||||
cmd.Flags().Set(name, "true")
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// chdirTemp changes the working directory to a fresh temp directory and
|
||||
// restores it when the test finishes.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,137 +19,161 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatRaw = "raw"
|
||||
FormatPlantUML = "plantuml"
|
||||
FormatMermaid = "mermaid"
|
||||
)
|
||||
|
||||
var formatCodeMap = map[string]int{
|
||||
FormatRaw: 0,
|
||||
FormatPlantUML: 1,
|
||||
FormatMermaid: 2,
|
||||
}
|
||||
|
||||
var wbUpdateScopes = []string{"board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:delete"}
|
||||
var wbUpdateAuthTypes = []string{"user", "bot"}
|
||||
var skipDeleteNodesBatchSleep = false // for accelerate UT testing only
|
||||
var wbUpdateFlags = []common.Flag{
|
||||
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
|
||||
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
|
||||
{Name: "source", Desc: "Input whiteboard data.", Required: true, Input: []string{common.Stdin, common.File}},
|
||||
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid. Default is raw.", Required: false},
|
||||
}
|
||||
|
||||
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
|
||||
if err := validate.RejectControlChars(runtime.Str("whiteboard-token"), "whiteboard-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
itoken := runtime.Str("idempotent-token")
|
||||
if err := validate.RejectControlChars(itoken, "idempotent-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if itoken != "" && len(itoken) < 10 {
|
||||
return common.FlagErrorf("--idempotent-token must be at least 10 characters long.")
|
||||
}
|
||||
|
||||
// 检查 --input_format 标志
|
||||
format := getFormat(runtime)
|
||||
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid {
|
||||
return common.FlagErrorf("--input_format must be one of: raw | plantuml | mermaid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFormat 获取 format,默认返回 raw
|
||||
func getFormat(runtime *common.RuntimeContext) string {
|
||||
format := runtime.Str("input_format")
|
||||
if format == "" {
|
||||
return FormatRaw
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// 读取输入内容
|
||||
input := runtime.Str("source")
|
||||
if input == "" {
|
||||
return common.NewDryRunAPI().Desc("read input failed: source is required")
|
||||
}
|
||||
format := getFormat(runtime)
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
descStr := "will call whiteboard open api to update content."
|
||||
var delNum int
|
||||
var err error
|
||||
if overwrite {
|
||||
// 还是会读取一下 whiteboard nodes,确认是否有节点要删除
|
||||
delNum, _, err = clearWhiteboardContent(ctx, runtime, token, []string{}, true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read whiteboard nodes failed: " + err.Error())
|
||||
}
|
||||
if delNum > 0 {
|
||||
descStr += fmt.Sprintf(" %d existing nodes deleted before update.", delNum)
|
||||
}
|
||||
}
|
||||
|
||||
desc := common.NewDryRunAPI().Desc(descStr)
|
||||
|
||||
switch format {
|
||||
case FormatRaw:
|
||||
nodes, err, _ := parseWBcliNodes([]byte(input))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(nodes).Desc("create all nodes of the whiteboard.")
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
syntaxType := formatCodeMap[format]
|
||||
reqBody := plantumlCreateReq{
|
||||
PlantUmlCode: input,
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0,
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc(fmt.Sprintf("create %s node on the whiteboard.", format))
|
||||
}
|
||||
|
||||
if overwrite && delNum > 0 {
|
||||
// 在 DryRun 中只记录意图,不实际拉取和计算节点
|
||||
desc.GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Desc("get all nodes of the whiteboard to delete, then filter out newly created ones.")
|
||||
desc.DELETE(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))).Body("{\"ids\":[\"...\"]}").
|
||||
Desc(fmt.Sprintf("delete all old nodes of the whiteboard 100 nodes at a time. This API may be called multiple times and is not reversible. %d whiteboard nodes will be deleted while update.", delNum))
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
idempotentToken := runtime.Str("idempotent-token")
|
||||
format := getFormat(runtime)
|
||||
|
||||
input := runtime.Str("source")
|
||||
if input == "" {
|
||||
return output.ErrValidation("read input failed: source is required")
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatRaw:
|
||||
return updateWhiteboardByRawNodes(ctx, runtime, token, []byte(input), overwrite, idempotentToken)
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
|
||||
default:
|
||||
return output.ErrValidation(fmt.Sprintf("unsupported format: %s", format))
|
||||
}
|
||||
}
|
||||
|
||||
const WhiteboardUpdateDescription = "Update an existing whiteboard in lark document with mermaid, plantuml or whiteboard dsl. refer to lark-whiteboard skill for more details."
|
||||
|
||||
var WhiteboardUpdate = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+update",
|
||||
Description: WhiteboardUpdateDescription,
|
||||
Risk: "high-risk-write",
|
||||
Scopes: wbUpdateScopes,
|
||||
AuthTypes: wbUpdateAuthTypes,
|
||||
Flags: wbUpdateFlags,
|
||||
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
|
||||
Validate: wbUpdateValidate,
|
||||
DryRun: wbUpdateDryRun,
|
||||
Execute: wbUpdateExecute,
|
||||
}
|
||||
|
||||
// WhiteboardUpdateOld 向前兼容历史版本 Doc 域下的更新命令
|
||||
var WhiteboardUpdateOld = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+whiteboard-update",
|
||||
Description: "Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details.",
|
||||
Description: WhiteboardUpdateDescription,
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:delete"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
|
||||
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
|
||||
if err := validate.RejectControlChars(runtime.Str("whiteboard-token"), "whiteboard-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
itoken := runtime.Str("idempotent-token")
|
||||
if err := validate.RejectControlChars(itoken, "idempotent-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if itoken != "" && len(itoken) < 10 {
|
||||
return common.FlagErrorf("--idempotent-token must be at least 10 characters long.")
|
||||
}
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil || (stat.Mode()&os.ModeCharDevice) != 0 {
|
||||
return output.ErrValidation("read stdin failed, please follow lark-whiteboard skill to pipe in input data")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// 读取 stdin 内容,解析为 OAPI 参数
|
||||
input, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read stdin failed: " + err.Error())
|
||||
}
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(input, &wbOutput); err != nil {
|
||||
return common.NewDryRunAPI().Desc("unmarshal stdin json failed: " + err.Error())
|
||||
}
|
||||
if wbOutput.Code != 0 || wbOutput.Data.To != "openapi" {
|
||||
return common.NewDryRunAPI().Desc("whiteboard-draw failed. please check previous log.")
|
||||
}
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
descStr := "will call whiteboard open api to draw such DSL content."
|
||||
var delNum int
|
||||
if overwrite {
|
||||
// 还是会读取一下 whiteboard nodes,确认是否有节点要删除
|
||||
delNum, _, err = clearWhiteboardContent(ctx, runtime, token, []string{}, true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read whiteboard nodes failed: " + err.Error())
|
||||
}
|
||||
if delNum > 0 {
|
||||
descStr += fmt.Sprintf("%d existing nodes deleted before update.", delNum)
|
||||
}
|
||||
}
|
||||
desc := common.NewDryRunAPI().Desc(descStr)
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(wbOutput.Data.Result).Desc("create all nodes of the whiteboard.")
|
||||
if overwrite && delNum > 0 {
|
||||
// 在 DryRun 中只记录意图,不实际拉取和计算节点
|
||||
desc.GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Desc("get all nodes of the whiteboard to delete, then filter out newly created ones.")
|
||||
desc.DELETE(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))).Body("{\"ids\":[\"...\"]}").
|
||||
Desc(fmt.Sprintf("delete all old nodes of the whiteboard 100 nodes at a time. This API may be called multiple times and is not reversible. %d whiteboard nodes will be deleted while update.", delNum))
|
||||
}
|
||||
return desc
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
idempotentToken := runtime.Str("idempotent-token")
|
||||
// 读取 stdin 内容,解析为 OAPI 参数
|
||||
input, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return output.ErrValidation("read stdin failed: " + err.Error())
|
||||
}
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(input, &wbOutput); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("unmarshal stdin json failed: %v", err))
|
||||
}
|
||||
if wbOutput.Code != 0 || wbOutput.Data.To != "openapi" {
|
||||
return output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-draw failed. please check previous log.")
|
||||
}
|
||||
outData := make(map[string]string)
|
||||
// 写入画板节点
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(token)),
|
||||
Body: wbOutput.Data.Result,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var createResp createResponse
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard failed", fmt.Sprintf("update whiteboard failed: %s", createResp.Msg))
|
||||
}
|
||||
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
|
||||
// 清空画板节点,先写后删,起码新的能写进去
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, token, createResp.Data.NodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
|
||||
}
|
||||
fmt.Fprintf(w, "update whiteboard success")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
Scopes: wbUpdateScopes,
|
||||
AuthTypes: wbUpdateAuthTypes,
|
||||
Flags: wbUpdateFlags,
|
||||
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
|
||||
Validate: wbUpdateValidate,
|
||||
DryRun: wbUpdateDryRun,
|
||||
Execute: wbUpdateExecute,
|
||||
}
|
||||
|
||||
type createResponse struct {
|
||||
@@ -173,7 +195,8 @@ type simpleNodeResp struct {
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
Id string `json:"id"`
|
||||
Id string `json:"id"`
|
||||
Children []string `json:"children"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -182,6 +205,42 @@ type deleteNodeReqBody struct {
|
||||
Ids []string `json:"ids"`
|
||||
}
|
||||
|
||||
type plantumlCreateReq struct {
|
||||
PlantUmlCode string `json:"plant_uml_code"`
|
||||
SyntaxType int `json:"syntax_type"`
|
||||
DiagramType int `json:"diagram_type,omitempty"`
|
||||
ParseMode int `json:"parse_mode,omitempty"`
|
||||
}
|
||||
|
||||
type plantumlCreateResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
NodeID string `json:"node_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func parseWBcliNodes(rawjson []byte) (wbNodes interface{}, err error, isRaw bool) {
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "parsing", fmt.Sprintf("unmarshal input json failed: %v", err)), false
|
||||
}
|
||||
if (wbOutput.Code != 0 || wbOutput.Data.To != "openapi") && wbOutput.RawNodes == nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
}
|
||||
if wbOutput.RawNodes != nil {
|
||||
wbNodes = struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
}{
|
||||
Nodes: wbOutput.RawNodes,
|
||||
}
|
||||
isRaw = true
|
||||
} else {
|
||||
wbNodes = wbOutput.Data.Result
|
||||
}
|
||||
return wbNodes, nil, isRaw
|
||||
}
|
||||
|
||||
func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext, wbToken string, newNodeIDs []string, dryRun bool) (int, []string, error) {
|
||||
resp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
@@ -201,6 +260,39 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
if nodes.Code != 0 {
|
||||
return 0, nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
|
||||
}
|
||||
|
||||
// 收集所有新节点及其 children 的 ID,递归处理
|
||||
protectedIDs := make(map[string]bool)
|
||||
for _, id := range newNodeIDs {
|
||||
protectedIDs[id] = true
|
||||
}
|
||||
// 构建 node map 以便快速查找
|
||||
nodeMap := make(map[string][]string)
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
nodeMap[node.Id] = node.Children
|
||||
}
|
||||
}
|
||||
// 递归收集所有 children
|
||||
visited := make(map[string]bool)
|
||||
var collectChildren func(id string)
|
||||
collectChildren = func(id string) {
|
||||
if visited[id] {
|
||||
return
|
||||
}
|
||||
visited[id] = true
|
||||
if children, ok := nodeMap[id]; ok {
|
||||
for _, child := range children {
|
||||
protectedIDs[child] = true
|
||||
collectChildren(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range newNodeIDs {
|
||||
collectChildren(id)
|
||||
}
|
||||
|
||||
// 确定要删除的节点
|
||||
nodeIds := make([]string, 0, len(nodes.Data.Nodes))
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
@@ -209,7 +301,7 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
delIds := make([]string, 0, len(nodeIds))
|
||||
for _, nodeId := range nodeIds {
|
||||
if !slices.Contains(newNodeIDs, nodeId) {
|
||||
if !protectedIDs[nodeId] {
|
||||
delIds = append(delIds, nodeId)
|
||||
}
|
||||
}
|
||||
@@ -218,7 +310,9 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
// 实际删除节点,按每批最多100个进行切分
|
||||
for i := 0; i < len(delIds); i += 100 {
|
||||
time.Sleep(time.Millisecond * 1000) // 画板内删除大量节点时,内部会有大量写操作,需要稍等一下,避免被限流
|
||||
if !skipDeleteNodesBatchSleep {
|
||||
time.Sleep(time.Millisecond * 1000) // 画板内删除大量节点时,内部会有大量写操作,需要稍等一下,避免被限流
|
||||
}
|
||||
end := i + 100
|
||||
if end > len(delIds) {
|
||||
end = len(delIds)
|
||||
@@ -249,3 +343,133 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
return len(delIds), delIds, nil
|
||||
}
|
||||
|
||||
// updateWhiteboardByCode 使用 plantuml/mermaid 代码更新画板
|
||||
func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, format string, overwrite bool, idempotentToken string) error {
|
||||
syntaxType := formatCodeMap[format]
|
||||
reqBody := plantumlCreateReq{
|
||||
PlantUmlCode: string(input),
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0, // 0 表示自动识别
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard by code failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
|
||||
var createResp plantumlCreateResp
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard by code failed", fmt.Sprintf("update whiteboard by code failed: %s", createResp.Msg))
|
||||
}
|
||||
|
||||
outData := make(map[string]string)
|
||||
outData["created_node_id"] = createResp.Data.NodeID
|
||||
newNodeIDs := []string{createResp.Data.NodeID}
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, newNodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_id"] != "" {
|
||||
fmt.Fprintf(w, "New node created.\n")
|
||||
}
|
||||
fmt.Fprintf(w, "Update whiteboard success")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateWhiteboardByRawNodes 使用原始 Open API 格式数据更新画板
|
||||
func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, overwrite bool, idempotentToken string) error {
|
||||
nodes, err, isRaw := parseWBcliNodes(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData := make(map[string]string)
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
|
||||
Body: nodes,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var detail string
|
||||
if isRaw {
|
||||
detail = fmt.Sprintf("It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
|
||||
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
|
||||
}
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), detail)
|
||||
}
|
||||
|
||||
var createResp createResponse
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
detail := fmt.Sprintf("update whiteboard failed: %s", createResp.Msg)
|
||||
if isRaw {
|
||||
detail += fmt.Sprintf("\n It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
|
||||
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
|
||||
}
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard failed", detail)
|
||||
}
|
||||
|
||||
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, createResp.Data.NodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
|
||||
}
|
||||
fmt.Fprintf(w, "Update whiteboard success")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
599
shortcuts/whiteboard/whiteboard_update_test.go
Normal file
599
shortcuts/whiteboard/whiteboard_update_test.go
Normal file
@@ -0,0 +1,599 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWhiteboardUpdate_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
boolFlags map[string]bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid: default format (raw) with token",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: plantuml format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "plantuml",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: mermaid format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "mermaid",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: with idempotent-token",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"idempotent-token": "xxx************xxxx",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid: bad input_format value",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "invalid",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid: idempotent-token too short",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"idempotent-token": "short",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid: with overwrite flag",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"source": "test content",
|
||||
},
|
||||
boolFlags: map[string]bool{
|
||||
"overwrite": true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, tt.boolFlags)
|
||||
err := wbUpdateValidate(ctx, rt)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("wbUpdateValidate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flagVal string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty defaults to raw",
|
||||
flagVal: "",
|
||||
expected: FormatRaw,
|
||||
},
|
||||
{
|
||||
name: "raw returns raw",
|
||||
flagVal: FormatRaw,
|
||||
expected: FormatRaw,
|
||||
},
|
||||
{
|
||||
name: "plantuml returns plantuml",
|
||||
flagVal: FormatPlantUML,
|
||||
expected: FormatPlantUML,
|
||||
},
|
||||
{
|
||||
name: "mermaid returns mermaid",
|
||||
flagVal: FormatMermaid,
|
||||
expected: FormatMermaid,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"input_format": tt.flagVal}, nil)
|
||||
result := getFormat(rt)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getFormat() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify WhiteboardUpdate is properly configured
|
||||
if WhiteboardUpdate.Command != "+update" {
|
||||
t.Errorf("WhiteboardUpdate.Command = %q, want \"+update\"", WhiteboardUpdate.Command)
|
||||
}
|
||||
if WhiteboardUpdate.Service != "whiteboard" {
|
||||
t.Errorf("WhiteboardUpdate.Service = %q, want \"whiteboard\"", WhiteboardUpdate.Service)
|
||||
}
|
||||
|
||||
// Verify WhiteboardUpdateOld is also properly configured
|
||||
if WhiteboardUpdateOld.Command != "+whiteboard-update" {
|
||||
t.Errorf("WhiteboardUpdateOld.Command = %q, want \"+whiteboard-update\"", WhiteboardUpdateOld.Command)
|
||||
}
|
||||
if WhiteboardUpdateOld.Service != "docs" {
|
||||
t.Errorf("WhiteboardUpdateOld.Service = %q, want \"docs\"", WhiteboardUpdateOld.Service)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+query",
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(got))
|
||||
for _, shortcut := range got {
|
||||
if seen[shortcut.Command] {
|
||||
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
|
||||
}
|
||||
seen[shortcut.Command] = true
|
||||
}
|
||||
|
||||
for _, command := range want {
|
||||
if !seen[command] {
|
||||
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWBcliNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
wantErr bool
|
||||
wantRaw bool
|
||||
}{
|
||||
{
|
||||
name: "valid with raw nodes",
|
||||
input: []byte(`{"code":0,"data":{"to":"openapi"},"nodes":[{"id":"1"}]}`),
|
||||
wantErr: false,
|
||||
wantRaw: true,
|
||||
},
|
||||
{
|
||||
name: "valid without raw nodes",
|
||||
input: []byte(`{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`),
|
||||
wantErr: false,
|
||||
wantRaw: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
input: []byte(`invalid json`),
|
||||
wantErr: true,
|
||||
wantRaw: false,
|
||||
},
|
||||
{
|
||||
name: "whiteboard-cli failed",
|
||||
input: []byte(`{"code":1,"data":{"to":"other"}}`),
|
||||
wantErr: true,
|
||||
wantRaw: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err, isRaw := parseWBcliNodes(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseWBcliNodes() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && isRaw != tt.wantRaw {
|
||||
t.Errorf("parseWBcliNodes() isRaw = %v, want %v", isRaw, tt.wantRaw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWBUpdateDryRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
boolFlags map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "dry run raw format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "raw",
|
||||
"source": `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dry run plantuml format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "plantuml",
|
||||
"source": "@@startuml\nBob -> Alice : hello\n@@enduml",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dry run mermaid format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "mermaid",
|
||||
"source": "graph TD\nA-->B",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, tt.boolFlags)
|
||||
dryRun := wbUpdateDryRun(ctx, rt)
|
||||
if dryRun == nil {
|
||||
t.Fatalf("wbUpdateDryRun() returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newUpdateExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
}
|
||||
|
||||
func runUpdateShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
// Temporarily lower risk for testing
|
||||
originalRisk := shortcut.Risk
|
||||
shortcut.Risk = "read"
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
|
||||
parent := &cobra.Command{Use: "whiteboard"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
err := parent.ExecuteContext(context.Background())
|
||||
|
||||
// Restore original risk
|
||||
shortcut.Risk = originalRisk
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node1", "node2"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-123", "--input_format", "raw", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_PlantUMLFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock plantuml create API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-plantuml/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node_id": "node1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `@@startuml
|
||||
Bob -> Alice : hello
|
||||
@@enduml`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-plantuml", "--input_format", "plantuml", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_MermaidFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock plantuml create API response (mermaid uses same endpoint)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-mermaid/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node_id": "node1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `graph TD
|
||||
A-->B`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-mermaid", "--input_format", "mermaid", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response with idempotent token
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-idempotent/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node1"},
|
||||
"client_token": "test-token-1234567890",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-idempotent", "--input_format", "raw", "--idempotent-token", "test-token-1234567890", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-nodes/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node1", "node2"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi"},"nodes":[{"id":"1"}]}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-nodes", "--input_format", "raw", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response with error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-api-error/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10001,
|
||||
"msg": "update failed",
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-api-error", "--input_format", "raw", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Logf("Expected API error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_PlantUMLAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock plantuml create API response with error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-plantuml-error/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10001,
|
||||
"msg": "invalid plantuml",
|
||||
},
|
||||
})
|
||||
|
||||
source := `@@startuml
|
||||
invalid
|
||||
@@enduml`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-plantuml-error", "--input_format", "plantuml", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Logf("Expected API error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{},
|
||||
},
|
||||
{
|
||||
"id": "old-node-2",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node_id": "new-node-123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `graph TD
|
||||
A-->B`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-overwrite", "--input_format", "mermaid", "--overwrite", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{"old-child-1"},
|
||||
},
|
||||
{
|
||||
"id": "old-child-1",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"new-node-1", "new-node-2"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-overwrite", "--input_format", "raw", "--overwrite", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,11 @@ Drive Folder (云空间文件夹)
|
||||
- 已有画板内容无法通过 lark-doc 的 `docs +update` 直接编辑
|
||||
- 编辑画板需要使用专门的 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md)
|
||||
|
||||
## 文档可视化建议
|
||||
> **💡 在撰写文档时,当需要表达较为复杂的时序、架构层次、逻辑关系、数据流程等内容时,建议使用画板绘制可视化图表以显著提升文档的可阅读性。**
|
||||
>
|
||||
> 请参考 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何绘制画板内容。
|
||||
|
||||
## 快速决策
|
||||
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
|
||||
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
|
||||
@@ -127,6 +132,45 @@ Drive Folder (云空间文件夹)
|
||||
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
|
||||
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”,**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
|
||||
|
||||
## 画板需求挖掘(主动识别)
|
||||
|
||||
> **用户很少主动提"画板"。创建文档时应主动识别适合可视化的内容,用画板呈现。**
|
||||
|
||||
### 🔴 关键要求(必须遵守)
|
||||
|
||||
**创建空白画板 ≠ 完成任务**。创建空白画板后,**必须继续使用 lark-whiteboard 技能填充实际内容**。
|
||||
|
||||
### 语义与画板类型映射
|
||||
|
||||
创建/编辑文档时,文档主题涉及以下语义,应**主动**创建画板,无需用户指定:
|
||||
|
||||
| 语义 | 画板类型 | 参考指南 |
|
||||
|---------------|-------|---------------------------------------------------------------------------------------------|
|
||||
| 架构/分层/技术方案 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 流程/审批/部署/业务流转 | 流程图 | [lark-whiteboard-cli/scenes/flowchart.md](../lark-whiteboard-cli/scenes/flowchart.md) |
|
||||
| 组织/层级/汇报关系 | 组织架构图 | [lark-whiteboard-cli/scenes/organization.md](../lark-whiteboard-cli/scenes/organization.md) |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 | [lark-whiteboard-cli/scenes/milestone.md](../lark-whiteboard-cli/scenes/milestone.md) |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 | [lark-whiteboard-cli/scenes/fishbone.md](../lark-whiteboard-cli/scenes/fishbone.md) |
|
||||
| 方案对比/技术选型 | 对比图 | [lark-whiteboard-cli/scenes/comparison.md](../lark-whiteboard-cli/scenes/comparison.md) |
|
||||
| 循环/飞轮/闭环 | 飞轮图 | [lark-whiteboard-cli/scenes/flywheel.md](../lark-whiteboard-cli/scenes/flywheel.md) |
|
||||
| 层级占比/能力模型 | 金字塔图 | [lark-whiteboard-cli/scenes/pyramid.md](../lark-whiteboard-cli/scenes/pyramid.md) |
|
||||
| 模块依赖/调用关系 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 分类梳理/知识体系 | 思维导图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
| 数据分布/占比 | 饼图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
|
||||
创建画板前,务必先阅读 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 和 [`lark-whiteboard`](../lark-whiteboard/SKILL.md) 这两个 Skill,了解画板的创建流程。
|
||||
|
||||
### 完整执行流程(必须完整执行)
|
||||
|
||||
1. **创建空白画板占位**:创建场景用 `docs +create`、编辑场景用 `docs +update` 插入空白画板
|
||||
2. **获取画板 token**:从 `docs +update` 响应的 `data.board_tokens` 获取画板 token 列表
|
||||
3. **填充画板内容**:切换到 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 创建画板内容,并填入画板
|
||||
4. **验证完成**:确认所有画板都有实际内容,不是空白
|
||||
|
||||
**不适用**:纯文字记录(日志/备忘)、数据密集型内容(用表格)、用户明确只要文字。
|
||||
|
||||
> ⚠️ **警告**:如果只创建空白画板而不填充内容,任务将被视为未完成!
|
||||
|
||||
## 补充说明
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
|
||||
@@ -141,6 +185,5 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+whiteboard-update`](references/lark-doc-whiteboard-update.md) | Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details. |
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
从 Lark-flavored Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
## 重要说明
|
||||
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -49,6 +52,19 @@ lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 重要:创建文档后的可视化流程
|
||||
|
||||
如果文档中包含空白画板(`<whiteboard type="blank"></whiteboard>`),**必须继续以下步骤**:
|
||||
|
||||
1. 从返回值的 `data.board_tokens` 字段记录所有新建画板的 token
|
||||
2. 立即切换到 [`lark-whiteboard`](../lark-whiteboard/SKILL.md) 技能
|
||||
3. 使用 `whiteboard +update` 命令为每个画板填充实际内容(Mermaid/PlantUML/DSL)
|
||||
4. 确认所有画板都有实际内容后,任务才算完成
|
||||
|
||||
**仅创建空白画板是不够的!** 如果只创建空白画板而不填充内容,任务将被视为未完成。
|
||||
|
||||
> ⚠️ **警告**:务必检查返回值中是否有 `board_tokens` 字段。如果有,说明创建了空白画板,必须继续填充内容!
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
@@ -69,6 +85,7 @@ lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown
|
||||
- **视觉节奏**:用分割线、分栏、表格打破大段纯文字
|
||||
- **图文交融**:流程、架构或草图需要可视化时,优先使用图片、表格或空白画板
|
||||
- **克制留白**:Callout 不过度、加粗只强调核心词
|
||||
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,主动插入空白画板,后续用 lark-whiteboard 填充;但若用户明确要求仅文本或内容更适合表格,则不插入。详见 [画板需求挖掘](../SKILL.md#画板需求挖掘主动识别)
|
||||
|
||||
当用户有明确的样式、风格需求时,应当以用户的需求为准!
|
||||
|
||||
@@ -633,6 +650,7 @@ $$
|
||||
| 知识卡片 | Callout + emoji | 用于概念解释、小贴士 |
|
||||
| 引用说明 | 引用块 > | 引用原文、名言 |
|
||||
| 术语对照 | 两列表格 | 中英文、缩写全称等 |
|
||||
| 架构/流程/组织/时间线/因果 | **空白画板** | 主动插入,用 lark-whiteboard 绘制(用户明确仅文本或数据密集表格场景除外) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
更新飞书云文档内容,支持 7 种更新模式。优先使用局部更新(replace_range/append/insert_before/insert_after),慎用 overwrite(会清空文档重写,可能丢失图片、评论等)。
|
||||
|
||||
## 重要说明
|
||||
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,17 +3,4 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
更新飞书云文档中的画板内容。这个操作需要提供画板的 Token 和画板的 DSL 内容,并需要使用 whiteboard-cli 工具解析 DSL 内容,并通过管道传入这个命令。
|
||||
关于如何设计画板内容,以及如何使用 whiteboard-cli,参考 [`../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md)。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|--------------------------------------------|
|
||||
| `--whiteboard-token` | 是 | 需要更新的画板 token。您需要拥有编辑画板所在文档的权限才能更新画板。 |
|
||||
| `--idempotent-token` | 否 | 幂等 token,用于确保更新操作是幂等的。默认不填,填写的话最小长度为10个字符。 |
|
||||
| `--overwrite` | 否 | 覆盖更新画板内容,在更新前删除所有现有内容。默认为 false。 |
|
||||
|
||||
## 示例
|
||||
|
||||
此处不提供示例调用,请参考 [`../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 了解完整的使用流程。
|
||||
本 shortcut 仅为兼容历史调用保留,具体使用方式请参考 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md)
|
||||
236
skills/lark-whiteboard-cli/SKILL.md
Normal file
236
skills/lark-whiteboard-cli/SKILL.md
Normal file
@@ -0,0 +1,236 @@
|
||||
---
|
||||
name: lark-whiteboard-cli
|
||||
description: >
|
||||
当用户要求或使用飞书画板绘制架构图、流程图、思维导图、时序图或其他可视化图表时使用此 skill,作为使用 whiteboard-cli 设计图表布局的指南
|
||||
compatibility: Requires Node.js 18+
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> **环境依赖**:绘制画板需要 `@larksuite/whiteboard-cli`(画板 Node.js CLI 工具),以及 `lark-cli`(LarkSuite CLI 工具)。
|
||||
> 如果执行失败,手动安装后重试:`npm install -g @larksuite/whiteboard-cli@^0.2.0`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 执行 `npm install` 安装新的依赖前,务必征得用户同意!
|
||||
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是画板,不是网页。** 画板是无限画布上自由放置元素,flex 布局是可选增强。
|
||||
|
||||
```
|
||||
Step 1: 路由 & 读取知识
|
||||
- 判断渲染路径(见路由表):Mermaid 还是 DSL?
|
||||
- 读对应 scene 指南 — 了解结构特征和布局策略
|
||||
- 确定布局策略(见下方快速判断)和构建方式
|
||||
- 读 references/ 核心模块 — 语法、布局、配色、排版、连线
|
||||
|
||||
Step 2: 生成完整 DSL(含颜色)
|
||||
- 按 content.md 规划信息量和分组
|
||||
- 按 layout.md 选择布局模式和间距
|
||||
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.0 --icons` 查看可用图标,选取合适的图标, 但不要过度使用或者所有图表都用图标, 根据图表类型和内容选择是否使用图标
|
||||
- 按 style.md 上色(用户没指定时用默认经典色板)
|
||||
- 按 schema.md 语法输出完整 JSON
|
||||
- 连线参考 connectors.md,排版参考 typography.md
|
||||
|
||||
注意:部分图形(鱼骨/飞轮/柱状/折线等)要按 scene 指南的脚本模板写 .js 脚本生成 JSON:
|
||||
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
|
||||
2. 将脚本保存为 diagram.gen.js,执行 node diagram.gen.js 产出 diagram.json
|
||||
3. 用产出的 diagram.json 进入 Step 3
|
||||
|
||||
Step 3: 渲染 & 审查 → 交付
|
||||
- 渲染前自查(见下方检查清单)
|
||||
- 渲染 PNG,检查:
|
||||
· 信息完整?布局合理?配色协调?
|
||||
· 文字无截断?连线无交叉?
|
||||
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
|
||||
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
|
||||
- 没问题 → 交付:
|
||||
· 用户要求上传飞书 → 见下方”上传飞书画板”章节中的说明
|
||||
· 用户未指定 → 展示 PNG 图片给用户
|
||||
```
|
||||
|
||||
**布局策略快速判断**(详见 `references/layout.md`):
|
||||
|
||||
先定**主布局**,再定子布局:**结构化信息**优先用 Flex,**关系链路**优先用 Dagre,**灵活定位**用绝对布局。
|
||||
|
||||
涉及 Dagre / Flex 的具体边界、危险模式、混合布局原则,统一以 `references/layout.md` 为准;scene 文件只描述场景差异,不重复定义通用布局规则。
|
||||
|
||||
> **构建方式是强约束**:当 scene 指南要求"脚本生成"时,必须先写脚本(.js)并用 `node` 执行来产出 JSON 文件。绝对定位场景(鱼骨图、飞轮图、柱状图、折线图等)的坐标需要数学计算,直接手写 JSON 极易导致节点重叠或连线穿模。
|
||||
---
|
||||
|
||||
## 渲染路径选择(DSL or Mermaid)
|
||||
|
||||
| 图表类型 | 路径 | 理由 |
|
||||
| ------------ | ----------- | ------------------- |
|
||||
| 思维导图 | **Mermaid** | 辐射结构自动布局 |
|
||||
| 时序图 | **Mermaid** | 参与方+消息自动排列 |
|
||||
| 类图 | **Mermaid** | 类关系自动布局 |
|
||||
| 饼图 | **Mermaid** | Mermaid 原生支持 |
|
||||
| 其他所有类型 | **DSL** | 精确控制样式和布局 |
|
||||
|
||||
**路由规则**:
|
||||
1. **自动 Mermaid**:思维导图、时序图、类图、饼图 → 默认走 Mermaid
|
||||
2. **显式 Mermaid**:用户输入包含 Mermaid 语法 → 走 Mermaid
|
||||
3. **DSL 路径**:其他所有类型 → 先读核心模块,再读对应场景指南
|
||||
|
||||
**Mermaid 路径**:参考 `scenes/mermaid.md` 编写 `.mmd` 文件,跳过 DSL 模块。
|
||||
**DSL 路径**:按 Workflow 3 步执行。
|
||||
|
||||
---
|
||||
|
||||
## 模块索引
|
||||
|
||||
### 核心参考(DSL 路径必读)
|
||||
|
||||
| 模块 | 文件 | 说明 |
|
||||
| -------- | -------------------------- | ------------------------------- |
|
||||
| DSL 语法 | `references/schema.md` | 节点类型、属性、尺寸值 |
|
||||
| 内容规划 | `references/content.md` | 信息提取、密度决策、连线预判 |
|
||||
| 布局系统 | `references/layout.md` | 网格方法论、Flex 映射、间距规则 |
|
||||
| 排版规则 | `references/typography.md` | 字号层级、对齐、行距 |
|
||||
| 连线系统 | `references/connectors.md` | 拓扑规划、锚点选择 |
|
||||
| 配色系统 | `references/style.md` | 多色板、视觉层级 |
|
||||
|
||||
|
||||
### 场景指南(按类型选读一个)
|
||||
|
||||
| 图表类型 | 文件 | 适用场景 |
|
||||
| ----------- | ------------------------ | -------------------------------------- |
|
||||
| 架构图 | `scenes/architecture.md` | 分层架构、微服务架构 |
|
||||
| 组织架构图 | `scenes/organization.md` | 公司组织、树形层级 |
|
||||
| 泳道图 | `scenes/swimlane.md` | 跨角色流程、跨系统交互流程、端到端链路 |
|
||||
| 对比图 | `scenes/comparison.md` | 方案对比、功能矩阵 |
|
||||
| 鱼骨图 | `scenes/fishbone.md` | 因果分析、根因分析 |
|
||||
| 柱状图 | `scenes/bar-chart.md` | 柱状图、条形图 |
|
||||
| 折线图 | `scenes/line-chart.md` | 折线图、趋势图 |
|
||||
| 树状图 | `scenes/treemap.md` | 矩形树图、层级占比 |
|
||||
| 漏斗图 | `scenes/funnel.md` | 转化漏斗、销售漏斗 |
|
||||
| 金字塔图 | `scenes/pyramid.md` | 层级结构、需求层次 |
|
||||
| 循环/飞轮图 | `scenes/flywheel.md` | 增长飞轮、闭环链路 |
|
||||
| 里程碑 | `scenes/milestone.md` | 时间线、版本演进 |
|
||||
| 流程图 | `scenes/flowchart.md` | 业务流、状态机、带条件判断的链路 |
|
||||
| Mermaid | `scenes/mermaid.md` | 思维导图、时序图、类图、饼图 |
|
||||
|
||||
---
|
||||
|
||||
## 文件产物规范
|
||||
|
||||
每次绘图在 `./diagrams/` 下按当前时间创建子目录(格式 `YYYY-MM-DDTHHMMSS`),目录内文件名固定。用户指定了保存路径时以用户为准。
|
||||
|
||||
```
|
||||
./diagrams/
|
||||
2026-03-27T143000/ ← 自动按时间创建,无需起名
|
||||
diagram.json ← DSL(CLI 输入)
|
||||
diagram.gen.js ← 坐标计算脚本(仅脚本构建方式)
|
||||
diagram.png ← 最终图片
|
||||
diagram.mmd ← Mermaid 源码(仅 Mermaid 路径)
|
||||
```
|
||||
|
||||
## CLI 命令
|
||||
|
||||
**查看可用图标**:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 --icons
|
||||
```
|
||||
|
||||
**渲染**:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i ./diagrams/2026-03-27T143000/diagram.json -o ./diagrams/2026-03-27T143000/diagram.png # DSL
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i ./diagrams/2026-03-27T143000/diagram.mmd -o ./diagrams/2026-03-27T143000/diagram.png # Mermaid
|
||||
```
|
||||
|
||||
**上传飞书画板**:
|
||||
|
||||
> 上传需要飞书认证。遇到认证或权限错误时,阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 了解登录和权限处理。
|
||||
|
||||
**第一步:获取画板 Token**
|
||||
|
||||
| 用户给了什么 | 怎么获取 Token |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 画板 Token(`XXX`) | 直接使用 |
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token=”XXX”/>` 中提取 token |
|
||||
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type=”blank”></whiteboard>' --as user`,从响应的 `data.board_tokens[0]` 获取 token |
|
||||
|
||||
关于飞书文档的创建,读取等更多操作,请参考 lark-doc skill [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md)。
|
||||
|
||||
**第二步:上传**
|
||||
|
||||
> [!CAUTION]
|
||||
> **MANDATORY PRE-FLIGHT CHECK (上传前强制拦截检查)**
|
||||
> 当你要向一个**已存在的画板 Token** 写入内容时,**绝对禁止**直接执行上传命令!你必须严格遵守以下两步:
|
||||
> **强制执行 Dry Run(状态探测)**
|
||||
> 必须先在命令中添加 `--overwrite --dry-run` 参数来探测画板当前状态。示例命令:
|
||||
> ```bash
|
||||
> npx -y @larksuite/whiteboard-cli@^0.2.0 --to openapi -i <输入文件> --format json | lark-cli whiteboard +update --whiteboard-token <Token> --source - --overwrite --dry-run --as user
|
||||
> ```
|
||||
>
|
||||
> **解析结果并拦截**
|
||||
> - 仔细阅读 Dry Run 的输出日志。
|
||||
> - **如果日志包含 `XX whiteboard nodes will be deleted`**:这说明画板**非空**,当前操作会覆盖并摧毁用户的原有图表!
|
||||
> - **你必须立即停止操作**,并通过 `AskUserQuestion` 工具(或直接回复)向用户确认:”目标画板当前非空,继续更新将清空原有的 XX 个节点,是否确认覆盖?”
|
||||
> - 只有在用户明确授权”同意覆盖”后,你才能移除 `--dry-run` 真正执行上传。
|
||||
> - 用户可能会要求你不覆盖更新画板内容,在这种情况下,移除 `--overwrite` 和 `--dry-run` 参数再上传。
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 --to openapi -i <输入文件> --format json | lark-cli whiteboard +update --whiteboard-token <画板Token> --source - --yes --as user
|
||||
```
|
||||
> 画板一经上传不可修改。如需应用身份上传,将 `--as user` 替换为 `--as bot`。
|
||||
> 如果画板非空,先加 `--overwrite --dry-run` 检查待删除节点数,向用户确认后去掉 `--dry-run` 执行。
|
||||
|
||||
你也可以将布局输出为原生 OpenAPI json 格式,再通过 lark-cli 导入飞书画板。关于 lark-cli 操作画板的更多方式,请参照 [../lark-whiteboard/SKILL.md](../lark-whiteboard/SKILL.md)
|
||||
|
||||
**症状→修复表**(视觉审查发现问题时参照):
|
||||
|
||||
| 看到的问题 | 改什么 |
|
||||
| ------------------ | ----------------------------------- |
|
||||
| 文字被截断 | height 改为 fit-content |
|
||||
| 文字溢出容器右侧 | 增大 width,或缩短文字 |
|
||||
| 节点重叠粘连 | 增大 gap |
|
||||
| 节点挤成一团 | 增大 padding 和 gap |
|
||||
| 连线穿过节点 | 调整 fromAnchor/toAnchor 或增大间距 |
|
||||
| 大面积空白 | 缩小外层 frame 宽度 |
|
||||
| 文字和背景色太接近 | 调整 fillColor 或 textColor |
|
||||
| 布局整体偏左/偏右 | 调整绝对定位的 x 坐标使内容居中 |
|
||||
|
||||
---
|
||||
|
||||
## 渲染前自查
|
||||
|
||||
生成 DSL 后、渲染前,快速检查:
|
||||
|
||||
- [ ] 不同分组用了不同颜色?同组节点样式完全一致?
|
||||
- [ ] 外层浅色背景、内层白色节点?(外重内轻)
|
||||
- [ ] 所有节点有边框(borderWidth=2)?文字在背景上清晰可读?
|
||||
- [ ] 连线用灰色(#BBBFC4),不用彩色?
|
||||
- [ ] frame 都写了 layout 属性?gap 和 padding 都显式设置了?
|
||||
- [ ] 含文字节点 height 用 fit-content?connector 在顶层 nodes 数组?
|
||||
|
||||
---
|
||||
|
||||
## 关键约束速查
|
||||
|
||||
> 最高频出错的规则,即使不读子模块文件也必须遵守。
|
||||
|
||||
1. **含文字节点的 height 必须用 `'fit-content'`** — 写死数值会截断文字
|
||||
2. **`fill-container` 仅在 flex 父容器中生效** — `layout: 'none'` 下宽度退化为 0
|
||||
3. **`layout: 'none'` 的容器必须有固定宽高** — 不要写成 `fit-content`
|
||||
4. **connector 必须放在顶层 nodes 数组** — 不能嵌套在 frame children 里
|
||||
5. **图层顺序** — 数组顺序 = 绘制顺序。后定义的元素层级越高,会覆盖先定义的。重叠/浮层/标注元素务必放在数组末尾。
|
||||
6. **flex 容器内的 x/y 会被完全忽略** — 需要自由定位时用 `layout: 'none'` 或放在顶层 nodes
|
||||
7. **Dagre 子容器默认为不透明节点** — 外层连线无法寻址其内部子节点(引擎会自动重定向至外壳)。需穿透时声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`
|
||||
|
||||
❌ 致命错误:flex 容器内设 x/y,坐标不生效,节点按顺序排列
|
||||
```json
|
||||
{ "type": "frame", "layout": "vertical", "children": [
|
||||
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
|
||||
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
|
||||
]}
|
||||
```
|
||||
✅ 正确:用 `layout: "none"` 或放在顶层 nodes 用 x/y 定位。
|
||||
|
||||
❌ 致命错误:`layout: "none"` 容器本身写 `width: "fit-content", height: "fit-content"`,再在内部摆绝对坐标节点
|
||||
|
||||
✅ 正确:绝对定位容器先给固定宽高,再在内部用 x/y 放置子节点。
|
||||
@@ -42,7 +42,7 @@ const doc: WBDocument = {
|
||||
## 连线技巧
|
||||
|
||||
```typescript
|
||||
// 自动绕线(推荐):仅需指定节点 id(锚点也是可选的,引擎可自动推断),并使用 polyline(或 rightAngle)形状
|
||||
// 自动绕线(推荐):仅需指定节点 id(引擎可自动推断最优出线方向),并使用 polyline 或 rightAngle 形状
|
||||
// 只要不传 waypoints,引擎会尝试自动避开障碍物并生成折线。
|
||||
{ type: 'connector', connector: {
|
||||
from: 'a', to: 'b', // fromAnchor 和 toAnchor 也可以省略,让引擎自己找最短路径
|
||||
@@ -60,20 +60,30 @@ const doc: WBDocument = {
|
||||
from: { x: 300, y: 140 }, to: { x: 300, y: 340 },
|
||||
waypoints: [{ x: 350, y: 140 }, { x: 350, y: 340 }],
|
||||
lineShape: 'polyline', lineColor: '#000000', lineWidth: 2, endArrow: 'arrow' }}
|
||||
|
||||
// 绘制坐标轴/数轴(必须使用 straight,防止刻度文字触发自动避障导致线条弯曲)
|
||||
{ type: 'connector', connector: {
|
||||
from: { x: 100, y: 400 }, to: { x: 600, y: 400 },
|
||||
lineShape: 'straight', lineColor: '#000000', lineWidth: 2, endArrow: 'arrow' }}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **1. `lineShape` 强制选用约束**:
|
||||
> - **`'polyline'`(圆角折线)**:**默认首选**。适用于流程图、架构图等绝大多数场景。
|
||||
> - **`'straight'`(直线)**:适用于**坐标轴、数轴、几何图形边框**等**绝对不能弯曲**的场景。
|
||||
> - **`'rightAngle'`(直角折线)**:适用于 [organization.md](scenes/organization.md) 等明确要求“总线/直角规约”、树状层级严格对齐的场景。
|
||||
> - **`'curve'`(曲线)**:适用于优雅的跨层连线(S型弯)、自由发散的脑图分支、或做注解箭头时。
|
||||
> **2. 间距要求**:有 connector 连线的卡片间 gap ≥ 40,否则箭头挤在缝里看不清。
|
||||
> **1. 形状选用要求(核心)**,需明确 `lineShape` 类型:
|
||||
> - **`'polyline'`(圆角折线)**:**默认首选**。适用于流程图、架构图等绝大多数场景。支持引擎的**自动绕线与避障**功能(只需指定 `from` 和 `to`)。
|
||||
> - **`'rightAngle'`(直角折线)**:适用于明确要求“总线/直角规约”、树状层级严格对齐的场景,同样支持**自动绕线与避障**。
|
||||
> - **`'straight'`(直线)**:不受自动避障机制的影响,适用于**坐标轴、数轴、几何图形边框、直接指向关系**等要求线条绝对笔直、不允许出现任何绕行或弯曲的场景。
|
||||
> - **`'curve'`(曲线)**:适用于优雅的跨层连线(S型弯)、自由发散的脑图分支、或做注解箭头时。
|
||||
> - **注意**:你需要根据当前绘制的图表类型和上下文语境,选择最合适的 `lineShape`。不要盲目全部使用 `polyline`,例如在绘制坐标系时必须主动切换为 `straight`。
|
||||
> **2. 间距要求**:有 connector 连线的卡片间 gap 需 ≥ 40,否则箭头挤在缝里看不清。
|
||||
> **3. 顶层约束**:`connector` 必须直接放在 `WBDocument.nodes`,**严禁**嵌套在 `children` 内。建议在数据末尾统一声明连线。
|
||||
|
||||
>
|
||||
> [!TIP]
|
||||
> **何时手动算 waypoints**:引擎没有连线自动避障功能,当需要避开特定障碍物、或保证特定的走线形状时,需要手动计算 `waypoints` 控制走向。
|
||||
> **连线标签**:需要文字说明时,可用 `label` 标注。
|
||||
> **自动绕线 vs 手动控制**
|
||||
> - **优先依赖自动绕线**:对于 `'polyline'` 和 `'rightAngle'`,引擎会自动规划路径并尝试避开障碍物(`fromAnchor` 和 `toAnchor` 也可省略,引擎会自动推断最优出线方向),这是最推荐的方式。
|
||||
> - **何时手动算 waypoints**:**仅在必要时**(例如自动路由不符合预期,或者必须强制走特定形状绕开特定元素时),才需要通过 `waypoints` 手动接管坐标序列。
|
||||
>
|
||||
> **连线标签**
|
||||
> - **连线文字说明**:需要文字说明时,可用 `label` 标注。
|
||||
|
||||
---
|
||||
|
||||
374
skills/lark-whiteboard-cli/references/layout.md
Normal file
374
skills/lark-whiteboard-cli/references/layout.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# 布局系统
|
||||
|
||||
## 布局决策
|
||||
|
||||
> 不要靠关键词猜布局。先分析信息结构,再决定布局策略。
|
||||
> 本文件负责说明通用布局原则与骨架模板;字段语义看 `references/schema.md`,完整场景范式看各 `scenes/*.md`。
|
||||
|
||||
总原则:**先定主布局,再定子布局。**
|
||||
|
||||
**快速判断**:
|
||||
- **Flex**:按层分、按区排
|
||||
- **Dagre**:关系网密、流程链主导
|
||||
- **绝对定位**:空间位置承载信息(地理方位、拓扑坐标、物理面板等),用脚本计算坐标
|
||||
- **默认选择**:拿不准时优先用 **Flex**
|
||||
|
||||
|
||||
**Dagre 版式统一原则**:
|
||||
1. Dagre 解决的是**拓扑关系**,不是自动把画布铺满。
|
||||
2. Dagre 作为子容器嵌套时,默认是不透明节点(Opaque Node),先根据内部拓扑计算自身包围盒,再作为原子节点参与父层布局。若需连线穿透边界,须声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`。
|
||||
3. 混合布局时,Flex 更适合负责分区与层次,Dagre 更适合负责局部复杂关系;但如果 Dagre 本身就是主布局,也完全可以直接承担整张图的主体拓扑。
|
||||
4. 选用 Dagre 前先看三件事:**最长链路方向、分支是否对称、是否有长回边/重试回路**。哪一项失衡,哪一项就会把包围盒撑歪。
|
||||
5. 长回边、失败重试、跨层返回等关系,优先收敛到局部;必要时拆成局部流程区或旁路说明,不要让一条边把整个 Dagre 宽度拉爆。
|
||||
6. 若 Dagre 产物在父容器中出现明显单侧留白、宽高失衡或内容只占很小一部分,必须调整 `rankdir`、重构拓扑,或在父层补充对称信息区,不能原样交付。
|
||||
|
||||
**读代码画架构图**:扫目录结构(按层分 → Flex;按功能模块分 → 看依赖方向)→ grep import(单向→Flex;网状→ Dagre 或 Flex + Dagre)→ 拿不准 → 默认 Flex。
|
||||
|
||||
> **flex 容器内的 `x/y` 会被完全忽略!**
|
||||
|
||||
❌ 致命错误:
|
||||
```json
|
||||
{ "type": "frame", "layout": "vertical", "children": [
|
||||
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
|
||||
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
|
||||
]}
|
||||
```
|
||||
✅ 正确:用 `layout: "none"` 或放在顶层 nodes 用 x/y。
|
||||
|
||||
> **`layout: "none"`(绝对定位)的容器必须有明确的固定宽高!**
|
||||
|
||||
❌ 致命错误:
|
||||
```json
|
||||
{ "type": "frame", "layout": "none", "width": "fit-content", "height": "fit-content", "children": [
|
||||
{ "type": "rect", "x": 0, "y": 0, "text": "区域A" },
|
||||
{ "type": "rect", "x": 500, "y": 0, "text": "区域B" }
|
||||
]}
|
||||
```
|
||||
✅ 正确:必须给绝对定位容器明确的固定宽高:
|
||||
```json
|
||||
{ "type": "frame", "layout": "none", "width": 1064, "height": 680, "children": [
|
||||
{ "type": "rect", "x": 0, "y": 0, "text": "区域A" },
|
||||
{ "type": "rect", "x": 554, "y": 0, "text": "区域B" }
|
||||
]}
|
||||
```
|
||||
|
||||
**构建方式**:
|
||||
|
||||
| 布局类型 | 做法 |
|
||||
| ---------------------- | ----------------------------------------------------------------------------- |
|
||||
| 纯 Flex / Dagre | 直接写 JSON |
|
||||
| 混合布局 (Flex包Dagre) | 直接写 JSON(外层先做分区,局部复杂关系交给 Dagre;若被嵌套,默认为不透明节点) |
|
||||
| 极度依赖几何坐标的图 | 写脚本生成 JSON(node xxx.js) |
|
||||
| 需要精确避让的特殊线 | 脚本 + `--layout` 两阶段 |
|
||||
|
||||
---
|
||||
|
||||
## 网格方法论
|
||||
|
||||
核心理念:**先画网格,再填内容**。
|
||||
|
||||
先回答三个问题:
|
||||
1. **信息分几行几列?** 每组一行或一列
|
||||
2. **每格多大?** 等宽还是有主次?
|
||||
3. **行列间距多大?** 分区间 24-32px,同区内 12-16px
|
||||
|
||||
---
|
||||
|
||||
## 布局模式选择
|
||||
|
||||
| 模式 | 适用场景 | DSL 映射 |
|
||||
| ---- | ---------------------------- | -------------------------------------------------------- |
|
||||
| grid | 架构图、对比表、卡片墙、看板 | vertical frame 嵌套 horizontal frame |
|
||||
| flow | 复杂流程图、微服务交互 | `layout: "dagre"`,由引擎自动计算网状连线排版 |
|
||||
| tree | 组织架构、模块依赖 | `layout: "dagre"` 配 `rankdir: "TB"` 或根节点居中的 Flex |
|
||||
| free | 地理位置布局、物理面板还原 | `layout: "none"` + x/y |
|
||||
|
||||
大多数图表用 grid 或 flow 模式。只有节点坐标本身有强语义(如地图)时才用 free。
|
||||
|
||||
> 以上都是布局策略名称,DSL 的 `layout` 属性值只支持 `'horizontal'`、`'vertical'`、`'none'`、`'dagre'` 四种。
|
||||
|
||||
---
|
||||
|
||||
## DSL 与 CSS Flexbox 属性映射
|
||||
|
||||
| DSL 属性 | 对应的 CSS 心智模型 | 限制 |
|
||||
| -------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `layout: 'horizontal'` | `flex-direction: row` | 不写 layout = 绝对定位 |
|
||||
| `layout: 'vertical'` | `flex-direction: column` | 同上 |
|
||||
| `layout: 'none'` | `position: absolute`(子节点用 x/y) | 子节点不能用 `fill-container`;容器必须有固定宽高 |
|
||||
| `layout: 'dagre'` | 类似 Mermaid / DOT 的有向图布局 | 宽高只支持 `fit-content`;先按拓扑算包围盒再参与父层布局;嵌套时默认为不透明节点 |
|
||||
| `width/height: 'fill-container'` | `flex: 1`(主轴)/ `align-self: stretch`(交叉轴) | 祖先必须有确定尺寸 |
|
||||
| `width/height: 'fit-content'` | `width/height: auto` | — |
|
||||
| `alignItems` | 同 CSS `align-items` | 仅 `'start'`/`'center'`/`'end'`/`'stretch'`(无 flex- 前缀) |
|
||||
| `justifyContent` | 同 CSS `justify-content` | 仅 `'start'`/`'center'`/`'end'`/`'space-between'`/`'space-around'` |
|
||||
| `gap` | 同 CSS `gap` | 必须显式写(不写节点会粘连) |
|
||||
| `padding` | 同 CSS `padding` | 必须显式写。支持 `number` / `[v,h]` / `[t,r,b,l]` |
|
||||
|
||||
`alignItems` 默认值为 `'start'`(CSS Flexbox 默认 `stretch`)。需要等高卡片时必须显式写 `alignItems: 'stretch'`。
|
||||
DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignSelf`、`flexWrap`、`margin` 等)。
|
||||
|
||||
---
|
||||
|
||||
## DSL 注意事项
|
||||
|
||||
1. **frame 必须写 layout 属性**,不写时子节点全堆在左上角。
|
||||
|
||||
2. **fill-container 死锁陷阱**:使用 `fill-container` 时,祖先链中必须有固定宽度(或高度),否则和 `fit-content` 形成死锁,尺寸退化为 0。
|
||||
错误示例:
|
||||
```json
|
||||
{ "type": "frame", "layout": "horizontal", "width": "fit-content", "children": [
|
||||
{ "type": "rect", "width": "fill-container" }
|
||||
]}
|
||||
```
|
||||
正确示例:
|
||||
```json
|
||||
{ "type": "frame", "layout": "horizontal", "width": 1200, "children": [
|
||||
{ "type": "rect", "width": "fill-container" }
|
||||
]}
|
||||
```
|
||||
3. **不要给 Dagre 套固定宽高的外框**:Dagre 产物尺寸由拓扑决定,无法提前预知。父容器应使用 `fit-content` 自适应,或直接让 Dagre 作为顶层容器,不要用固定像素框住它。
|
||||
4. **`layout: 'none'` 的容器必须有固定宽高**,不要写成 `fit-content`,否则子节点绝对定位容易错乱。
|
||||
5. **含文字节点高度用 fit-content**,引擎不支持 overflow,写死高度会截断文字。
|
||||
6. **Shape 节点有内边距**:rect/ellipse/diamond/triangle 各边 12px;cylinder 垂直 +42px。
|
||||
7. **不支持 flex-wrap**,需要换行时用嵌套 frame 模拟。
|
||||
8. **图层顺序**:数组中越靠后的节点层级越高。需要叠加标注时放在数组最后。
|
||||
|
||||
---
|
||||
|
||||
## 布局选择指南
|
||||
|
||||
| 你要表达的关系 | 怎么排 | DSL 写法 |
|
||||
| -------------------------- | ------------------------ | ---------------------------------------------------------------------------- |
|
||||
| 先后顺序、层级从上到下 | 纵向堆叠 | `layout: 'vertical'` |
|
||||
| 并列、同等重要、可对比 | 横向等分 | `layout: 'horizontal'` + `alignItems: 'stretch'` + `width: 'fill-container'` |
|
||||
| 区域有名称,名称在侧边 | 侧标签 + 内容并排 | 横向 frame: [text(标签), frame(内容)] |
|
||||
| 多个大分区,各自独立 | 分区纵向排列 | 纵向 frame 包多个彩色 frame |
|
||||
| 一行放不下,需要换行 | 嵌套横向 frame 模拟换行 | 纵向 frame 包多个横向 frame |
|
||||
| 复杂的网状关系、拓扑图 | **Dagre 有向图自动布局** | `layout: 'dagre'` + `layoutOptions.edges` |
|
||||
| 节点位置本身有含义(地图) | 绝对定位 | `layout: 'none'` + x/y |
|
||||
|
||||
这些可以自由嵌套组合。比如:纵向堆叠(标题) + 分区纵向排列(多个层) + 每个层内横向等分(节点)。
|
||||
|
||||
---
|
||||
|
||||
## 布局示例
|
||||
|
||||
### 纵向堆叠(标题 + 内容)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 28, "padding": 32,
|
||||
"width": 1200, "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "text", "width": "fill-container", "height": "fit-content",
|
||||
"text": "图表标题", "fontSize": 24, "textAlign": "center" },
|
||||
...内容...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 横向等分(并列元素)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 0,
|
||||
"width": "fill-container", "height": "fit-content",
|
||||
"alignItems": "stretch",
|
||||
"children": [
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content",
|
||||
"textAlign": "center", "verticalAlign": "middle", "text": "A" },
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content",
|
||||
"textAlign": "center", "verticalAlign": "middle", "text": "B" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`alignItems: 'stretch'` + `width: 'fill-container'` = 等宽等高。
|
||||
|
||||
### 侧标签 + 内容
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "horizontal", "gap": 24, "padding": 0,
|
||||
"width": "fill-container", "height": "fit-content",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "width": 160, "height": "fit-content",
|
||||
"text": "区域名称", "fontSize": 20, "textColor": "#1F2329", "textAlign": "right" },
|
||||
{ "type": "frame", "width": "fill-container", "height": "fit-content",
|
||||
...区域内容...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
不要用 frame 的 `title` 属性做标签——渲染为极小标题栏,不可读。
|
||||
|
||||
### 分区纵向排列
|
||||
|
||||
把内容划分为几个大区域,每个区域用不同颜色区分(颜色从 style 文件的色板选取):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 28, "padding": 0,
|
||||
"width": "fill-container", "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "frame", "borderRadius": 8,
|
||||
"layout": "horizontal", "gap": 16, "padding": 20, ...区域1... },
|
||||
{ "type": "frame", "borderRadius": 8,
|
||||
"layout": "horizontal", "gap": 16, "padding": 20, ...区域2... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 模拟换行
|
||||
|
||||
一行放不下时,拆成多个横向 frame:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 8, "padding": 0,
|
||||
"children": [
|
||||
{ "type": "frame", "layout": "horizontal", "gap": 8, "padding": 0,
|
||||
"children": [item1, item2, item3, item4] },
|
||||
{ "type": "frame", "layout": "horizontal", "gap": 8, "padding": 0,
|
||||
"children": [item5, item6] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 复杂拓扑混合布局 (Dagre + Flex)
|
||||
|
||||
当你在处理**连线众多、关系杂乱的拓扑图 / 链路流程图 / 复杂架构图**时,不用手动去算每个节点坐标,优先考虑 **Flex + Dagre 的混合布局策略**。这主要包含两种维度的嵌套:
|
||||
|
||||
* **外层 Dagre + 内层 Flex(复杂节点)**:**这是最推荐的复杂架构画法**。整图拓扑交由 `layout: "dagre"` 自动计算并顺滑布线,而图中的节点不再只是单调的矩形,可以是一个用 Flex 自由拼装的复杂 `frame` 卡片(包含图标、主次标题、状态等),让节点承载更丰富的信息。
|
||||
* **外层 Flex + 内层 Dagre(局部流程)**:外层用 Flex 或绝对定位划分大的业务区域,而某个特定区域内部放入 `layout: "dagre"` 容器负责处理局部的业务流。
|
||||
* **嵌套前先做宽度预判**:Dagre 会根据拓扑尽情往两侧撑出包围盒。如果可能横跨导致溢出,优先改 `rankdir` 为 `TB`、缩短文案、调小 `nodesep/ranksep`,必要时将超长的链路拆成分步区。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "id": "arch_root",
|
||||
"layout": "dagre", "padding": 40,
|
||||
"width": "fit-content", "height": "fit-content",
|
||||
"layoutOptions": {
|
||||
"rankdir": "LR", "nodesep": 60, "ranksep": 100,
|
||||
"edges": [
|
||||
["client", "auth_svc", "request"],
|
||||
["auth_svc", "order_svc"],
|
||||
["order_svc", "order_db"]
|
||||
]
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "frame", "id": "client",
|
||||
"layout": "vertical", "gap": 6, "padding": [12, 16],
|
||||
"alignItems": "center",
|
||||
"fillColor": "#F8FAFC", "borderColor": "#CBD5E1", "borderWidth": 2, "borderRadius": 10,
|
||||
"children": [
|
||||
{ "type": "text", "text": "Client App", "fontSize": 14, "textColor": "#0F172A" },
|
||||
{ "type": "text", "text": "React 18", "fontSize": 10, "textColor": "#64748B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "cluster_gateway",
|
||||
"layout": "dagre", "layoutOptions": { "isCluster": true, "clusterTitle": "Gateway Tier", "clusterTitleColor": "#15803D" },
|
||||
"fillColor": "#F0FDF4", "borderColor": "#86EFAC",
|
||||
"borderWidth": 2, "borderDash": "dashed", "borderRadius": 16,
|
||||
"children": [
|
||||
{ "type": "rect", "id": "auth_svc", "width": 120, "height": 40, "text": "Auth Service", "fillColor": "#DCFCE7", "borderColor": "#86EFAC", "borderWidth": 1, "borderRadius": 6, "fontSize": 12 },
|
||||
{ "type": "rect", "id": "order_svc", "width": 120, "height": 40, "text": "Order Service", "fillColor": "#DCFCE7", "borderColor": "#86EFAC", "borderWidth": 1, "borderRadius": 6, "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "order_db",
|
||||
"layout": "vertical", "gap": 4, "padding": [10, 14],
|
||||
"alignItems": "center",
|
||||
"fillColor": "#FFFFFF", "borderColor": "#FECACA", "borderWidth": 2, "borderRadius": 10,
|
||||
"children": [
|
||||
{ "type": "cylinder", "width": 50, "height": 36, "fillColor": "#FCA5A5", "borderColor": "#DC2626", "borderWidth": 1 },
|
||||
{ "type": "text", "text": "Order DB", "fontSize": 12, "textColor": "#7F1D1D" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**示例要点**:
|
||||
- `client` 和 `order_db` 是 **Flex 复合节点**(不透明节点),内部用 vertical 布局组合多行信息,对外层 Dagre 是固定宽高的原子。
|
||||
- `cluster_gateway` 是 **透明子图**(`layout: "dagre"` + `isCluster: true`),外部连线可穿越边界直达 `auth_svc` 和 `order_svc`。
|
||||
- 所有 `edges` 统一写在最外层根 Dagre 的 `layoutOptions` 中。
|
||||
|
||||
**Dagre 嵌套排版规则**:
|
||||
|
||||
1. **不透明节点(Opaque Node)**:Dagre 内的子容器,无论其内部 layout 是 flex、absolute 还是 dagre,只要未声明 isCluster: true,对外层 Dagre 就是具有确定宽高的不透明原子节点。外层连线无法寻址其内部子节点。
|
||||
2. **连线兜底重定向(Edge Redirect Fallback)**:当 edges 引用了某不透明节点内部的子节点 ID 时,引擎自动将该连线端点重定向至其最近的不透明祖先节点。不报错,不产生悬空连线。
|
||||
3. **透明子图(Compound Cluster)**:子容器同时声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }` 时,成为外层 Dagre 的复合子图。其内部子节点直接参与外层拓扑运算,连线可穿越子图边界。子图自身不执行独立排版,尺寸由外层 Dagre 根据内部节点包围盒自动撑开。
|
||||
|
||||
---
|
||||
|
||||
## 绝对定位
|
||||
|
||||
当节点位置本身有含义(拓扑图、地图、时间线轴)时用绝对定位。大多数图表优先用 Flex。
|
||||
|
||||
### 混合布局
|
||||
|
||||
模块内部用 Flex 自动排版,模块之间用绝对定位自由摆放。注意:承载这些模块的 `layout: "none"` 父容器必须先给出**固定宽高**,再在里面摆放子模块。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "none", "width": 1200, "height": 800,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame", "id": "module-a", "x": 100, "y": 100,
|
||||
"width": 300, "height": "fit-content",
|
||||
"layout": "vertical", "gap": 8, "padding": 16,
|
||||
"children": [
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "内容1" },
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "内容2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 两阶段绘图
|
||||
|
||||
先出骨架图导出坐标,再基于坐标补充连线和注解:
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i skeleton.json -o step1.png -l coords.json
|
||||
```
|
||||
|
||||
`coords.json` 包含每个带 id 节点的精确坐标(absX, absY, width, height)。
|
||||
|
||||
---
|
||||
|
||||
## 常用间距和尺寸
|
||||
|
||||
| 参数 | 常用范围 | 说明 |
|
||||
| ---------------- | ----------- | ------------ |
|
||||
| 整图宽度 | 1000-1400px | — |
|
||||
| 分区之间间距 | 24-32px | — |
|
||||
| 同分区内节点间距 | 12-16px | — |
|
||||
| 有连线的节点间距 | >= 40px | 给箭头留空间 |
|
||||
| 分区内边距 | 16-24px | — |
|
||||
| 侧标签宽度 | 120-180px | — |
|
||||
|
||||
---
|
||||
|
||||
## 等大卡片
|
||||
|
||||
一排卡片需要等宽等高时,不要写固定像素:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 0,
|
||||
"alignItems": "stretch",
|
||||
"children": [
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "A" },
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "B" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`alignItems: 'stretch'` + `width: 'fill-container'` = 等宽等高。
|
||||
@@ -1,6 +1,13 @@
|
||||
# DSL Schema
|
||||
|
||||
> Frame 的布局系统基于 Yoga 引擎,行为基本等同于 CSS Flexbox。`layout: 'horizontal'` = `flex-direction: row`,`fill-container` = `flex: 1`,`fit-content` = `width: auto`,`gap` / `padding` / `alignItems` / `justifyContent` 语义相同。枚举值用 `'start'`/`'end'` 而非 `'flex-start'`/`'flex-end'`。**注意差异**:`alignItems` 默认值为 `'start'`(CSS 默认 `stretch`),需要等高卡片时必须显式写 `alignItems: 'stretch'`。
|
||||
> 本文件只说明 **DSL 里能写什么**:节点类型、字段、枚举值、硬约束。布局策略、组合方法、Dagre/Flex 心智模型统一放在 `references/layout.md`。
|
||||
> `?` 表示该字段在 schema 层是 optional;若需要稳定产出,再参考对应 scene 或 layout 文件中的最佳实践。
|
||||
|
||||
**📝 布局引擎核心法则**:
|
||||
- **基本行为与 Flexbox 等同**:Frame 布局基于 Yoga 引擎。`layout: 'horizontal'` = `flex-direction: row`,`fill-container` = `flex: 1`,`fit-content` = `width: auto`,`gap` / `padding` / `alignItems` / `justifyContent` 语义相同。
|
||||
- **枚举值无 flex- 前缀**:一律使用 `'start'` / `'end'` 而非原生 CSS 的 `'flex-start'` / `'flex-end'`。
|
||||
- **默认对齐的差异**:`alignItems` 的默认值是 `'start'`(原生 CSS 默认是 `stretch`)。所以同排卡片需要等高时,**必须显式声名** `alignItems: 'stretch'`。
|
||||
- **Dagre 引擎的特殊性**:`layout: 'dagre'` 作为专属拓扑连线引擎,自身不支持 `fill-container` 宽高,对其父容器而言,它是一个自适应(打包裹)的黑盒。
|
||||
|
||||
## WBDocument
|
||||
|
||||
@@ -24,12 +31,21 @@ interface WBDocument {
|
||||
x?: number; y?: number; // Flex 子节点不需要 x/y
|
||||
width: WBSizeValue;
|
||||
height: WBSizeValue;
|
||||
|
||||
layout: 'horizontal' | 'vertical' | 'none'; // 必须写,不写默认绝对定位
|
||||
layout: 'horizontal' | 'vertical' | 'none' | 'dagre'; // 布局模式
|
||||
gap: number; // 必须显式写(不写节点会粘连,容易出 bug)
|
||||
padding: number | [number, number] | [number, number, number, number]; // 必须显式写(不写内容贴边)
|
||||
justifyContent?: 'start' | 'center' | 'end' | 'space-between' | 'space-around';
|
||||
alignItems?: 'start' | 'center' | 'end' | 'stretch';
|
||||
layoutOptions?: { // 仅当 layout 为 'dagre' 时生效
|
||||
rankdir?: 'TB' | 'BT' | 'LR' | 'RL';
|
||||
nodesep?: number;
|
||||
edgesep?: number;
|
||||
ranksep?: number;
|
||||
edges?: Array<[string, string] | [string, string, string]>; // [fromId, toId, label?] 引擎自动排版子节点并生成贝塞尔曲线连线
|
||||
isCluster?: boolean; // 透明子图。为 true 时子节点参与父级 Dagre 拓扑运算,连线可穿越边界
|
||||
clusterTitle?: string; // 子图悬浮标题(自动吸附左上角)
|
||||
clusterTitleColor?: string; // 标题颜色 (HEX格式,如 "#8B5CF6")
|
||||
};
|
||||
fillColor?: string;
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
@@ -39,7 +55,31 @@ interface WBDocument {
|
||||
}
|
||||
```
|
||||
|
||||
> **虚拟 frame 陷阱**:无 title、无 fillColor、无 borderColor、无 borderWidth 的 frame 在编译时会被跳过(子节点直接提升到父级)。如果给这种虚拟 frame 设了 id 并用 connector 连接它,编译后 frame 消失,connector 引用会失效。解决办法:给 frame 加上 `borderWidth: 0` 或任意可见属性,阻止它被优化掉。
|
||||
**Dagre 嵌套排版规则**:
|
||||
|
||||
1. **不透明节点(Opaque Node)**:Dagre 内的子容器,无论 `layout` 是 `flex`、`absolute` 还是 `dagre`,只要未声明 `isCluster: true`,对外层 Dagre 就是具有确定宽高的不透明原子节点。外层连线无法寻址其内部子节点。
|
||||
2. **连线兜底重定向(Edge Redirect Fallback)**:当 `edges` 引用了某不透明节点内部的子节点 ID 时,引擎自动将该连线端点重定向至其最近的不透明祖先节点。不报错,不产生悬空连线。
|
||||
3. **透明子图(Compound Cluster)**:子容器同时声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }` 时,成为外层 Dagre 的复合子图。其内部子节点直接参与外层拓扑运算,连线可穿越子图边界。子图自身不执行独立排版,尺寸由外层 Dagre 根据内部节点包围盒自动撑开。
|
||||
|
||||
**isCluster 最小用法**:
|
||||
```json
|
||||
{
|
||||
"type": "frame", "id": "cluster_a",
|
||||
"layout": "dagre", "layoutOptions": { "isCluster": true },
|
||||
"fillColor": "#F0FDF4", "borderColor": "#86EFAC", "borderWidth": 2, "borderDash": "dashed", "borderRadius": 16,
|
||||
"children": [
|
||||
{ "type": "text", "text": "区域标题", "fontSize": 11, "textColor": "#15803D" },
|
||||
{ "type": "rect", "id": "node_inside", "width": 120, "height": 40, "text": "内部节点" }
|
||||
]
|
||||
}
|
||||
```
|
||||
> 注意:`edges` 必须写在**最外层的根 Dagre** 的 `layoutOptions` 中,不要写在 cluster 内部。
|
||||
**其他约束**:
|
||||
- `layout / gap / padding` 在 schema 层是 optional,但实际生成时推荐显式写出,避免依赖默认行为。
|
||||
- `layoutOptions` 仅在 `layout: 'dagre'` 时生效。
|
||||
- `children` 里不能出现 `connector`。
|
||||
|
||||
> **虚拟 frame 陷阱**:没有 `fillColor`、`borderColor`、`borderWidth` 的 frame 在编译时可能被当作纯布局容器跳过(子节点直接提升到父级)。如果给这种 frame 设了 `id` 并让外部 connector 连接它,编译后 frame 消失,connector 引用会失效。需要保留这个 frame 时,请给它加上不会被优化掉的外观属性。
|
||||
|
||||
### 基础图形
|
||||
|
||||
@@ -130,10 +170,9 @@ interface WBDocument {
|
||||
lineStyle?: 'solid' | 'dashed' | 'dotted';
|
||||
startArrow?: 'none' | 'arrow' | 'triangle' | 'circle' | 'diamond';
|
||||
endArrow?: 'none' | 'arrow' | 'triangle' | 'circle' | 'diamond';
|
||||
label?: string; // 连线中间的标签文字
|
||||
waypoints?: { x: number; y: number }[]; // polyline 途经点
|
||||
label?: string; // 连线中间的标签文字
|
||||
labelPosition?: number; // 标签位置,0-1,默认 0.5(中点)
|
||||
waypoints?: { x: number; y: number }[]; // polyline 途经点
|
||||
label?: string; // 连线中间的标签文字
|
||||
labelPosition?: number; // 标签位置,0-1,默认 0.5(中点)
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -198,6 +237,50 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
|
||||
"svg": { "code": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>" } }
|
||||
```
|
||||
|
||||
### Icon(内置图标)
|
||||
|
||||
引用画板内置图标库的图标。比手写 SVG 更简单——只需指定 `name`。
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'icon';
|
||||
id?: string;
|
||||
x?: number; y?: number;
|
||||
width?: WBSizeValue; // 默认 48
|
||||
height?: WBSizeValue; // 默认 48,保持正方形
|
||||
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.0 --icons 输出中选取
|
||||
color?: string; // 可选颜色覆盖,hex 格式如 '#FF6600'
|
||||
}
|
||||
```
|
||||
|
||||
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 --icons
|
||||
```
|
||||
|
||||
用法:
|
||||
```json
|
||||
{ "type": "icon", "id": "db", "name": "database", "width": 48, "height": 48 }
|
||||
```
|
||||
|
||||
**使用建议**:
|
||||
- 当图表中的节点代表具体事物(服务器、用户、数据库等)时,用图标比纯文字方块更直观
|
||||
- 一张图 3-8 个图标为宜,为关键组件配图标,次要节点用普通形状
|
||||
- 用 `color` 为图标指定合适的颜色, 比如与所在容器的配色一致
|
||||
- 图标可放在 frame 子元素中参与 flex 布局,连线可通过 id 连接到图标
|
||||
- 图标+文字组合:frame(vertical) 中放 icon + text,形成富组件
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 8, "padding": 12,
|
||||
"alignItems": "center", "fillColor": "#F0F5FF", "borderColor": "#ADC6FF",
|
||||
"children": [
|
||||
{ "type": "icon", "id": "db-icon", "name": "database", "width": 36, "height": 36 },
|
||||
{ "type": "text", "text": "PostgreSQL", "fontSize": 12, "width": "fit-content", "height": "fit-content" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 富文本 WBTextRun
|
||||
@@ -239,12 +322,12 @@ interface WBTextRun {
|
||||
|
||||
## 尺寸值 WBSizeValue
|
||||
|
||||
| 值 | 含义 | 注意 |
|
||||
|----|------|------|
|
||||
| `number` | 固定像素 | 任何场景 |
|
||||
| `'fit-content'` | 由内容决定大小 | 父级需要 Flex 布局 |
|
||||
| `'fit-content(N)'` | 同上,无内容时 fallback N | 同上 |
|
||||
| `'fill-container'` | 填满父级剩余空间 | 父级需要 Flex 布局,且祖先链有固定宽度 |
|
||||
| `'fill-container(N)'` | 同上,无 Flex 时 fallback N | — |
|
||||
| 值 | 含义 | 注意 |
|
||||
| --------------------- | --------------------------- | -------------------------------------- |
|
||||
| `number` | 固定像素 | 任何场景 |
|
||||
| `'fit-content'` | 由内容决定大小 | 父级需要 Flex 布局 |
|
||||
| `'fit-content(N)'` | 同上,无内容时 fallback N | 同上 |
|
||||
| `'fill-container'` | 填满父级剩余空间 | 父级需要 Flex 布局,且祖先链有固定宽度 |
|
||||
| `'fill-container(N)'` | 同上,无 Flex 时 fallback N | — |
|
||||
|
||||
`fill-container` 在 `layout: 'none'`(绝对定位)下无效。`fit-content` 仍可用于含文字节点(引擎通过 Yoga measureFunc 测量文字尺寸)。
|
||||
@@ -209,6 +209,23 @@
|
||||
{ "fillColor": "#1F2329", "borderColor": "#1F2329", "borderWidth": 2, "borderRadius": 0, "fontSize": 15, "textColor": "#FFFFFF", "textAlign": "center" }
|
||||
```
|
||||
|
||||
### 图标组件
|
||||
|
||||
icon + text 的组合卡片。icon 的 `color` 跟随所属分组的 borderColor,与其他节点视觉一致。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 4, "padding": 12,
|
||||
"alignItems": "center", "fillColor": "#FFFFFF", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8,
|
||||
"children": [
|
||||
{ "type": "icon", "name": "server", "width": 36, "height": 36, "color": "#5178C6" },
|
||||
{ "type": "text", "width": "fit-content", "height": "fit-content", "text": "应用服务器", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
icon color 需要结合上下文选择合适的颜色, 比如: 使用所属分组的borderColor
|
||||
|
||||
### textColor 规则
|
||||
|
||||
```
|
||||
@@ -60,6 +60,12 @@ Shape 节点默认 `textAlign: 'center'` + `verticalAlign: 'middle'`(与 CSS
|
||||
|
||||
---
|
||||
|
||||
## 图标+文字组合
|
||||
|
||||
icon + text 纵向排列时:icon 宽高 36-48px,下方文字 fontSize 12-13,外层 frame gap 4-8。icon 比文字大 2-3 倍时视觉比例最佳。
|
||||
|
||||
---
|
||||
|
||||
## 尺寸规则
|
||||
|
||||
含文字节点 `height` 必须用 `'fit-content'`。写死高度会截断文字。
|
||||
@@ -11,15 +11,16 @@
|
||||
- 技术组件加括号注明技术栈(如"消息队列\n(Kafka)")
|
||||
- 存储节点必须用 `cylinder` 类型(弧度固定 16px,禁止 `fill-container` 宽度,用 120-200 固定宽度)。每行最多 4 个 cylinder(超过 4 个换行或合并同类项,如多个 MySQL 合并为"关系数据库\n(MySQL)")
|
||||
- 侧边栏(如运维监控、基础设施)只在用户明确要求时才加,最多 2-3 项。不要自作主张添加侧边栏
|
||||
- 可使用 icon+text 组合更直观的进行内容展示和增强辨识度
|
||||
- **连线:非必要不画。** 架构图的分层结构本身已表达了调用方向(上层调下层),不需要每对节点都连线。只在需要强调特定调用关系时才画,且总数不超过 3-5 条
|
||||
|
||||
## Layout 选型
|
||||
|
||||
| 模式 | 适用条件 | 特征 |
|
||||
|------|---------|------|
|
||||
| 模式 | 适用条件 | 特征 |
|
||||
| -------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| **grid(分层条带)** | 有明确上下层级关系(接入→网关→服务→存储) | 行=层级,每行 horizontal frame 等分节点。左侧 text 标签 + 右侧层 frame(Label-Outside 模式) |
|
||||
| **grid(网格矩阵)** | 多模块平级,无明确层级 | N×M 网格等分,每格一个模块 |
|
||||
| **混合(岛屿式)** | 模块间网状互联,无清晰分层 | 宏观 `layout: "none"` + x/y 定位各模块岛屿,微观每个岛屿内部用 flex 布局 |
|
||||
| **grid(网格矩阵)** | 多模块平级,无明确层级 | N×M 网格等分,每格一个模块 |
|
||||
| **混合(岛屿式)** | 模块间网状互联,无清晰分层 | 宏观 `layout: "none"` + x/y 定位各模块岛屿,微观每个岛屿内部用 flex 布局 |
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .js 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.1.0` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .js 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
|
||||
|
||||
## Layout 规则
|
||||
@@ -172,13 +172,6 @@
|
||||
- bar-2 (150): height = (150/200)*400 = 300, y = 480-300 = 180
|
||||
- bar-0 x = 80 + 0*300 + 80/2 = 120, bar-1 x = 80 + 1*300 + 40 = 420, bar-2 x = 80 + 2*300 + 40 = 720
|
||||
|
||||
**脚本运行方式**:
|
||||
|
||||
```bash
|
||||
node generate-bar-chart.js
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i bar-chart.json -o ./bar-chart.png
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
|
||||
- 单系列用多色(不专业):同一数据系列所有柱体应使用同一颜色
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .js 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.1.0` 渲染
|
||||
- **脚本生成坐标**(必须):用 .js 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -182,7 +182,7 @@ categories.forEach((cat, index) => {
|
||||
});
|
||||
});
|
||||
|
||||
fs.writeFileSync('fishbone-diagram.json', JSON.stringify({ version: 2, nodes }, null, 2));
|
||||
fs.writeFileSync('diagram.json', JSON.stringify({ version: 2, nodes }, null, 2));
|
||||
```
|
||||
|
||||
## 连线格式与注意点
|
||||
@@ -228,13 +228,6 @@ fs.writeFileSync('fishbone-diagram.json', JSON.stringify({ version: 2, nodes },
|
||||
|
||||
上述骨架展示一个分类(上方)+ 一条原因的模式。完整鱼骨图重复此模式,上下交替。每个分类下可有多条原因,均匀插值分布在分支骨上。
|
||||
|
||||
**脚本运行方式**:
|
||||
|
||||
```bash
|
||||
node generate-fishbone.js
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i fishbone.json -o ./fishbone.png
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
|
||||
- **代码生成**:必须使用带有动态防重叠算法的脚本来计算坐标并输出 JSON。
|
||||
185
skills/lark-whiteboard-cli/scenes/flowchart.md
Normal file
185
skills/lark-whiteboard-cli/scenes/flowchart.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 流程图 (Flowchart)
|
||||
|
||||
适用于:各种业务流转图、决策树、审批流、时序控制逻辑、带条件判断的链路、系统架构拓扑等。
|
||||
|
||||
通用字段语义详见 `references/schema.md`,通用布局原则详见 `references/layout.md`;本文件只描述流程图场景下的选型边界与范式。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **流程图必须走 DSL 路径,不再使用 Mermaid!**
|
||||
> 复杂分支、判断、回路、跳级关系优先使用 `layout: "dagre"` 计算拓扑;如果只是规整的单线流水线,且卡片强对齐比自动拓扑更重要,也可以使用 Flex + 顶层 `connector` 组合实现。
|
||||
|
||||
## 美学规范
|
||||
|
||||
- **摒弃简陋节点,推崇全卡片化**:核心业务节点不要只用一个纯文本 `rect`。**应优先采用 Flex 组合卡片**(如:在 `vertical` frame 内上下组合【Emoji 标题项】和【补充说明项】),使得节点信息结构化、层级分明。
|
||||
- **语义化色彩编排**:节点底色严禁随机分配。必须按状态语义映射:常规链路用浅蓝/浅紫、核心风控/检查用预警黄、成功通过用生命绿、失败熔断用危险红。边框颜色可同色系加深,以凸显卡片边缘。
|
||||
- **统一判定逻辑**:条件分支必须使用 `diamond` 菱形节点,并且**严禁漏掉** `layoutOptions.edges` 边定义里的第三个标签参数(必须清晰写明"是/否"、"通过/拒绝")。
|
||||
- **形状多样化**:合理搭配不同形状来表达语义 —— `ellipse` 用于外部实体/起终点、`diamond` 用于判断路由、`rect` 用于业务处理节点、`cylinder` 用于持久化存储。
|
||||
|
||||
## Layout 选型
|
||||
|
||||
| 模式 | 适用条件 | 核心配置 |
|
||||
| ---------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **主体用 Dagre** | 有判断、分支、回路、回退、跳级关系的标准流程图 | 主体 frame 设定 `layout: "dagre"`,按需配置 `rankdir: "TB"` 或 `rankdir: "LR"`。 |
|
||||
| **局部复合节点** | 流程中的某一步本身是一个小型 UI 组合体 | 外层仍用 `dagre`,复合步骤内部改用 `layout: "vertical"` / `"horizontal"`。此类节点为**不透明节点**,外层连线只能连到外壳。 |
|
||||
| **透明子图** | 需按业务区域分组,且连线穿越区域边界 | 子容器声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`,成为透明子图。内部节点直接参与外层拓扑运算。 |
|
||||
| **规整流水线** | 基本是单线 A → B → C → D,且卡片对齐要求极高 | 主体可用 Flex 排版,连线改用顶层 `connector`;不要为了"自动"而硬上 Dagre。 |
|
||||
|
||||
## 核心属性
|
||||
|
||||
- **`rankdir`**: `TB`(上下)或 `LR`(左右)。**强烈推荐优先使用 `LR`**,充分利用宽屏横向空间。
|
||||
- **`edges`**: 在根 Dagre 的 `layoutOptions.edges` 中按 `[fromId, toId, "标签"]` 声明。**支持反向连接**实现闭环。所有 edges 统一写在**最外层根 Dagre**,不要写在 cluster 内部。
|
||||
- **`ranksep` 与边文本**: 若边上标注了说明文字,**必须根据字数调大间距**:`ranksep = max(60, 字数 × 16)`。
|
||||
- **自适应尺寸**: dagre 容器**必须**设定 `width: "fit-content"` 和 `height: "fit-content"`。
|
||||
- **`clusterTitle`**: 透明子图可通过 `clusterTitle` 声明悬浮标题(自动吸附左上角、加粗 14px),搭配 `clusterTitleColor` 指定标题颜色。
|
||||
|
||||
## 两种嵌套模式
|
||||
|
||||
### 不透明节点(Opaque Node)
|
||||
Dagre 内的子容器,只要未声明 `isCluster: true`,对外层 Dagre 就是具有确定宽高的原子节点。外层连线无法寻址其内部子节点。适合封装复杂的组合卡片(如带图标、版本号、多行描述的业务模块)。
|
||||
|
||||
### 透明子图(Compound Cluster)
|
||||
子容器同时声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }` 时,成为外层 Dagre 的复合子图。其内部子节点直接参与外层拓扑运算,连线可穿越子图边界。适合划分网络区域、功能层级、命名空间等边界容器。推荐搭配 `borderDash: "dashed"` 虚线边框 + 淡色背景。
|
||||
|
||||
## 骨架示例(推荐范本)
|
||||
|
||||
以下是一个混合架构拓扑的完整示例。它同时展示了**透明子图**(Kubernetes Zone,连线可穿透)和**不透明复合节点**(DB 集群、AI 引擎,连线只能连外壳)的标准写法,以及多种形状(ellipse / diamond / rect / cylinder)和语义化配色规范。
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "root",
|
||||
"x": 20, "y": 20,
|
||||
"layout": "dagre",
|
||||
"width": "fit-content", "height": "fit-content",
|
||||
"padding": 60,
|
||||
"fillColor": "#F8FAFC",
|
||||
"borderColor": "#CBD5E1",
|
||||
"borderWidth": 1,
|
||||
"borderRadius": 16,
|
||||
"layoutOptions": {
|
||||
"rankdir": "LR",
|
||||
"nodesep": 60,
|
||||
"ranksep": 120,
|
||||
"edges": [
|
||||
["user", "k8s_ingress", "HTTPS request"],
|
||||
["k8s_ingress", "web_pod", "Route UI"],
|
||||
["k8s_ingress", "api_pod", "Route API"],
|
||||
["web_pod", "api_pod", "Internal REST"],
|
||||
["api_pod", "db_cluster", "SQL Query"],
|
||||
["api_pod", "ai_service", "gRPC Stream"]
|
||||
]
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "ellipse", "id": "user", "text": "Global Users",
|
||||
"width": 110, "height": 60,
|
||||
"fillColor": "#E2E8F0", "borderColor": "#64748B", "borderWidth": 1,
|
||||
"fontSize": 14, "textColor": "#334155"
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "zone_k8s",
|
||||
"layout": "dagre",
|
||||
"layoutOptions": {
|
||||
"isCluster": true,
|
||||
"clusterTitle": "☸️ Kubernetes Zone (isCluster)",
|
||||
"clusterTitleColor": "#2563EB"
|
||||
},
|
||||
"fillColor": "#EFF6FF", "borderColor": "#60A5FA",
|
||||
"borderWidth": 2, "borderDash": "dashed", "borderRadius": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "diamond", "id": "k8s_ingress", "text": "Nginx Ingress",
|
||||
"width": 130, "height": 70,
|
||||
"fillColor": "#DBEAFE", "borderColor": "#3B82F6", "borderWidth": 2,
|
||||
"textColor": "#1E40AF"
|
||||
},
|
||||
{
|
||||
"type": "rect", "id": "web_pod", "text": "Next.js SSR Pod",
|
||||
"width": 140, "height": 48,
|
||||
"fillColor": "#BFDBFE", "borderColor": "#2563EB", "borderWidth": 2,
|
||||
"borderRadius": 8, "textColor": "#1E3A8A"
|
||||
},
|
||||
{
|
||||
"type": "rect", "id": "api_pod", "text": "Go Lang API Pod",
|
||||
"width": 140, "height": 48,
|
||||
"fillColor": "#BFDBFE", "borderColor": "#2563EB", "borderWidth": 2,
|
||||
"borderRadius": 8, "textColor": "#1E3A8A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "db_cluster",
|
||||
"layout": "vertical", "gap": 16, "padding": [20, 24],
|
||||
"alignItems": "center",
|
||||
"fillColor": "#F0FDF4", "borderColor": "#22C55E",
|
||||
"borderWidth": 2, "borderRadius": 16,
|
||||
"children": [
|
||||
{
|
||||
"type": "text", "id": "db_title",
|
||||
"text": "🗄️ Highly Available DB (不透明)", "fontSize": 14, "textColor": "#14532D"
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "db_row", "layout": "horizontal", "gap": 20,
|
||||
"children": [
|
||||
{
|
||||
"type": "cylinder", "id": "db_master", "text": "Master",
|
||||
"width": 80, "height": 50,
|
||||
"fillColor": "#DCFCE7", "borderColor": "#16A34A", "borderWidth": 1,
|
||||
"textColor": "#166534"
|
||||
},
|
||||
{
|
||||
"type": "cylinder", "id": "db_replica", "text": "Replica",
|
||||
"width": 80, "height": 50,
|
||||
"fillColor": "#DCFCE7", "borderColor": "#16A34A", "borderWidth": 1,
|
||||
"textColor": "#166534"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "ai_service",
|
||||
"layout": "vertical", "gap": 10, "padding": [16, 20],
|
||||
"alignItems": "center",
|
||||
"fillColor": "#FAF5FF", "borderColor": "#A855F7",
|
||||
"borderWidth": 2, "borderRadius": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "text", "id": "ai_title",
|
||||
"text": "🧠 Multi-Modal Engine (不透明)", "fontSize": 14, "textColor": "#6B21A8"
|
||||
},
|
||||
{
|
||||
"type": "rect", "id": "ai_version",
|
||||
"text": "v4.2.1-beta", "width": 90, "height": 22,
|
||||
"fillColor": "#E9D5FF", "borderColor": "#C084FC", "borderWidth": 1,
|
||||
"borderRadius": 4, "fontSize": 11, "textColor": "#581C87"
|
||||
},
|
||||
{
|
||||
"type": "text", "id": "ai_desc",
|
||||
"text": "Includes Vector Store\n& Transformer Blocks",
|
||||
"fontSize": 12, "textColor": "#7E22CE", "textAlign": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**范本要点**:
|
||||
- `zone_k8s` 是**透明子图**(`isCluster: true` + `clusterTitle`),外部连线穿越虚线边界直达 `k8s_ingress`、`web_pod`、`api_pod`。
|
||||
- `db_cluster` 和 `ai_service` 是**不透明节点**(`layout: "vertical"`),内部用 Flex 组合了多行结构化信息,对外层 Dagre 是固定宽高的原子。连线只能连到外壳 ID。
|
||||
- 所有 `edges` 统一写在最外层根 Dagre 的 `layoutOptions` 中。
|
||||
- 本范本中用到了 `ellipse`(外部实体)、`diamond`(路由判断)、`rect`(业务节点)、`cylinder`(数据库存储)四种形状。
|
||||
|
||||
## 陷阱与常见报错防范
|
||||
|
||||
- **误用 Mermaid**:只要用户没有带 `mermaid` 具体语法代码,哪怕描述明确是"流程图",也**强制使用 DSL 框架下的 Dagre 模式**。
|
||||
- **重复画线**:`dagre` 里的所有子节点关系通过 `edges` 定义,引擎会自动生成连线。**绝对不要再去外层用 `connector` 节点重复连一次**。
|
||||
- **穿透黑盒**:普通子容器是不透明节点,外部连线无法直接寻址其内部子节点(引擎会自动重定向至外壳)。若需穿透,必须声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }`。
|
||||
- **`id` 缺失**:只要是在 `edges` 里出现的标识符,`children` 里一定能找到同名 `id` 的节点对应,拼写必须完全一致。
|
||||
- **宽度灾难**:Dagre 内容器禁止子框使用 `fill-container`,因为 dagre 父容器本身是被内容撑开的。
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .js 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.1.0` 渲染
|
||||
- **脚本生成坐标**(必须):用 .js 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -53,7 +53,7 @@ nodes 数组中的图层顺序(必须严格遵守):
|
||||
|
||||
此场景必须用 .js 脚本生成。Agent 使用时只需修改 `stages` 数组和 `centerTitle`/`centerSubtitle`,其余坐标全自动计算。
|
||||
|
||||
```json
|
||||
```javascript
|
||||
const { writeFileSync } = require('fs');
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -184,14 +184,7 @@ nodes.push({
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
writeFileSync('flywheel.json', JSON.stringify({ version: 2, nodes }, null, 2));
|
||||
```
|
||||
|
||||
**脚本运行方式**:
|
||||
|
||||
```bash
|
||||
node generate-flywheel.js
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i flywheel.json -o ./flywheel.png
|
||||
writeFileSync('diagram.json', JSON.stringify({ version: 2, nodes }, null, 2));
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
@@ -90,7 +90,7 @@ const output = {
|
||||
]
|
||||
};
|
||||
|
||||
fs.writeFileSync('funnel.json', JSON.stringify(output, null, 2));
|
||||
fs.writeFileSync('diagram.json', JSON.stringify(output, null, 2));
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .js 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.1.0` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .js 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -199,13 +199,6 @@
|
||||
- 点3 (Q4, 180): pointX = 80 + (3/3)*900 = 980, pointY = 480 - ((180-100)/120)*400 = 213
|
||||
- ellipse 定位:ellipseX = pointX - 6, ellipseY = pointY - 6
|
||||
|
||||
**脚本运行方式**:
|
||||
|
||||
```bash
|
||||
node generate-line-chart.js
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i line-chart.json -o ./line-chart.png
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
|
||||
- Y 轴范围不合理:若数据集中在 80-120,Y 轴从 0 到 120 会让折线挤在顶部一小段区域,应设 yMin 接近数据最小值
|
||||
@@ -14,12 +14,12 @@
|
||||
满足以下任一条件时使用:
|
||||
- 用户明确要求 "用 Mermaid" 或 "输出 Mermaid"
|
||||
- 用户直接粘贴了 Mermaid 语法文本
|
||||
- 图表类型为思维导图、时序图、类图、饼图、流程图(自动路由)
|
||||
- 图表类型为思维导图、时序图、类图、饼图(自动路由)
|
||||
|
||||
## CLI 用法
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i diagram.mmd -o output.png
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i ./diagrams/{name}.mmd -o ./diagrams/{name}.png
|
||||
```
|
||||
|
||||
## 思维导图 (Mindmap)
|
||||
@@ -80,7 +80,11 @@ pie title 分布
|
||||
|
||||
## 流程图 (Flowchart)
|
||||
|
||||
适用于:业务流程、审批流、订单处理流程等有明确顺序和分支判断的场景。
|
||||
> [!WARNING]
|
||||
> **流程图不推荐使用 Mermaid 路径!**
|
||||
> 带复杂分支、复合节点、高保真卡片样式的流程图应优先走 **DSL 路径**(参见 `scenes/flowchart.md`)。只有用户明确给出 Mermaid 代码,或场景本身就是极简文字流程时,才走此路径。
|
||||
|
||||
适用于:极简的文字节点判断业务流。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -129,4 +133,4 @@ stateDiagram-v2
|
||||
- 输出纯 Mermaid 文本,不是 JSON,不要混用 DSL
|
||||
- 节点文字含特殊字符时用双引号包裹:`A["包含(括号)的文字"]`
|
||||
- `subgraph` 用于逻辑分组
|
||||
- 流程图默认 `TD`(上到下),如果流程较宽(步骤多但层级浅),用 `LR`(左到右)
|
||||
- Mermaid 的流程图样式较基础,也无法在节点内部嵌套复杂排版;复杂流程优先走 DSL(见 `scenes/flowchart.md`),极简文字流程或用户显式给 Mermaid 代码时再使用 Mermaid。
|
||||
@@ -161,7 +161,7 @@
|
||||
]
|
||||
};
|
||||
|
||||
fs.writeFileSync('org_chart.json', JSON.stringify(doc, null, 2));
|
||||
fs.writeFileSync('diagram.json', JSON.stringify(doc, null, 2));
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
@@ -82,7 +82,7 @@ const output = {
|
||||
]
|
||||
};
|
||||
|
||||
fs.writeFileSync('pyramid.json', JSON.stringify(output, null, 2));
|
||||
fs.writeFileSync('diagram.json', JSON.stringify(output, null, 2));
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
370
skills/lark-whiteboard-cli/scenes/swimlane.md
Normal file
370
skills/lark-whiteboard-cli/scenes/swimlane.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# 泳道图(Swimlane)
|
||||
|
||||
适用于:跨角色/跨系统的端到端流程(用户/网关/服务/存储/回调)、多泳道协作流程、系统交互链路图。
|
||||
|
||||
支持两种方向:
|
||||
- **水平泳道**:泳道为横向条带(自上而下排列),流程从左到右推进
|
||||
- **垂直泳道**:泳道为纵向列(自左向右排列),流程从上到下推进
|
||||
|
||||
## Content 约束
|
||||
|
||||
- 泳道数(lanes)建议 3-7,超过 7 会显著降低可读性;如必须更多泳道,优先合并同类或拆成两张图
|
||||
- 阶段数(stages)建议 4-8;超过 8 优先合并相邻阶段或改成“代表性阶段”
|
||||
- 每个阶段在每条泳道中最多放 1 个“主步骤卡片”;如同一阶段需要多个步骤,放在同一格内做纵向堆叠(2-3 个为上限)
|
||||
- 节点文本 1-2 行为主;长文本用 `\n` 手动换行,避免单行超长导致卡片过宽
|
||||
- 仅画必要连线:泳道图的结构已经表达了“属于哪个角色/系统 + 发生顺序”,连线只用于表达跨泳道交互、关键因果关系或异步事件流
|
||||
|
||||
## Layout 选型
|
||||
|
||||
| 模式 | 适用条件 | 特征 |
|
||||
|------|---------|------|
|
||||
| **水平泳道** | 默认推荐;流程天然左→右推进 | lanes=行,stages=列;跨泳道同一阶段严格 x 对齐 |
|
||||
| **垂直泳道** | 用户明确要求竖版、或画布更适合纵向滚动阅读 | lanes=列,stages=行;跨泳道同一阶段严格 y 对齐 |
|
||||
|
||||
## Layout 规则
|
||||
|
||||
### 通用规则(两种方向都适用)
|
||||
|
||||
1. **网格对齐是第一优先级**:跨泳道同一阶段必须严格对齐(水平对齐 x;垂直对齐 y)。对齐通过“共享阶段标尺(stage ruler / stage slots)”实现,不靠肉眼估算,也不靠逐节点随意手写坐标
|
||||
2. **只生成真实节点**:为保证跨泳道阶段严格对齐,所有阶段统一保留透明的 **stage cell**;仅在真实阶段的 cell 内生成卡片节点,并按阶段索引映射到对应槽位
|
||||
3. **泳道容器禁止使用背景色**:每条泳道是一个可见的分组容器,只能使用边框表达分组(建议统一使用浅灰色细虚线 `borderDash: "dashed"`,`borderWidth: 1`,`borderColor: "#DEE0E3"`),**不得使用任何有色背景作为泳道底色**,以降低视觉噪音。必须显式声明 `fillColor: "transparent"`,保持视觉透明
|
||||
4. **步骤卡片**:使用 `rect`。为建立清晰的视觉层级,卡片**必须填充浅色背景**(参考 `references/style.md` 中的浅色板,如极浅的主题色),边框使用对应的主题主色(`borderWidth: 1-2`),文字使用深色(如 `#1F2329`)以确保可读性。统一圆角;宽高以可读为先,避免过窄导致换行过多
|
||||
5. **间距**:只要存在 connector 连线,卡片之间的主轴间距必须满足 `gap >= 40`;如果连线包含文字(`label`),主轴间距必须 `gap >= 64`,以提供充足的阅读空间。
|
||||
|
||||
### 子节点对齐
|
||||
|
||||
- **同一阶段必须严格对齐**:所有泳道复用同一套 stage slots;不允许靠卡片自身宽度或肉眼估算来对齐
|
||||
- **卡片宽度一致**:同一泳道中的步骤卡片应保持统一宽度;推荐使用统一固定宽度,或严格复用同一槽位宽度
|
||||
- **统一使用 stack 容器**:有内容的阶段统一使用 `layout: "vertical"` 的 stack frame(纵向堆叠 1-3 张卡片);空阶段不生成 stack/卡片,但保留透明 cell 保证对齐
|
||||
- **垂直居中但不影响对齐**:stage cell 默认 `alignItems: "stretch"`,可用 `justifyContent: "center"` 让卡片在 cell 内居中,以确保左右边界严格对齐
|
||||
- **不靠底色区分行/列**:阶段网格默认不需要背景色;如需“轻微”的行/列边界提示,优先给 stage cell 加 1px 细边框(`fillColor: "transparent"` 仍保持视觉透明)
|
||||
|
||||
### Flex 栅格模式(默认)
|
||||
|
||||
- lane body 使用 Flex 布局:水平泳道用 `layout: "horizontal"`,垂直泳道用 `layout: "vertical"`
|
||||
- 为每个阶段生成一个 **stage cell**(占位单元格);空阶段的 cell 透明但保留;cell 内用 `layout: "vertical"` 的 stack 承载 1-3 张卡片
|
||||
- 统一参数:`slotWidth: 180-220`(水平泳道 cell 宽度)、`slotHeight: 64-104`(垂直泳道 cell 高度建议档)、`gap: 40-56`(有连线时必须 ≥40)、`stackGap: 8`、`lanePadding: 16`
|
||||
- 对齐规则:所有泳道复用同一组 `slotWidth/slotHeight/gap`;同一阶段在各泳道上使用相同的 cell 索引保证严格对齐
|
||||
- 尺寸语义:lane body `width/height` 用 `"fit-content"`(Yoga 自适应);卡片 `height: "fit-content"`;Flex 容器内不写子节点 `x/y`
|
||||
- 内容密度:卡片文字 1-2 行;同阶段堆叠上限 2-3;超过上限优先拆分到相邻阶段或缩短文本
|
||||
|
||||
### 跨泳道间距(lanesGap)
|
||||
|
||||
- 根容器承载所有泳道:水平泳道用 `layout: "vertical"`,垂直泳道用 `layout: "horizontal"`
|
||||
- 固定跨泳道主轴间距 `lanesGap`(建议 `64-80`),更宽的间距能让跨泳道连线(特别是带文字时)有更多留白,降低重叠感
|
||||
- 每条泳道作为根容器的子 frame,内部再使用上述 Flex 栅格的 stage cell 布局
|
||||
- `lanesGap` 与 `lanePadding/stackGap` 独立;lane 内容增减不应影响跨泳道间距
|
||||
- 4px 基线对齐:`lanesGap`、`lanePadding`、cell 尺寸建议按 4 的倍数对齐
|
||||
|
||||
### 水平泳道(lanes=行,stages=列)
|
||||
|
||||
- 根容器:`layout: "vertical"`,`gap: lanesGap` 固定;`alignItems: "stretch"`,标题在最上方
|
||||
- 每条泳道:一个可见 frame(分组容器),内部用 `layout: "horizontal"` 分成两块:
|
||||
- 左侧 lane label:固定宽度 text(如 100-140),垂直居中;左对齐(`textAlign: "left"`);title 需要比步骤卡片更醒目,优先通过 `fontSize: 18-20` + `fontWeight: "bold"` + 与泳道边框一致的 `textColor` 实现
|
||||
- 右侧 lane body:`layout: "horizontal"`,包含完整的阶段 **stage cell** 数组;cell 宽度固定为 `slotWidth`,相邻 cell 间 `gap` 统一;空阶段 cell 透明但保留
|
||||
- 步骤卡片:推荐统一卡片宽度(如 160-220),并在所有泳道复用同一组 `slotWidth / gap`,保证跨泳道阶段严格 x 对齐
|
||||
|
||||
### 垂直泳道(lanes=列,stages=行)
|
||||
|
||||
- 根容器:`layout: "horizontal"`,`gap: lanesGap` 固定;`alignItems: "stretch"`,标题在最上方
|
||||
- 每条泳道:一个可见 frame(分组容器),内部 `layout: "vertical"`:
|
||||
- 顶部 lane label:必须放在单独的 `lane label frame` 中,label frame 使用 `width: "fill-container"`、`alignItems: "center"`、`justifyContent: "center"`,并通过 `paddingTop` 留出与泳道上边的 gap(推荐 `12-16`,按 4px 基线取值,如 `padding: [12, 8, 8, 8]`);内部 text 使用 `width: "fill-container"` + `textAlign: "center"`,确保 title 在整条泳道顶部**水平居中**
|
||||
- lane body:`layout: "vertical"`,包含完整的阶段 **stage cell** 数组;cell 高度固定为 `slotHeight`,相邻 cell 间 `gap` 统一;空阶段 cell 透明但保留
|
||||
- 内容居中对齐:stage cell 建议 `alignItems: "center"` + `justifyContent: "center"`,让卡片在每个 cell 内水平/垂直居中;卡片宽度不超过 `slotWidth`(或固定宽度),避免被 `"fill-container"` 拉伸导致“看起来不居中”
|
||||
- 步骤卡片:推荐统一卡片高度或统一 `slotHeight / gap`,保证跨泳道阶段严格 y 对齐
|
||||
- 泳道外层容器必须显式写 `fillColor: "transparent"`、`borderDash: "dashed"`、`borderWidth: 1`、`borderColor: "#DEE0E3"`(统一浅灰色),否则会被编译为虚拟 frame 导致不渲染
|
||||
- 统一高度(Flex 自适应,可选):根容器使用 `alignItems: "stretch"`,每个泳道外层 frame 使用 `height: "fill-container"`;泳道内部仍保持 lane label + lane body 的结构
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lanes-root",
|
||||
"x": 40, "y": 40,
|
||||
"layout": "horizontal",
|
||||
"gap": 64,
|
||||
"alignItems": "stretch",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lane-left",
|
||||
"layout": "vertical",
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
"children": [
|
||||
{ "type": "frame", "id": "lane-left-label-wrap", "layout": "vertical", "width": "fill-container", "height": "fit-content",
|
||||
"alignItems": "center", "justifyContent": "center", "padding": [12, 8, 8, 8], "children": [
|
||||
{ "type": "text", "id": "lane-left-label", "text": "Lane Left", "width": "fill-container", "height": "fit-content",
|
||||
"textAlign": "center", "verticalAlign": "middle", "fontSize": 18, "fontWeight": "bold", "textColor": "#5178C6" }
|
||||
] },
|
||||
{ "type": "frame", "id": "lane-left-body", "layout": "vertical",
|
||||
"gap": 64, "padding": 16,
|
||||
"children": [
|
||||
{ "type": "frame", "id": "stage-1-cell-left", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center",
|
||||
"children": [{ "type": "rect", "id": "c-s1", "width": 200, "height": "fit-content", "fillColor": "#E1EAFA", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8 }] },
|
||||
{ "type": "frame", "id": "stage-2-cell-left", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center", "children": [] }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lane-right",
|
||||
"layout": "vertical",
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
"children": [
|
||||
{ "type": "frame", "id": "lane-right-label-wrap", "layout": "vertical", "width": "fill-container", "height": "fit-content",
|
||||
"alignItems": "center", "justifyContent": "center", "padding": [12, 8, 8, 8], "children": [
|
||||
{ "type": "text", "id": "lane-right-label", "text": "Lane Right", "width": "fill-container", "height": "fit-content",
|
||||
"textAlign": "center", "verticalAlign": "middle", "fontSize": 18, "fontWeight": "bold", "textColor": "#8569CB" }
|
||||
] },
|
||||
{ "type": "frame", "id": "lane-right-body", "layout": "vertical",
|
||||
"gap": 64, "padding": 16,
|
||||
"children": [
|
||||
{ "type": "frame", "id": "stage-1-cell-right", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center", "children": [] },
|
||||
{ "type": "frame", "id": "stage-2-cell-right", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center",
|
||||
"children": [{ "type": "rect", "id": "d-s2", "width": 200, "height": "fit-content", "fillColor": "#EAE6F3", "borderColor": "#8569CB", "borderWidth": 2, "borderRadius": 8 }] }
|
||||
] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "connector", "connector": { "from": "c-s1", "to": "d-s2",
|
||||
"lineShape": "polyline", "lineColor": "#BBBFC4", "lineWidth": 2, "endArrow": "arrow" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 泳道配色(默认色板)
|
||||
|
||||
- **泳道边框**:所有泳道外层容器统一使用浅灰色细虚线(`borderColor: "#DEE0E3"`, `borderWidth: 1`, `borderDash: "dashed"`)。
|
||||
- **泳道标题**:按 `references/style.md` 经典色板为每条泳道分配不同的主题色,泳道 title 的 `textColor` 使用该主题色。
|
||||
- **内容节点(rect)**:采用“浅色底 + 主题色边框”策略。`fillColor` 使用与该泳道主题色对应的极浅色(如浅蓝、浅紫等),`borderColor` 使用对应的主题色,文字 `textColor` 统一使用深色 `#1F2329`。
|
||||
- **连线(connector)**:连线颜色固定为灰色 `#BBBFC4`,不随泳道颜色变化。当连线带有文字(`label`)时,为防止文字压在边框上难以阅读,必须为连线文字设置纯白背景(`labelFillColor: "#FFFFFF"`)遮挡底纹。
|
||||
|
||||
提醒:避免创建“虚拟 frame”(见 `references/schema.md` 的说明)。lane 外层必须具有可见属性以避免在编译时被跳过。
|
||||
|
||||
|
||||
## 连线规则(强制参考 connectors.md)
|
||||
|
||||
泳道图中所有连线的选择与写法必须严格遵循 `references/connectors.md`,尤其是:
|
||||
- `connector` 必须放在 `WBDocument.nodes` 顶层,不能嵌套在 `children`
|
||||
- 默认优先使用自动绕线:`lineShape: "polyline"` / `"rightAngle"`,且不写 `waypoints`
|
||||
- 未指定 `lineShape` 时默认使用 `"rightAngle"`
|
||||
- 只有在必要时才强制锚点方向;锚点选择必须与节点相对位置一致
|
||||
- 有连线时卡片间距必须满足 `gap >= 40`;如果连线包含文字(`label`),主轴间距必须 `gap >= 64`
|
||||
- 带文字的连线必须设置 `labelFillColor: "#FFFFFF"` 遮挡底纹
|
||||
|
||||
泳道图语境下的落地约束:
|
||||
- **默认不写锚点**,交给引擎自动推断;只有需要强制“左→右推进 / 上→下推进”时才写
|
||||
- 需要表达“异步/事件流/推送”(如 SSE/Chunk)时:使用 `lineStyle: "dashed"` 并配合 `label` 说明语义;其他参数仍按 connectors.md
|
||||
- 避免连接“仅用于布局且可能被优化掉的虚拟 frame”,尽量连接具体步骤卡片的节点 id(参考 `references/schema.md` 的虚拟 frame 陷阱)
|
||||
|
||||
## 骨架示例
|
||||
|
||||
> 示例展示布局的结构与对齐方法;实际节点的样式满足当前布局规则的前提下参考 `references/style.md`
|
||||
|
||||
- 水平泳道示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lanes-root",
|
||||
"x": 40,
|
||||
"y": 40,
|
||||
"layout": "vertical",
|
||||
"gap": 64,
|
||||
"alignItems": "stretch",
|
||||
"padding": 0,
|
||||
"width": "fit-content",
|
||||
"height": "fit-content",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lane-a",
|
||||
"layout": "horizontal",
|
||||
"gap": 64,
|
||||
"padding": 16,
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "lane-a-label",
|
||||
"text": "Lane A",
|
||||
"width": 120,
|
||||
"height": "fit-content",
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "middle",
|
||||
"fontSize": 18,
|
||||
"fontWeight": "bold",
|
||||
"textColor": "#5178C6"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "stage-1-cell-a",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 0,
|
||||
"width": 200,
|
||||
"height": "fit-content",
|
||||
"fillColor": "transparent",
|
||||
"alignItems": "stretch",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rect",
|
||||
"id": "a-s1",
|
||||
"width": "fill-container",
|
||||
"height": "fit-content",
|
||||
"fillColor": "#E1EAFA",
|
||||
"borderColor": "#5178C6",
|
||||
"borderWidth": 2,
|
||||
"borderRadius": 8,
|
||||
"text": "[阶段 1 节点]",
|
||||
"fontSize": 14,
|
||||
"textColor": "#1F2329",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "stage-2-cell-a",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 0,
|
||||
"width": 200,
|
||||
"height": "fit-content",
|
||||
"fillColor": "transparent",
|
||||
"alignItems": "stretch",
|
||||
"justifyContent": "center",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lane-b",
|
||||
"layout": "horizontal",
|
||||
"gap": 64,
|
||||
"padding": 16,
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "lane-b-label",
|
||||
"text": "Lane B",
|
||||
"width": 120,
|
||||
"height": "fit-content",
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "middle",
|
||||
"fontSize": 18,
|
||||
"fontWeight": "bold",
|
||||
"textColor": "#8569CB"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "stage-1-cell-b",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 0,
|
||||
"width": 200,
|
||||
"height": "fit-content",
|
||||
"fillColor": "transparent",
|
||||
"alignItems": "stretch",
|
||||
"justifyContent": "center",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "stage-2-cell-b",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 0,
|
||||
"width": 200,
|
||||
"height": "fit-content",
|
||||
"fillColor": "transparent",
|
||||
"alignItems": "stretch",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rect",
|
||||
"id": "b-s2",
|
||||
"width": "fill-container",
|
||||
"height": "fit-content",
|
||||
"fillColor": "#EAE6F3",
|
||||
"borderColor": "#8569CB",
|
||||
"borderWidth": 2,
|
||||
"borderRadius": 8,
|
||||
"text": "[阶段 2 节点]",
|
||||
"fontSize": 14,
|
||||
"textColor": "#1F2329",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "connector",
|
||||
"connector": {
|
||||
"from": "a-s1",
|
||||
"to": "b-s2",
|
||||
"lineShape": "polyline",
|
||||
"lineColor": "#BBBFC4",
|
||||
"lineWidth": 2,
|
||||
"endArrow": "arrow",
|
||||
"label": "[跨泳道交互]",
|
||||
"labelFillColor": "#FFFFFF"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 垂直泳道示例:见上文“垂直泳道”
|
||||
|
||||
- 全泳道统一 `slotWidth/slotHeight/gap`,并为每个阶段生成占位 **stage cell**(空阶段 cell 透明但保留)
|
||||
- Flex 容器内不写子节点 `x/y`;对齐通过 cell 索引与统一尺寸实现
|
||||
- 只有真实阶段才在对应 cell 内生成卡片;空阶段不生成卡片但保留 cell 保证网格完整
|
||||
- 连线必须放在 `nodes` 顶层,并连接具体步骤卡片 id,不要连接 `lane-*-body` 这类布局容器
|
||||
- **水平泳道**:根容器用 `layout: "vertical"` 固定 `lanesGap`;lane body 用 `layout: "horizontal"`;cell 固定宽度 `slotWidth`;主轴 `gap` 统一
|
||||
- **垂直泳道**:根容器用 `layout: "horizontal"` 固定 `lanesGap`;lane body 用 `layout: "vertical"`;cell 固定高度 `slotHeight`;主轴 `gap` 统一
|
||||
- **泳道 title**:title 比步骤卡片更醒目,但仍只用字号、字重、文字色强调;不要给泳道 title 额外加背景条
|
||||
|
||||
## 陷阱
|
||||
|
||||
- **各泳道复用的 stage slots 不一致**:会导致同阶段错位;`slotWidth / slotHeight / gap` 必须全泳道统一
|
||||
- **把 connector 放进 children**:会导致 schema 报错或无法连线(见 connectors.md)
|
||||
- **把辅助容器画成可见元素**:lane body 或其他支撑 frame 必须保持 `fillColor: "transparent"`,除泳道分组容器外不要额外加边框
|
||||
- **手写 waypoints 过早**:先让引擎自动绕线;只有在必要时才通过 waypoints 接管
|
||||
- **连线过多**:按 connectors.md 的连线数量策略降采样,否则跨泳道线会互相遮挡导致不可读
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .js 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.1.0` 渲染
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .js 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- 不适合手动心算坐标
|
||||
|
||||
## Layout 规则
|
||||
@@ -202,13 +202,6 @@
|
||||
- 硬件 40/100 * 1100 = 440,软件 35/100 * 1100 = 385,服务 25/100 * 1100 = 275
|
||||
- 子矩形从 y=75 开始,可用高度 665
|
||||
|
||||
**脚本运行方式**:
|
||||
|
||||
```bash
|
||||
node generate-treemap.js
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i treemap.json -o ./treemap.png
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
|
||||
- **父标签被子矩形遮挡**(最严重):子矩形必须从 y + 35(相对父矩形顶部)开始放置,为父分类标签留出空间
|
||||
@@ -1,214 +1,118 @@
|
||||
---
|
||||
name: lark-whiteboard
|
||||
version: 1.0.0
|
||||
description: >
|
||||
当用户要求在飞书云文档中绘制图表,或使用飞书画板绘制架构图、流程图、思维导图、时序图或其他可视化图表时使用此 skill。
|
||||
compatibility: Requires Node.js 18+
|
||||
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 PlantUML/Mermaid 代码或 OpenAPI 原生格式更新画板内容。
|
||||
当用户需要查看画板内容、导出画板图片、或编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill,无论是否提及"画板"。
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
bins: [ "lark-cli" ]
|
||||
cliHelp: "lark-cli whiteboard --help"
|
||||
---
|
||||
|
||||
# Whiteboard Cli Skill
|
||||
# whiteboard (v1)
|
||||
|
||||
> [!NOTE]
|
||||
> **环境依赖**:绘制画板需要 `@larksuite/whiteboard-cli`(画板 Node.js CLI 工具),以及 `lark-cli`(LarkSuite CLI 工具)。
|
||||
> 如果执行失败,手动安装后重试:`npm install -g @larksuite/whiteboard-cli@^0.1.0`
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 执行 `npm install` 安装新的依赖前,务必征得用户同意!
|
||||
## 核心概念
|
||||
|
||||
### 画板 Token
|
||||
|
||||
画板 token 是画板的唯一标识符。飞书画板嵌入在云文档中,可以从云文档的 `docs +fetch` 结果中获取(`<whiteboard token="xxx"/>`
|
||||
标签),或从 `docs +update` 新建画板后的 `data.board_tokens` 字段中获取。
|
||||
|
||||
## 快速决策
|
||||
|
||||
当需要插入图表时:
|
||||
|
||||
1. 能否使用飞书画板?
|
||||
- 能 → 走画板路径(推荐!可编辑、可协作)
|
||||
- 不能 → 走图片路径
|
||||
|
||||
| 用户需求 | 推荐 Shortcut |
|
||||
|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| "查看这个画板的内容" | [`+query --output_as image`](references/lark-whiteboard-query.md) |
|
||||
| "导出画板为图片" | [`+query --output_as image`](references/lark-whiteboard-query.md) |
|
||||
| "获取画板的 PlantUML/Mermaid 代码" | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| "检查画板是否由 PlantUML/Mermaid 代码块组成" | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| "修改画板某个节点的颜色或文字" | [`+query --output_as raw`](references/lark-whiteboard-query.md) 后 [`+update`](references/lark-whiteboard-update.md) |
|
||||
| "用 PlantUML 绘制画板" | [`+update --input_format plantuml`](references/lark-whiteboard-update.md) |
|
||||
| "用 Mermaid 绘制画板" | [`+update --input_format mermaid`](references/lark-whiteboard-update.md) |
|
||||
| "在画板绘制复杂图表" | [`+update --input_format raw`](references/lark-whiteboard-update.md), 需要使用 whiteboard-cli 工具,参见 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) |
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|---------------------------------------------------|---------------------------------------------|
|
||||
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |
|
||||
| [`+update`](references/lark-whiteboard-update.md) | 更新画板内容,支持 PlantUML、Mermaid 或 OpenAPI 原生格式输入 |
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是画板,不是网页。** 画板是无限画布上自由放置元素,flex 布局是可选增强。
|
||||
### 场景 1: 创作一个画板
|
||||
|
||||
```
|
||||
Step 1: 路由 & 读取知识
|
||||
- 判断渲染路径(见路由表):Mermaid 还是 DSL?
|
||||
- 读对应 scene 指南 — 了解结构特征和布局策略
|
||||
- 确定布局策略(见下方快速判断)和构建方式
|
||||
- 读 references/ 核心模块 — 语法、布局、配色、排版、连线
|
||||
1. 确定需要创作的画板 Token(从用户请求或对应的文档中获取)与要创作的内容
|
||||
2. 参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 生成画板内容
|
||||
3. 使用 [`+update`](references/lark-whiteboard-update.md) shortcut 更新画板内容
|
||||
|
||||
Step 2: 生成完整 DSL(含颜色)
|
||||
- 按 content.md 规划信息量和分组
|
||||
- 按 layout.md 选择布局模式和间距
|
||||
- 按 style.md 上色(用户没指定时用默认经典色板)
|
||||
- 按 schema.md 语法输出完整 JSON
|
||||
- 连线参考 connectors.md,排版参考 typography.md
|
||||
### 场景 2: 修改或优化一个画板
|
||||
|
||||
注意:部分图形(鱼骨/飞轮/柱状/折线等)等, 要按 scene 指南的脚本模板写 .js 脚本生成 JSON:
|
||||
- node xxx.js → 产出 JSON 文件
|
||||
- 用产出的 JSON 文件进入 Step 3
|
||||
1. 确定要修改的画板 Token (从用户请求或对应的文档中获取)
|
||||
2. 使用 [`+query --output_as code`](references/lark-whiteboard-query.md) shortcut 导出画板代码,确认画板是否由 Mermaid 或
|
||||
PlantUML 绘制
|
||||
1. 如果 +query --output_as code 返回了 Mermaid / PlantUML 代码块,则在这一代码的基础上优化修改
|
||||
2. 如果没有返回代码块,则使用 [`+query --output_as image`](references/lark-whiteboard-query.md)
|
||||
获取画板预览图片,根据图片内容参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 重绘优化
|
||||
3. 如果用户只需要简单修改某个节点的文本内容/颜色,可以使用 [
|
||||
`+query --output_as raw`](references/lark-whiteboard-query.md) shortcut 导出画板原生 OpenAPI 格式,并在此基础上修改。
|
||||
4. 如果用户有明确要求,则以用户要求优先。
|
||||
3. 使用 [`+update`](references/lark-whiteboard-update.md) shortcut 创建新的画板内容。根据用户需求,你可能会需要使用 [
|
||||
`docs +update`](../lark-doc/references/lark-doc-update.md) 创建新的画板,或使用 [
|
||||
`+update --overwrite`](references/lark-whiteboard-update.md) 在原画板上覆盖式更新。
|
||||
|
||||
Step 3: 渲染 & 审查 → 交付
|
||||
- 渲染前自查(见下方检查清单)
|
||||
- 渲染 PNG,检查:
|
||||
· 信息完整?布局合理?配色协调?
|
||||
· 文字无截断?连线无交叉?
|
||||
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
|
||||
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
|
||||
- 没问题 → 交付:
|
||||
· 用户要求上传飞书 → 见下方”上传飞书画板”章节中的说明
|
||||
· 用户未指定 → 展示 PNG 图片给用户
|
||||
```
|
||||
## 与 lark-doc 的配合使用
|
||||
|
||||
**布局策略快速判断**(详见 layout.md):
|
||||
### 场景 1: 从文档中获取画板 token
|
||||
|
||||
| 判断条件 | 布局策略 | 构建方式 |
|
||||
|----------|----------|----------|
|
||||
| 有明确上下层级(用户层→服务层→数据层) | Flex 分层 | 直接写 JSON |
|
||||
| 空间位置承载信息(地理、拓扑、角度) | 纯绝对定位 | 写脚本算坐标(node xxx.js) |
|
||||
| 多个独立模块平级互联 | 混合(岛屿式) | 直接写 JSON + 估高辅助 |
|
||||
| 不确定 | 默认 Flex(最安全) | 直接写 JSON |
|
||||
1. 使用 `lark-doc` 的 [`+fetch`](../lark-doc/references/lark-doc-fetch.md) 获取文档内容
|
||||
2. 从返回的 markdown 中解析 `<whiteboard token="xxx"/>` 标签,记录画板 token
|
||||
3. 使用本 skill 的 `+query` 或 `+update` 读取或操作画板
|
||||
|
||||
> **构建方式是强约束**:当 scene 指南要求"脚本生成"时,必须先写脚本(.js)并用 `node` 执行来产出 JSON 文件。绝对定位场景(鱼骨图、飞轮图、柱状图、折线图等)的坐标需要数学计算,直接手写 JSON 极易导致节点重叠或连线穿模。
|
||||
### 场景 2: 新建画板并编辑(完整流程)
|
||||
|
||||
---
|
||||
这是最常见的使用场景,**必须完整执行以下步骤**:
|
||||
|
||||
## 渲染路径选择(DSL or Mermaid)
|
||||
1. 使用 `lark-doc` 的 [`+update`](../lark-doc/references/lark-doc-update.md) 创建空白画板
|
||||
- 在 markdown 中传入 `<whiteboard type="blank"></whiteboard>`
|
||||
- **注意这一 XML 标签不要转义**
|
||||
- 需要多个画板时,重复多个 whiteboard 标签
|
||||
|
||||
| 图表类型 | 路径 | 理由 |
|
||||
|----------|------|------|
|
||||
| 思维导图 | **Mermaid** | 辐射结构自动布局 |
|
||||
| 时序图 | **Mermaid** | 参与方+消息自动排列 |
|
||||
| 类图 | **Mermaid** | 类关系自动布局 |
|
||||
| 饼图 | **Mermaid** | Mermaid 原生支持 |
|
||||
| 流程图 | **Mermaid** | 通过 Mermaid 语法稳定生成结构 |
|
||||
| 其他所有类型 | **DSL** | 精确控制样式和布局 |
|
||||
2. 从响应的 `data.board_tokens` 中获取新建画板的 token 列表
|
||||
- 记录每个 token 对应的图表类型和位置
|
||||
|
||||
**路由规则**:
|
||||
1. **自动 Mermaid**:思维导图、时序图、类图、饼图、流程图 → 默认走 Mermaid
|
||||
2. **显式 Mermaid**:用户输入包含 Mermaid 语法 → 走 Mermaid
|
||||
3. **DSL 路径**:其他所有类型 → 先读核心模块,再读对应场景指南
|
||||
3. 根据文档主题,为每个画板设计相应的内容
|
||||
- 参考下方"常见图表模板与参考指南"选择合适的语法
|
||||
- 使用 Mermaid(推荐)、PlantUML 或 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 生成内容
|
||||
|
||||
**Mermaid 路径**:参考 `scenes/mermaid.md` 编写 `.mmd` 文件,跳过 DSL 模块。
|
||||
**DSL 路径**:按 Workflow 3 步执行。
|
||||
4. **逐个更新画板**:使用本 skill 的 `+update` shortcut 编辑每个画板的内容
|
||||
- 不要遗漏任何一个画板 token
|
||||
- 确保每个画板都有实际内容,不是空白
|
||||
|
||||
---
|
||||
### 常见图表模板与参考指南
|
||||
|
||||
## 模块索引
|
||||
|
||||
### 核心参考(DSL 路径必读)
|
||||
|
||||
| 模块 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| DSL 语法 | `references/schema.md` | 节点类型、属性、尺寸值 |
|
||||
| 内容规划 | `references/content.md` | 信息提取、密度决策、连线预判 |
|
||||
| 布局系统 | `references/layout.md` | 网格方法论、Flex 映射、间距规则 |
|
||||
| 排版规则 | `references/typography.md` | 字号层级、对齐、行距 |
|
||||
| 连线系统 | `references/connectors.md` | 拓扑规划、锚点选择 |
|
||||
| 配色系统 | `references/style.md` | 多色板、视觉层级 |
|
||||
|
||||
|
||||
### 场景指南(按类型选读一个)
|
||||
|
||||
| 图表类型 | 文件 | 适用场景 |
|
||||
|----------|------|----------|
|
||||
| 架构图 | `scenes/architecture.md` | 分层架构、微服务架构 |
|
||||
| 组织架构图 | `scenes/organization.md` | 公司组织、树形层级 |
|
||||
| 对比图 | `scenes/comparison.md` | 方案对比、功能矩阵 |
|
||||
| 鱼骨图 | `scenes/fishbone.md` | 因果分析、根因分析 |
|
||||
| 柱状图 | `scenes/bar-chart.md` | 柱状图、条形图 |
|
||||
| 折线图 | `scenes/line-chart.md` | 折线图、趋势图 |
|
||||
| 树状图 | `scenes/treemap.md` | 矩形树图、层级占比 |
|
||||
| 漏斗图 | `scenes/funnel.md` | 转化漏斗、销售漏斗 |
|
||||
| 金字塔图 | `scenes/pyramid.md` | 层级结构、需求层次 |
|
||||
| 循环/飞轮图 | `scenes/flywheel.md` | 增长飞轮、闭环链路 |
|
||||
| 里程碑 | `scenes/milestone.md` | 时间线、版本演进 |
|
||||
| Mermaid | `scenes/mermaid.md` | 思维导图、时序图、类图、饼图、流程图 |
|
||||
|
||||
---
|
||||
|
||||
## CLI 命令
|
||||
|
||||
**渲染**:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i my-diagram.json -o ./images/my-diagram.png # DSL 路径
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i diagram.mmd -o ./images/diagram.png # Mermaid 路径
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i skeleton.json -o ./images/step1.png -l coords.json # 两阶段(提取坐标)
|
||||
```
|
||||
|
||||
**上传飞书画板**:
|
||||
|
||||
> 上传需要飞书认证。遇到认证或权限错误时,阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 了解登录和权限处理。
|
||||
|
||||
**第一步:获取画板 Token**
|
||||
|
||||
| 用户给了什么 | 怎么获取 Token |
|
||||
|------------|--------------|
|
||||
| 画板 Token(`XXX`) | 直接使用 |
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token=”XXX”/>` 中提取 token |
|
||||
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type=”blank”></whiteboard>' --as user`,从响应的 `data.board_tokens[0]` 获取 token |
|
||||
|
||||
关于飞书文档的创建,读取等更多操作,请参考 lark-doc skill [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md)。
|
||||
|
||||
**第二步:上传**
|
||||
|
||||
> [!CAUTION]
|
||||
> **MANDATORY PRE-FLIGHT CHECK (上传前强制拦截检查)**
|
||||
> 当你要向一个**已存在的画板 Token** 写入内容时,**绝对禁止**直接执行上传命令!你必须严格遵守以下两步:
|
||||
> **强制执行 Dry Run(状态探测)**
|
||||
> 必须先在命令中添加 `--overwrite --dry-run` 参数来探测画板当前状态。示例命令:
|
||||
> ```bash
|
||||
> npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <输入文件> --format json | lark-cli docs +whiteboard-update --whiteboard-token <Token> --overwrite --dry-run --as user
|
||||
> ```
|
||||
>
|
||||
> **解析结果并拦截**
|
||||
> - 仔细阅读 Dry Run 的输出日志。
|
||||
> - **如果日志包含 `XX whiteboard nodes will be deleted`**:这说明画板**非空**,当前操作会覆盖并摧毁用户的原有图表!
|
||||
> - **你必须立即停止操作**,并通过 `AskUserQuestion` 工具(或直接回复)向用户确认:”目标画板当前非空,继续更新将清空原有的 XX 个节点,是否确认覆盖?”
|
||||
> - 只有在用户明确授权”同意覆盖”后,你才能移除 `--dry-run` 真正执行上传。
|
||||
> - 用户可能会要求你不覆盖更新画板内容,在这种情况下,移除 `--overwrite` 和 `--dry-run` 参数再上传。
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <输入文件> --format json | lark-cli docs +whiteboard-update --whiteboard-token <画板Token> --yes --as user
|
||||
```
|
||||
> 画板一经上传不可修改。如需应用身份上传,将 `--as user` 替换为 `--as bot`。
|
||||
> 如果画板非空,先加 `--overwrite --dry-run` 检查待删除节点数,向用户确认后去掉 `--dry-run` 执行。
|
||||
|
||||
**症状→修复表**(视觉审查发现问题时参照):
|
||||
|
||||
| 看到的问题 | 改什么 |
|
||||
|-----------|--------|
|
||||
| 文字被截断 | height 改为 fit-content |
|
||||
| 文字溢出容器右侧 | 增大 width,或缩短文字 |
|
||||
| 节点重叠粘连 | 增大 gap |
|
||||
| 节点挤成一团 | 增大 padding 和 gap |
|
||||
| 连线穿过节点 | 调整 fromAnchor/toAnchor 或增大间距 |
|
||||
| 大面积空白 | 缩小外层 frame 宽度 |
|
||||
| 文字和背景色太接近 | 调整 fillColor 或 textColor |
|
||||
| 布局整体偏左/偏右 | 调整绝对定位的 x 坐标使内容居中 |
|
||||
|
||||
---
|
||||
|
||||
## 渲染前自查
|
||||
|
||||
生成 DSL 后、渲染前,快速检查:
|
||||
|
||||
- [ ] 不同分组用了不同颜色?同组节点样式完全一致?
|
||||
- [ ] 外层浅色背景、内层白色节点?(外重内轻)
|
||||
- [ ] 所有节点有边框(borderWidth=2)?文字在背景上清晰可读?
|
||||
- [ ] 连线用灰色(#BBBFC4),不用彩色?
|
||||
- [ ] frame 都写了 layout 属性?gap 和 padding 都显式设置了?
|
||||
- [ ] 含文字节点 height 用 fit-content?connector 在顶层 nodes 数组?
|
||||
|
||||
---
|
||||
|
||||
## 关键约束速查
|
||||
|
||||
> 最高频出错的规则,即使不读子模块文件也必须遵守。
|
||||
|
||||
1. **含文字节点的 height 必须用 `'fit-content'`** — 写死数值会截断文字
|
||||
2. **`fill-container` 仅在 flex 父容器中生效** — `layout: 'none'` 下宽度退化为 0
|
||||
3. **connector 必须放在顶层 nodes 数组** — 不能嵌套在 frame children 里
|
||||
4. **图层顺序** — 数组顺序 = 绘制顺序。后定义的元素层级越高,会覆盖先定义的。重叠/浮层/标注元素务必放在数组末尾。
|
||||
5. **flex 容器内的 x/y 会被完全忽略** — 需要自由定位时用 `layout: 'none'` 或放在顶层 nodes
|
||||
|
||||
❌ 致命错误:flex 容器内设 x/y,坐标不生效,节点按顺序排列
|
||||
```json
|
||||
{ "type": "frame", "layout": "vertical", "children": [
|
||||
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
|
||||
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
|
||||
]}
|
||||
```
|
||||
✅ 正确:用 `layout: "none"` 或放在顶层 nodes 用 x/y 定位。
|
||||
| 图表类型 | 推荐语法 | 详细参考指南 |
|
||||
|----------------|--------------------|---------------------------------------------------------------------------------------------|
|
||||
| 架构图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 流程图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/flowchart.md](../lark-whiteboard-cli/scenes/flowchart.md) |
|
||||
| 组织架构图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/organization.md](../lark-whiteboard-cli/scenes/organization.md) |
|
||||
| 里程碑/时间线 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/milestone.md](../lark-whiteboard-cli/scenes/milestone.md) |
|
||||
| 鱼骨图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/fishbone.md](../lark-whiteboard-cli/scenes/fishbone.md) |
|
||||
| 对比图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/comparison.md](../lark-whiteboard-cli/scenes/comparison.md) |
|
||||
| 飞轮图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/flywheel.md](../lark-whiteboard-cli/scenes/flywheel.md) |
|
||||
| 金字塔图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/pyramid.md](../lark-whiteboard-cli/scenes/pyramid.md) |
|
||||
| 思维导图/饼图/时序图/类图 | Mermaid | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
| 柱状图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/bar-chart.md](../lark-whiteboard-cli/scenes/bar-chart.md) |
|
||||
| 折线图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/line-chart.md](../lark-whiteboard-cli/scenes/line-chart.md) |
|
||||
| 树状图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/treemap.md](../lark-whiteboard-cli/scenes/treemap.md) |
|
||||
| 漏斗图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/funnel.md](../lark-whiteboard-cli/scenes/funnel.md) |
|
||||
| 泳道图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/swimlane.md](../lark-whiteboard-cli/scenes/swimlane.md) |
|
||||
|
||||
49
skills/lark-whiteboard/references/lark-whiteboard-query.md
Normal file
49
skills/lark-whiteboard/references/lark-whiteboard-query.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# whiteboard +query(查询画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询画板内容,支持导出为预览图片、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|----------------------|----|------------------------------------------------------------------------|
|
||||
| `--whiteboard-token` | 是 | 画板 token,需要拥有画板的读权限 |
|
||||
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`code`(PlantUML/Mermaid 代码)、`raw`(OpenAPI 原生画板节点格式) |
|
||||
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as code/raw` 时可选,不填则直接输出到终端 |
|
||||
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
|
||||
|
||||
## 输出格式
|
||||
|
||||
- `image`:预览图片
|
||||
- `code`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 。
|
||||
|
||||
## 示例
|
||||
|
||||
### 示例 1:导出画板为预览图片
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as image \
|
||||
--output ./preview.png
|
||||
```
|
||||
|
||||
### 示例 2:提取画板中的代码并直接输出
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as code
|
||||
```
|
||||
|
||||
### 示例 3:导出画板原始节点结构到文件
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as raw \
|
||||
--output ./nodes.json \
|
||||
--overwrite
|
||||
```
|
||||
97
skills/lark-whiteboard/references/lark-whiteboard-update.md
Normal file
97
skills/lark-whiteboard/references/lark-whiteboard-update.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# whiteboard +update(更新画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
更新画板内容,支持三种输入格式:
|
||||
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式,不推荐直接编辑。
|
||||
- `plantuml`:PlantUML 代码
|
||||
- `mermaid`:Mermaid 代码
|
||||
|
||||
输入内容可以通过管道从 stdin 读取,或通过 `--source` 指定文件。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|----------------------|----|--------------------------------------------|
|
||||
| `--whiteboard-token` | 是 | 画板 token,需要拥有画板的编辑权限 |
|
||||
| `--idempotent-token` | 否 | 幂等 token,确保更新操作幂等,最小长度 10 个字符 |
|
||||
| `--overwrite` | 否 | 覆盖更新,在更新前删除所有现有内容,默认为 false |
|
||||
| `--source` | 是 | 输入画板内容,支持使用 `@path` 从文件读取,或 `-` 从 stdin 读取 |
|
||||
| `--input_format` | 否 | 输入格式:`raw`、`plantuml`、`mermaid`,默认为 `raw` |
|
||||
|
||||
### 以 raw (OpenAPI 原生画板节点格式) 创作
|
||||
|
||||
**不要以直接生成 json 语法的方式创作 raw 格式的飞书 OpenAPI 原生画板节点参数**
|
||||
|
||||
思维导图,时序图,类图,饼图,流程图等图标推荐使用 Mermaid/PlantUML 语法绘制。
|
||||
|
||||
而当需要绘制架构图,组织架构图,泳道图,对比图,鱼骨图,柱状图,折线图,树状图,漏斗图,金字塔图,循环/飞轮图,里程碑或其他较为复杂的图表时,推荐参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md)
|
||||
使用 whiteboard-cli 工具创作。
|
||||
|
||||
## 示例
|
||||
|
||||
### 示例 1:使用 PlantUML 代码更新画板(从 stdin 读取)
|
||||
|
||||
```bash
|
||||
# 编写 PlantUML 代码
|
||||
cat > diagram.puml << 'EOF'
|
||||
@startuml
|
||||
Alice -> Bob: Hello
|
||||
Bob -> Alice: Hi
|
||||
@enduml
|
||||
EOF
|
||||
|
||||
# 通过管道传递给命令
|
||||
cat diagram.puml | lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--input_format plantuml --source -\
|
||||
--overwrite --yes --as user
|
||||
```
|
||||
|
||||
### 示例 2:使用 Mermaid 代码更新画板(从文件读取)
|
||||
|
||||
```bash
|
||||
# 编写 Mermaid 代码
|
||||
cat > diagram.mmd << 'EOF'
|
||||
graph TD
|
||||
A[开始] --> B{判断}
|
||||
B -->|是| C[处理]
|
||||
B -->|否| D[结束]
|
||||
C --> D
|
||||
EOF
|
||||
|
||||
# 从文件读取并更新
|
||||
lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--input_format mermaid \
|
||||
--source @./diagram.mmd \
|
||||
--overwrite --yes --as user
|
||||
```
|
||||
|
||||
### 示例 3:使用 whiteboard-cli 直接使用画板 DSL 更新画板
|
||||
|
||||
whiteboard-cli 工具的具体用法请参考 [../lark-whiteboard-cli/SKILL.md](../lark-whiteboard-cli/SKILL.md)
|
||||
|
||||
```bash
|
||||
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <画板 DSL> --format json | lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--input_format raw --source -\
|
||||
--overwrite --yes --as user
|
||||
```
|
||||
|
||||
### 示例 4:使用 whiteboard-cli 使用画板 DSL 生成 Raw 格式 Json,并使用其更新画板
|
||||
|
||||
whiteboard-cli 工具的具体用法请参考 [../lark-whiteboard-cli/SKILL.md](../lark-whiteboard-cli/SKILL.md)
|
||||
|
||||
```bash
|
||||
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <画板 DSL> -o ./temp.json
|
||||
|
||||
lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--input_format raw \
|
||||
--source @./temp.json \
|
||||
--overwrite --yes --as user
|
||||
```
|
||||
@@ -1,274 +0,0 @@
|
||||
# 布局系统
|
||||
|
||||
## 布局决策
|
||||
|
||||
> 不要靠关键词猜布局。先分析信息结构,再决定布局策略。
|
||||
|
||||
| 判断条件 | 布局策略 |
|
||||
|----------|----------|
|
||||
| 元素有明确上下层级(用户层→服务层→数据层) | **Flex 分层** |
|
||||
| 空间位置承载信息(地理方位、拓扑坐标、角度) | **纯绝对定位**(脚本计算坐标) |
|
||||
| 多个独立模块平级互联,无上下级 | **混合布局(岛屿式)** |
|
||||
| 不确定 | **默认 Flex 分层**(最安全) |
|
||||
|
||||
| 布局策略 | 适用图表 |
|
||||
|----------|----------|
|
||||
| 纯绝对定位 | 鱼骨图、柱状图、折线图、拓扑图、地图路线 |
|
||||
| Flex 骨架 | 架构层级图、卡片墙、组织架构图、对比表 |
|
||||
| 混合(岛屿式) | 系统集成图、飞轮图、流程图 |
|
||||
|
||||
**读代码画架构图**:扫目录结构(按层分 → Flex;按功能模块分 → 看依赖方向)→ grep import(单向→Flex;网状→混合)→ 拿不准→默认 Flex。
|
||||
|
||||
> **flex 容器内的 `x/y` 会被完全忽略!**
|
||||
|
||||
❌ 致命错误:
|
||||
```json
|
||||
{ "type": "frame", "layout": "vertical", "children": [
|
||||
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
|
||||
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
|
||||
]}
|
||||
```
|
||||
✅ 正确:用 `layout: "none"` 或放在顶层 nodes 用 x/y。
|
||||
|
||||
**构建方式**:
|
||||
|
||||
| 布局类型 | 做法 |
|
||||
|----------|------|
|
||||
| 纯 Flex | 直接写 JSON |
|
||||
| 混合布局 | 直接写 JSON + 估高辅助 |
|
||||
| 纯绝对定位 | 写脚本生成 JSON(node xxx.js) |
|
||||
| 需要精确避让 | 脚本 + `--layout` 两阶段 |
|
||||
|
||||
---
|
||||
|
||||
## 网格方法论
|
||||
|
||||
核心理念:**先画网格,再填内容**。
|
||||
|
||||
先回答三个问题:
|
||||
1. **信息分几行几列?** 每组一行或一列
|
||||
2. **每格多大?** 等宽还是有主次?
|
||||
3. **行列间距多大?** 分区间 24-32px,同区内 12-16px
|
||||
|
||||
---
|
||||
|
||||
## 布局模式选择
|
||||
|
||||
| 模式 | 适用场景 | DSL 映射 |
|
||||
|------|---------|---------|
|
||||
| grid | 架构图、对比表、卡片墙、看板 | vertical frame 嵌套 horizontal frame |
|
||||
| flow | 流程图、审批流 | vertical frame,主流程居中 |
|
||||
| tree | 组织架构、模块依赖 | 根节点居中,子节点横向展开 |
|
||||
| free | 系统集成、拓扑图、鱼骨图 | `layout: "none"` + x/y |
|
||||
|
||||
大多数图表用 grid 模式。只有节点位置本身有含义时才用 free。
|
||||
|
||||
> 以上都是布局策略名称,不是 DSL 的 `layout` 属性值。DSL 的 layout 只支持 `'horizontal'`、`'vertical'`、`'none'` 三种。
|
||||
|
||||
---
|
||||
|
||||
## DSL 与 CSS Flexbox 属性映射
|
||||
|
||||
| DSL 属性 | 对应的 CSS 心智模型 | 限制 |
|
||||
|-----------------------|-----------------------------------|--------|
|
||||
| `layout: 'horizontal'` | `flex-direction: row` | 不写 layout = 绝对定位 |
|
||||
| `layout: 'vertical'` | `flex-direction: column` | 同上 |
|
||||
| `layout: 'none'` | `position: absolute`(子节点用 x/y) | 子节点不能用 `fill-container` |
|
||||
| `width/height: 'fill-container'` | `flex: 1`(主轴)/ `align-self: stretch`(交叉轴) | 祖先必须有确定尺寸 |
|
||||
| `width/height: 'fit-content'` | `width/height: auto` | — |
|
||||
| `alignItems` | 同 CSS `align-items` | 仅 `'start'`/`'center'`/`'end'`/`'stretch'`(无 flex- 前缀)|
|
||||
| `justifyContent` | 同 CSS `justify-content` | 仅 `'start'`/`'center'`/`'end'`/`'space-between'`/`'space-around'` |
|
||||
| `gap` | 同 CSS `gap` | 必须显式写(不写节点会粘连) |
|
||||
| `padding` | 同 CSS `padding` | 必须显式写。支持 `number` / `[v,h]` / `[t,r,b,l]` |
|
||||
|
||||
`alignItems` 默认值为 `'start'`(CSS Flexbox 默认 `stretch`)。需要等高卡片时必须显式写 `alignItems: 'stretch'`。
|
||||
|
||||
DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignSelf`、`flexWrap`、`margin` 等)。
|
||||
|
||||
---
|
||||
|
||||
## DSL 注意事项
|
||||
|
||||
1. **frame 必须写 layout 属性**,不写时子节点全堆在左上角。
|
||||
2. **fill-container 死锁陷阱**:使用 `fill-container` 时,祖先链中必须有固定宽度(或高度),否则和 `fit-content` 形成死锁,尺寸退化为 0。
|
||||
```json
|
||||
// 死锁:horizontal 父 width fit-content + 子 width fill-container
|
||||
{ "type": "frame", "layout": "horizontal", "width": "fit-content", "children": [
|
||||
{ "type": "rect", "width": "fill-container" }
|
||||
]}
|
||||
// 正确:祖先在对应轴有固定尺寸
|
||||
{ "type": "frame", "layout": "horizontal", "width": 1200, "children": [
|
||||
{ "type": "rect", "width": "fill-container" }
|
||||
]}
|
||||
```
|
||||
3. **含文字节点高度用 fit-content**,引擎不支持 overflow,写死高度会截断文字。
|
||||
4. **Shape 节点有内边距**:rect/ellipse/diamond/triangle 各边 12px;cylinder 垂直 +42px。
|
||||
5. **不支持 flex-wrap**,需要换行时用嵌套 frame 模拟。
|
||||
6. **图层顺序**:数组中越靠后的节点层级越高。需要叠加标注时放在数组最后。
|
||||
|
||||
---
|
||||
|
||||
## 布局选择指南
|
||||
|
||||
| 你要表达的关系 | 怎么排 | DSL 写法 |
|
||||
|-------------|-------|---------|
|
||||
| 先后顺序、层级从上到下 | 纵向堆叠 | `layout: 'vertical'` |
|
||||
| 并列、同等重要、可对比 | 横向等分 | `layout: 'horizontal'` + `alignItems: 'stretch'` + `width: 'fill-container'` |
|
||||
| 区域有名称,名称在侧边 | 侧标签 + 内容并排 | 横向 frame: [text(标签), frame(内容)] |
|
||||
| 多个大分区,各自独立 | 分区纵向排列 | 纵向 frame 包多个彩色 frame |
|
||||
| 一行放不下,需要换行 | 嵌套横向 frame 模拟换行 | 纵向 frame 包多个横向 frame |
|
||||
| 节点位置本身有含义(拓扑、地图) | 绝对定位 | `layout: 'none'` + x/y |
|
||||
|
||||
这些可以自由嵌套组合。比如:纵向堆叠(标题) + 分区纵向排列(多个层) + 每个层内横向等分(节点)。
|
||||
|
||||
---
|
||||
|
||||
## 布局示例
|
||||
|
||||
### 纵向堆叠(标题 + 内容)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 28, "padding": 32,
|
||||
"width": 1200, "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "text", "width": "fill-container", "height": "fit-content",
|
||||
"text": "图表标题", "fontSize": 24, "textAlign": "center" },
|
||||
...内容...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 横向等分(并列元素)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 0,
|
||||
"width": "fill-container", "height": "fit-content",
|
||||
"alignItems": "stretch",
|
||||
"children": [
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content",
|
||||
"textAlign": "center", "verticalAlign": "middle", "text": "A" },
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content",
|
||||
"textAlign": "center", "verticalAlign": "middle", "text": "B" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`alignItems: 'stretch'` + `width: 'fill-container'` = 等宽等高。
|
||||
|
||||
### 侧标签 + 内容
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "horizontal", "gap": 24, "padding": 0,
|
||||
"width": "fill-container", "height": "fit-content",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "width": 160, "height": "fit-content",
|
||||
"text": "区域名称", "fontSize": 20, "textColor": "#1F2329", "textAlign": "right" },
|
||||
{ "type": "frame", "width": "fill-container", "height": "fit-content",
|
||||
...区域内容...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
不要用 frame 的 `title` 属性做标签——渲染为极小标题栏,不可读。
|
||||
|
||||
### 分区纵向排列
|
||||
|
||||
把内容划分为几个大区域,每个区域用不同颜色区分(颜色从 style 文件的色板选取):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 28, "padding": 0,
|
||||
"width": "fill-container", "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "frame", "borderRadius": 8,
|
||||
"layout": "horizontal", "gap": 16, "padding": 20, ...区域1... },
|
||||
{ "type": "frame", "borderRadius": 8,
|
||||
"layout": "horizontal", "gap": 16, "padding": 20, ...区域2... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 模拟换行
|
||||
|
||||
一行放不下时,拆成多个横向 frame:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 8, "padding": 0,
|
||||
"children": [
|
||||
{ "type": "frame", "layout": "horizontal", "gap": 8, "padding": 0,
|
||||
"children": [item1, item2, item3, item4] },
|
||||
{ "type": "frame", "layout": "horizontal", "gap": 8, "padding": 0,
|
||||
"children": [item5, item6] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 绝对定位
|
||||
|
||||
当节点位置本身有含义(拓扑图、地图、时间线轴)时用绝对定位。大多数图表优先用 Flex。
|
||||
|
||||
### 混合布局
|
||||
|
||||
模块内部用 Flex 自动排版,模块之间用绝对定位自由摆放。每个模块是一个带 x/y 的 flex frame:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "id": "module-a", "x": 100, "y": 100,
|
||||
"width": 300, "height": "fit-content",
|
||||
"layout": "vertical", "gap": 8, "padding": 16,
|
||||
"children": [
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "内容1" },
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "内容2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 两阶段绘图
|
||||
|
||||
先出骨架图导出坐标,再基于坐标补充连线和注解:
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 -i skeleton.json -o step1.png -l coords.json
|
||||
```
|
||||
|
||||
`coords.json` 包含每个带 id 节点的精确坐标(absX, absY, width, height)。
|
||||
|
||||
---
|
||||
|
||||
## 常用间距和尺寸
|
||||
|
||||
| 参数 | 常用范围 | 说明 |
|
||||
|------|---------|------|
|
||||
| 整图宽度 | 1000-1400px | — |
|
||||
| 分区之间间距 | 24-32px | — |
|
||||
| 同分区内节点间距 | 12-16px | — |
|
||||
| 有连线的节点间距 | >= 40px | 给箭头留空间 |
|
||||
| 分区内边距 | 16-24px | — |
|
||||
| 侧标签宽度 | 120-180px | — |
|
||||
|
||||
---
|
||||
|
||||
## 等大卡片
|
||||
|
||||
一排卡片需要等宽等高时,不要写固定像素:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 0,
|
||||
"alignItems": "stretch",
|
||||
"children": [
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "A" },
|
||||
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "B" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`alignItems: 'stretch'` + `width: 'fill-container'` = 等宽等高。
|
||||
Reference in New Issue
Block a user