mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
11 Commits
codex/html
...
v1.0.64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d6038aae | ||
|
|
efa3439e01 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
@@ -1333,6 +1355,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||
WithMissingScopes("mail:user_mailbox.message:send").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||
WithMissingScopes("calendar:event:create").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"identity": "user",
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||
"missing_scopes": []any{"calendar:event:create"},
|
||||
}
|
||||
for k, want := range wantFields {
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||
// coupling this contract to core's brand enum.
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
// QueryEscape both values — clientID and scopes both sit in the query
|
||||
// string, and untrusted content must not be able to inject extra query
|
||||
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||
// open-platform base URL stays a single source of truth.
|
||||
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
|
||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
wantInURL: []string{"scopes=scope%23fragment"},
|
||||
denyInURL: []string{"scopes=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||
denyInURL: []string{"clientID=good?q=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
wantInURL: []string{"clientID=good%23fragment"},
|
||||
denyInURL: []string{"clientID=good#fragment"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
name: "slash in appID does not open a new path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
||||
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||
if !strings.Contains(got, "developer console") {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -78,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
if isPlaceholderCredentialURL(file, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
if !warnForPrivateIPv4(file) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
@@ -130,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
@@ -284,6 +291,9 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
@@ -313,11 +323,109 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
value = strings.TrimPrefix(value, "$")
|
||||
value = strings.Trim(value, "%")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func highEntropyCredentialValue(value string) bool {
|
||||
if len(value) < 32 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-' || r == '.' || r == '=':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
|
||||
}
|
||||
|
||||
func shannonEntropy(value string) float64 {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
counts := map[rune]int{}
|
||||
for _, r := range value {
|
||||
counts[r]++
|
||||
}
|
||||
var entropy float64
|
||||
length := float64(len([]rune(value)))
|
||||
for _, count := range counts {
|
||||
p := float64(count) / length
|
||||
entropy -= p * log2(p)
|
||||
}
|
||||
return entropy
|
||||
}
|
||||
|
||||
func log2(value float64) float64 {
|
||||
return math.Log(value) / math.Ln2
|
||||
}
|
||||
|
||||
func authCredentialTokenKey(key string) bool {
|
||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||
case "access_token",
|
||||
"api_token",
|
||||
"bot_token",
|
||||
"refresh_token",
|
||||
"secret_token",
|
||||
"session_token",
|
||||
"service_token",
|
||||
"bearer_token",
|
||||
"auth_token",
|
||||
"authorization_token",
|
||||
@@ -844,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -853,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
return credentialURLPasswordPlaceholder(password) ||
|
||||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
@@ -867,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
"pw",
|
||||
"s3cret",
|
||||
"secret",
|
||||
"t":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceOrTestFixtureFile(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
return sourceCodeFile(normalized) ||
|
||||
strings.HasPrefix(normalized, "testdata/") ||
|
||||
strings.HasPrefix(normalized, "fixtures/") ||
|
||||
strings.Contains(normalized, "/testdata/") ||
|
||||
strings.Contains(normalized, "/fixtures/")
|
||||
}
|
||||
|
||||
func warnForPrivateIPv4(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
if sourceOrTestFixtureFile(normalized) {
|
||||
return false
|
||||
}
|
||||
switch filepath.Ext(normalized) {
|
||||
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(normalized, "docs/") ||
|
||||
strings.HasPrefix(normalized, "skills/")
|
||||
}
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
|
||||
@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
|
||||
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@10.0.0.1:3128"`,
|
||||
`target := "socks5://admin:secret@172.16.0.1:1080"`,
|
||||
`host := "192.168.0.10"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
||||
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||
if len(benign) != 0 {
|
||||
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
|
||||
`proxy: http://user:pass@proxy:8080`,
|
||||
`repo: https://u:t@h/r.git`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
|
||||
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
|
||||
`endpoint: http://10.0.0.1:8080`,
|
||||
`redis: 192.168.1.10:6379`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||
for _, item := range got {
|
||||
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||
"URL=https://<user>:real-secret@example.invalid/path",
|
||||
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
|
||||
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
|
||||
`{"token":"img_abc123"}`,
|
||||
`{"token":"img_live_secret"}`,
|
||||
`{"token":"img_prod_key"}`,
|
||||
`token=ab********cd`,
|
||||
`{"image_token":"img_live_secret"}`,
|
||||
`{"data_mail_token":"mail_abc123"}`,
|
||||
`{"whiteboard_token":"board_v3_example"}`,
|
||||
`{"want_token":"token from callback"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
stripeToken := "sk_" + "live_1234567890abcdef"
|
||||
randomToken := strings.Join([]string{
|
||||
"a1b2c3d4",
|
||||
"e5f6g7h8",
|
||||
"i9j0k1l2",
|
||||
"m3n4p5q6",
|
||||
}, "")
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"token":"` + githubToken + `"}`,
|
||||
`token=` + stripeToken,
|
||||
`{"image_token":"` + githubToken + `"}`,
|
||||
`{"token":"` + randomToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
@@ -1052,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
||||
if appID == "" || scope == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
core.ResolveOpenBaseURL(brand),
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 5 * time.Second
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.63",
|
||||
"version": "1.0.64",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
|
||||
want := []string{
|
||||
"+chat-create",
|
||||
"+chat-list",
|
||||
"+chat-members-list",
|
||||
"+chat-messages-list",
|
||||
"+chat-search",
|
||||
"+chat-update",
|
||||
|
||||
420
shortcuts/im/im_chat_members_list.go
Normal file
420
shortcuts/im/im_chat_members_list.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
|
||||
chatMembersListDefaultPageSize = 20
|
||||
chatMembersListMaxPageSize = 100
|
||||
// chatMembersListDefaultPageDelay throttles --page-all the same way the
|
||||
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
|
||||
// server-side member cap, where a large group drains many pages back to
|
||||
// back and could otherwise trip rate limits.
|
||||
chatMembersListDefaultPageDelay = 200
|
||||
)
|
||||
|
||||
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
|
||||
// returning users and bots in separate buckets (users[]/bots[]). It owns its
|
||||
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
|
||||
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
|
||||
// the response is multi-bucket — the generic --page-all merger is built for
|
||||
// single-array responses and would drop the bots[] bucket and the final-page
|
||||
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
|
||||
var ImChatMembersList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-members-list",
|
||||
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
|
||||
Risk: "read",
|
||||
// Declare the narrowest scope the API accepts so tokens carrying only
|
||||
// im:chat.members:read are honored (same rationale as +chat-list).
|
||||
Scopes: []string{"im:chat.members:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
|
||||
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
|
||||
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
||||
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
||||
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Default fetches a single page; pass --page-all to walk every page.",
|
||||
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
|
||||
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
if chatID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||
}
|
||||
if !strings.HasPrefix(chatID, "oc_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
}
|
||||
if n := runtime.Int("page-delay"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
|
||||
}
|
||||
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
dry := common.NewDryRunAPI()
|
||||
if chatMembersShouldAutoPaginate(runtime) {
|
||||
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||
}
|
||||
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
return dry.
|
||||
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
res, err := fetchChatMembers(ctx, runtime, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The truncation signal is the whole reason this is a dedicated shortcut:
|
||||
// surface it loudly so an agent never mistakes a capped list for a
|
||||
// complete one.
|
||||
if len(res.truncations) > 0 {
|
||||
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"users": res.users,
|
||||
"bots": res.bots,
|
||||
"truncations": res.truncations,
|
||||
"has_more": res.hasMore,
|
||||
"page_token": res.pageToken,
|
||||
}
|
||||
if res.userTotal != nil {
|
||||
outData["user_total"] = res.userTotal
|
||||
}
|
||||
if res.botTotal != nil {
|
||||
outData["bot_total"] = res.botTotal
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
|
||||
renderChatMembersPretty(w, chatID, res)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// chatMembersResult is the aggregated view across one or more pages.
|
||||
type chatMembersResult struct {
|
||||
users []interface{}
|
||||
bots []interface{}
|
||||
truncations []interface{}
|
||||
userTotal interface{}
|
||||
botTotal interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
}
|
||||
|
||||
// effectiveChatMembersPageSize resolves the page_size to request. When draining
|
||||
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
|
||||
// uses the maximum so a full walk takes the fewest round-trips. An explicit
|
||||
// --page-size is always honored; without --page-all the smaller default is kept
|
||||
// as a sensible single-page preview size.
|
||||
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
|
||||
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
|
||||
return chatMembersListMaxPageSize
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
return n
|
||||
}
|
||||
return chatMembersListDefaultPageSize
|
||||
}
|
||||
|
||||
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
|
||||
// every page. An explicit --page-token disables the auto loop because the
|
||||
// caller supplied a specific cursor (single-page fetch).
|
||||
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" {
|
||||
return false
|
||||
}
|
||||
return runtime.Bool("page-all")
|
||||
}
|
||||
|
||||
// buildChatMembersParams builds the query params for one page request. The
|
||||
// startToken (when non-empty) seeds the page_token; the loop overrides it per
|
||||
// page. Returns the params and the normalized member-types CSV (already
|
||||
// validated by Validate, so the error is only a defensive guard).
|
||||
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
|
||||
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"member_id_type": runtime.Str("member-id-type"),
|
||||
"page_size": effectiveChatMembersPageSize(runtime),
|
||||
}
|
||||
if memberTypes != "" {
|
||||
params["member_types"] = memberTypes
|
||||
}
|
||||
if startToken != "" {
|
||||
params["page_token"] = startToken
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// fetchChatMembers walks the list_members endpoint, honoring the four
|
||||
// pagination flags the same way the generic --page-all path does. It merges
|
||||
// each page into the aggregate as it arrives (rather than buffering every raw
|
||||
// page), so peak memory is just the aggregated members plus the single most
|
||||
// recent page — important for large groups under --page-limit 0.
|
||||
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
|
||||
auto := chatMembersShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
pageDelay := runtime.Int("page-delay")
|
||||
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
|
||||
|
||||
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := newChatMembersResult()
|
||||
var lastData map[string]interface{}
|
||||
pageToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
for page := 0; ; page++ {
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addMemberBuckets(res, data)
|
||||
lastData = data
|
||||
|
||||
hasMore, nextToken := common.PaginationMeta(data)
|
||||
if !auto {
|
||||
break
|
||||
}
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
if nextToken == pageToken {
|
||||
// Guard against a buggy server echoing the same cursor with
|
||||
// has_more=true: without --page-limit we would loop forever.
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
|
||||
break
|
||||
}
|
||||
if pageLimit > 0 && page+1 >= pageLimit {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
// Throttle between pages (only reached when another page follows), so
|
||||
// draining a large untruncated list doesn't hammer the API.
|
||||
if pageDelay > 0 {
|
||||
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if lastData != nil {
|
||||
applyLastPageSignals(res, lastData)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
|
||||
// JSON output always carries arrays (never null).
|
||||
func newChatMembersResult() *chatMembersResult {
|
||||
return &chatMembersResult{
|
||||
users: []interface{}{},
|
||||
bots: []interface{}{},
|
||||
truncations: []interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
|
||||
// Concatenating every bucket is what avoids dropping bots[] — the bug the
|
||||
// generic single-array --page-all merger would hit on this multi-bucket shape.
|
||||
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
|
||||
if u, ok := data["users"].([]interface{}); ok {
|
||||
res.users = append(res.users, u...)
|
||||
}
|
||||
if b, ok := data["bots"].([]interface{}); ok {
|
||||
res.bots = append(res.bots, b...)
|
||||
}
|
||||
}
|
||||
|
||||
// applyLastPageSignals copies the per-request signals from the FINAL page:
|
||||
// has_more / page_token / truncations / totals. These must come from the last
|
||||
// page, not page 1: truncations[] is emitted only on the final page (empty
|
||||
// earlier), so reading it sooner would hide a server-side cap; user_total /
|
||||
// bot_total are server-wide counts, and taking the final page's value keeps a
|
||||
// single, consistent source rather than a possibly-stale earlier count.
|
||||
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
|
||||
res.hasMore, res.pageToken = common.PaginationMeta(data)
|
||||
if t, ok := data["truncations"].([]interface{}); ok {
|
||||
res.truncations = t
|
||||
}
|
||||
res.userTotal = data["user_total"]
|
||||
res.botTotal = data["bot_total"]
|
||||
}
|
||||
|
||||
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
|
||||
// the same logic fetchChatMembers applies incrementally, kept as a pure
|
||||
// function so the multi-bucket merge + last-page-signal semantics are unit
|
||||
// tested in one place.
|
||||
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
|
||||
res := newChatMembersResult()
|
||||
if len(pages) == 0 {
|
||||
return res
|
||||
}
|
||||
for _, data := range pages {
|
||||
addMemberBuckets(res, data)
|
||||
}
|
||||
applyLastPageSignals(res, pages[len(pages)-1])
|
||||
return res
|
||||
}
|
||||
|
||||
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
|
||||
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
|
||||
// the API's default of all types). Any element outside {user, bot} is rejected.
|
||||
func normalizeMemberTypes(raw []string) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p != "user" && p != "bot" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, ","), nil
|
||||
}
|
||||
|
||||
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
|
||||
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
|
||||
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
|
||||
}
|
||||
}
|
||||
|
||||
// writeChatMembersTruncationWarning emits a stderr warning for every
|
||||
// server-side bucket cap reported in truncations[]. It uses the repo's plain
|
||||
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
|
||||
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
|
||||
// pipes regardless of terminal encoding.
|
||||
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
|
||||
for _, t := range truncations {
|
||||
tm, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
memberType := valueOrAll(tm["member_type"])
|
||||
limit := tm["limit"]
|
||||
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrAll(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
|
||||
fmt.Fprintf(w, "Chat: %s\n", chatID)
|
||||
// Show the server-wide total next to the fetched count: when truncated or
|
||||
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
|
||||
// what tells the reader how incomplete the list is.
|
||||
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
|
||||
for i, u := range res.users {
|
||||
m, _ := u.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
|
||||
for i, b := range res.bots {
|
||||
m, _ := b.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
if len(res.truncations) > 0 {
|
||||
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
|
||||
}
|
||||
if res.hasMore {
|
||||
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
|
||||
if res.pageToken != "" {
|
||||
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDash(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// totalSuffix renders " of <total>" when the server-reported total exceeds the
|
||||
// number actually fetched (so a truncated/partial bucket is obvious), and ""
|
||||
// when the total is absent or already matches the fetched count.
|
||||
func totalSuffix(total interface{}, fetched int) string {
|
||||
n, ok := toInt(total)
|
||||
if !ok || n <= fetched {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" of %d", n)
|
||||
}
|
||||
|
||||
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
325
shortcuts/im/im_chat_members_list_test.go
Normal file
325
shortcuts/im/im_chat_members_list_test.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// page builds one list_members page payload shaped like the data object the
|
||||
// server returns (users[]/bots[]/truncations[] plus paging + totals).
|
||||
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"users": users,
|
||||
"bots": bots,
|
||||
"truncations": truncations,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
"user_total": 324,
|
||||
"bot_total": 2,
|
||||
}
|
||||
}
|
||||
|
||||
func us(ids ...string) []interface{} {
|
||||
out := make([]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]interface{}{"member_id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
|
||||
// (users AND bots) must be concatenated across pages, not just one of them.
|
||||
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 merged, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 3 {
|
||||
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
|
||||
// is emitted only on the final page, so the merged view must take it from the
|
||||
// last page rather than inherit page 1's empty slice.
|
||||
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
|
||||
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u2"), nil, limit, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.truncations) != 1 {
|
||||
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
|
||||
// signals come from the final page (so a --page-limit cutoff is visible).
|
||||
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), nil, nil, true, "p2"),
|
||||
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true from last page")
|
||||
}
|
||||
if res.pageToken != "p3" {
|
||||
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
|
||||
// are taken from the final page (not an earlier, possibly-different value).
|
||||
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
|
||||
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
|
||||
}
|
||||
res := mergeChatMemberPages(pages)
|
||||
if n, _ := toInt(res.userTotal); n != 324 {
|
||||
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
|
||||
}
|
||||
if n, _ := toInt(res.botTotal); n != 2 {
|
||||
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
|
||||
func TestChatMembersValidate(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
chatID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid oc_", "oc_abc", false},
|
||||
{"empty", "", true},
|
||||
{"missing oc_ prefix", "abc123", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
|
||||
err := ImChatMembersList.Validate(context.Background(), rt)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, c.name, err, "--chat-id")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationError checks err satisfies the repo's typed-error contract for
|
||||
// a validation failure: a *errs.ValidationError carrying the expected Param, and
|
||||
// problem metadata of category validation / subtype invalid_argument.
|
||||
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
|
||||
return
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMemberTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{nil, "", false},
|
||||
{[]string{"user", "bot"}, "user,bot", false},
|
||||
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
|
||||
{[]string{"admin"}, "", true},
|
||||
{[]string{""}, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := normalizeMemberTypes(c.in)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
|
||||
// drain with no explicit size → max; explicit size → honored; single page → default.
|
||||
func TestEffectiveChatMembersPageSize(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
b map[string]bool
|
||||
ints map[string]int
|
||||
want int
|
||||
}{
|
||||
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
|
||||
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
|
||||
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
|
||||
if got := effectiveChatMembersPageSize(rt); got != c.want {
|
||||
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newChatMembersTestRuntime registers the shortcut's flags and returns a
|
||||
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
|
||||
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
runtime := newUserShortcutRuntime(t, rt)
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("chat-id", "", "")
|
||||
cmd.Flags().String("member-id-type", "open_id", "")
|
||||
cmd.Flags().StringSlice("member-types", nil, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
for k, v := range str {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range b {
|
||||
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range ints {
|
||||
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
return runtime
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
|
||||
// fetch loop over mocked pages: users/bots merge across pages and the final
|
||||
// page's truncations[] survives.
|
||||
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
|
||||
calls := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
|
||||
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
|
||||
}
|
||||
calls++
|
||||
token := req.URL.Query().Get("page_token")
|
||||
if token == "" {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("want 2 page calls, got %d", calls)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 2 {
|
||||
t.Errorf("bots: want 2, got %d", len(res.bots))
|
||||
}
|
||||
if len(res.truncations) != 1 {
|
||||
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
|
||||
}
|
||||
if res.hasMore {
|
||||
t.Error("has_more: want false after draining all pages")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
|
||||
// leaves has_more=true so the caller knows the result is incomplete.
|
||||
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
|
||||
seq := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// Every page reports more pages available, with an advancing token so the
|
||||
// loop is stopped by --page-limit, not the non-advancing-token guard.
|
||||
seq++
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
|
||||
}
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true (loop cut short by page-limit)")
|
||||
}
|
||||
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "reached page limit (3)") {
|
||||
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMembersList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// buildContentBlock converts text and mentions to a ContentBlock.
|
||||
func buildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
|
||||
|
||||
// Add text element
|
||||
textElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
}
|
||||
elements = append(elements, textElem)
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range mentions {
|
||||
mentionElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &mention,
|
||||
},
|
||||
}
|
||||
elements = append(elements, mentionElem)
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
content := buildContentBlock(obj.Text, obj.Mention)
|
||||
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
|
||||
|
||||
// createKR calls the API to create a key result.
|
||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
||||
content := buildContentBlock(kr.Text, kr.Mention)
|
||||
content := BuildContentBlock(kr.Text, kr.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Objective creation
|
||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
||||
objContent := BuildContentBlock(obj.Text, obj.Mention)
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
// KR creations
|
||||
for j, kr := range obj.KRs {
|
||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
||||
krContent := BuildContentBlock(kr.Text, kr.Mention)
|
||||
krBody := map[string]interface{}{
|
||||
"content": krContent,
|
||||
}
|
||||
|
||||
@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
|
||||
|
||||
func TestBuildContentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
|
||||
@@ -29,15 +29,10 @@ type RespCategory struct {
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
ID string `json:"id"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
@@ -152,3 +147,145 @@ type RespProgress struct {
|
||||
Content *string `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ========== Simple-style response types (semi-plain text format) ==========
|
||||
|
||||
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespKeyResultSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespObjectiveSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *SemiPlainContent `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespProgressSimple struct {
|
||||
ID string `json:"progress_id"`
|
||||
ModifyTime string `json:"modify_time"`
|
||||
CreateTime *string `json:"create_time,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ToSimple converts KeyResult to RespKeyResultSimple.
|
||||
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResultSimple{
|
||||
ID: k.ID,
|
||||
CreateTime: formatTimestamp(k.CreateTime),
|
||||
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||
Owner: *k.Owner.ToResp(),
|
||||
ObjectiveID: k.ObjectiveID,
|
||||
Position: k.Position,
|
||||
Score: k.Score,
|
||||
Weight: k.Weight,
|
||||
}
|
||||
if k.Deadline != nil {
|
||||
d := formatTimestamp(*k.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = k.Content.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts Objective to RespObjectiveSimple.
|
||||
func (o *Objective) ToSimple() *RespObjectiveSimple {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjectiveSimple{
|
||||
ID: o.ID,
|
||||
CreateTime: formatTimestamp(o.CreateTime),
|
||||
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||
Owner: *o.Owner.ToResp(),
|
||||
CycleID: o.CycleID,
|
||||
Position: o.Position,
|
||||
Score: o.Score,
|
||||
Weight: o.Weight,
|
||||
CategoryID: o.CategoryID,
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
d := formatTimestamp(*o.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = o.Content.ToSemiPlain()
|
||||
result.Notes = o.Notes.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts ProgressV1 to RespProgressSimple.
|
||||
func (p *ProgressV1) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.ModifyTime),
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.Percent,
|
||||
}
|
||||
if p.ProgressRate.Status != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.Status).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Content != nil {
|
||||
resp.Content = p.Content.ToV2().ToSemiPlain()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToSimple converts Progress to RespProgressSimple.
|
||||
func (p *Progress) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
createTime := formatTimestamp(p.CreateTime)
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.UpdateTime),
|
||||
CreateTime: &createTime,
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.ProgressPercent,
|
||||
}
|
||||
if p.ProgressRate.ProgressStatus != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Content = p.Content.ToSemiPlain()
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
@@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
style := runtime.Str("style")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
@@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := map[string]interface{}{"page_size": "100"}
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if style == "simple" {
|
||||
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
obj := &objectives[i]
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
respObj := obj.ToSimple()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToSimple(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
contentText := ""
|
||||
if o.Content != nil {
|
||||
contentText = o.Content.Text
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
notesText := ""
|
||||
if o.Notes != nil {
|
||||
notesText = o.Notes.Text
|
||||
}
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
krText := ""
|
||||
if kr.Content != nil {
|
||||
krText = kr.Content.Text
|
||||
}
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// richtext mode
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
krQuery["page_token"] = pageToken
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
// isCurrentActiveCycle checks whether a cycle is currently active:
|
||||
// - current time is within the cycle's start and end time
|
||||
// - cycle status is default (0) or normal (1)
|
||||
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
nowUTC := now.UTC()
|
||||
|
||||
// Check time range: now must be >= start and <= end
|
||||
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check status: must be default or normal
|
||||
if cycle.CycleStatus == nil {
|
||||
return false
|
||||
}
|
||||
status := *cycle.CycleStatus
|
||||
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
// Filter current active cycles
|
||||
now := time.Now()
|
||||
currentActiveCycles := make([]*RespCycle, 0)
|
||||
for i := range filtered {
|
||||
if isCurrentActiveCycle(&filtered[i], now) {
|
||||
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"current_active_cycles": currentActiveCycles,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
if len(currentActiveCycles) > 0 {
|
||||
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
|
||||
for _, c := range currentActiveCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -5,8 +5,10 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
// Assert current_active_cycles field exists and is a slice
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 0 {
|
||||
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
|
||||
}
|
||||
}
|
||||
|
||||
// --- isCurrentActiveCycle unit tests ---
|
||||
|
||||
func TestIsCurrentActiveCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cycle *Cycle
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "active cycle with normal status",
|
||||
cycle: &Cycle{
|
||||
ID: "c1",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31 23:59:59
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "active cycle with default status",
|
||||
cycle: &Cycle{
|
||||
ID: "c2",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusDefault.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "cycle with invalid status",
|
||||
cycle: &Cycle{
|
||||
ID: "c3",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusInvalid.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cycle with hidden status",
|
||||
cycle: &Cycle{
|
||||
ID: "c4",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusHidden.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "past cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c5",
|
||||
StartTime: "1704067200000", // 2024-01-01
|
||||
EndTime: "1719791999999", // 2024-06-30
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "future cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c6",
|
||||
StartTime: "1830297600000", // 2028-01-01
|
||||
EndTime: "1861833599999", // 2028-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil cycle status",
|
||||
cycle: &Cycle{
|
||||
ID: "c7",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: nil,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid start time",
|
||||
cycle: &Cycle{
|
||||
ID: "c8",
|
||||
StartTime: "invalid",
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "exact start time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c9",
|
||||
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact end time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c10",
|
||||
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
|
||||
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isCurrentActiveCycle(tt.cycle, now)
|
||||
if result != tt.expected {
|
||||
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Calculate timestamps relative to now to avoid test expiration
|
||||
now := time.Now().UTC()
|
||||
// Active cycle: 6 months before to 6 months after now
|
||||
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
|
||||
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
|
||||
// Past cycle: 2 years before to 1.5 years before now
|
||||
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
|
||||
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"id": "cycle-active",
|
||||
"start_time": strconv.FormatInt(activeStartMs, 10),
|
||||
"end_time": strconv.FormatInt(activeEndMs, 10),
|
||||
"cycle_status": 1, // normal
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"id": "cycle-past",
|
||||
"start_time": strconv.FormatInt(pastStartMs, 10),
|
||||
"end_time": strconv.FormatInt(pastEndMs, 10),
|
||||
"cycle_status": 2, // invalid
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
|
||||
// Check current_active_cycles - should only contain cycle-active
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 1 {
|
||||
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
|
||||
}
|
||||
activeCycle, ok := currentActive[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
|
||||
}
|
||||
if activeCycle["id"] != "cycle-active" {
|
||||
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
|
||||
}
|
||||
|
||||
// Verify removed fields are not present in the response
|
||||
for _, c := range cycles {
|
||||
cycleMap, _ := c.(map[string]interface{})
|
||||
if _, ok := cycleMap["create_time"]; ok {
|
||||
t.Fatal("create_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["update_time"]; ok {
|
||||
t.Fatal("update_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["tenant_cycle_id"]; ok {
|
||||
t.Fatal("tenant_cycle_id should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["owner"]; ok {
|
||||
t.Fatal("owner should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["score"]; ok {
|
||||
t.Fatal("score should not be present in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,9 @@ package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
ID: c.ID,
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
@@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SemiPlainContent (半纯文本格式) ==========
|
||||
|
||||
// Regex patterns for semi-plain text processing (pre-compiled for performance).
|
||||
var (
|
||||
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
|
||||
multiSpaceRE = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// SemiPlainDoc represents a document link in semi-plain content.
|
||||
type SemiPlainDoc struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
|
||||
// It contains plain text, mentions, docs, and images without rich formatting or position info.
|
||||
type SemiPlainContent struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
Docs []SemiPlainDoc `json:"docs,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
|
||||
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
|
||||
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
result := &SemiPlainContent{}
|
||||
var textParts []string
|
||||
|
||||
for _, block := range c.Blocks {
|
||||
if block.Paragraph != nil {
|
||||
for _, elem := range block.Paragraph.Elements {
|
||||
switch {
|
||||
case elem.TextRun != nil && elem.TextRun.Text != nil:
|
||||
textParts = append(textParts, *elem.TextRun.Text)
|
||||
case elem.Mention != nil && elem.Mention.UserID != nil:
|
||||
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
|
||||
result.Mention = append(result.Mention, *elem.Mention.UserID)
|
||||
case elem.DocsLink != nil:
|
||||
doc := SemiPlainDoc{}
|
||||
if elem.DocsLink.Title != nil {
|
||||
doc.Title = *elem.DocsLink.Title
|
||||
}
|
||||
if elem.DocsLink.URL != nil {
|
||||
doc.URL = *elem.DocsLink.URL
|
||||
}
|
||||
result.Docs = append(result.Docs, doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if block.Gallery != nil {
|
||||
for _, img := range block.Gallery.Images {
|
||||
if img.Src != nil {
|
||||
result.Images = append(result.Images, *img.Src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Text = strings.Join(textParts, "")
|
||||
return result
|
||||
}
|
||||
|
||||
// ToContentBlock converts SemiPlainContent to ContentBlock.
|
||||
// Text and mentions are placed in a single paragraph (text first, then mentions).
|
||||
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
|
||||
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
|
||||
|
||||
// Strip @{userID} placeholders from text to avoid duplicate mentions
|
||||
// (these placeholders are only for readability in the output format)
|
||||
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
|
||||
// Collapse multiple spaces and trim
|
||||
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
|
||||
strippedText = strings.TrimSpace(strippedText)
|
||||
|
||||
// Add text element if stripped text is not empty
|
||||
if strippedText != "" {
|
||||
text := strippedText
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range s.Mention {
|
||||
m := mention
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &m,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContentBlock converts text and mentions to a ContentBlock.
|
||||
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
|
||||
func BuildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
return (&SemiPlainContent{
|
||||
Text: text,
|
||||
Mention: mentions,
|
||||
}).ToContentBlock()
|
||||
}
|
||||
|
||||
// ProgressRateV1 进度率
|
||||
type ProgressRateV1 struct {
|
||||
Percent *float64 `json:"percent,omitempty"`
|
||||
|
||||
@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
// Verify removed fields are not present in RespCycle
|
||||
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
|
||||
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
|
||||
// boolPtr returns a pointer to the given bool value.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// ========== SemiPlainContent Conversion Tests ==========
|
||||
|
||||
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello world"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Hello world" {
|
||||
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 0 {
|
||||
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
|
||||
}
|
||||
if len(sp.Docs) != 0 {
|
||||
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
|
||||
}
|
||||
if len(sp.Images) != 0 {
|
||||
t.Fatalf("expected 0 images, got %d", len(sp.Images))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: strPtr("ou_123"),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr(", how are you?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
// Text includes @{userID} placeholder to preserve positional context
|
||||
if sp.Text != "Hello @{ou_123} , how are you?" {
|
||||
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
|
||||
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Check out this doc: "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
|
||||
DocsLink: &ContentDocsLink{
|
||||
Title: strPtr("Design Doc"),
|
||||
URL: strPtr("https://example.feishu.cn/docx/xxx"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
BlockElementType: BlockElementTypeGallery.Ptr(),
|
||||
Gallery: &ContentGallery{
|
||||
Images: []ContentImageItem{
|
||||
{
|
||||
Src: strPtr("https://example.com/img1.png"),
|
||||
},
|
||||
{
|
||||
Src: strPtr("https://example.com/img2.png"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Check out this doc: " {
|
||||
t.Fatalf("unexpected text: '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Docs) != 1 {
|
||||
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
|
||||
}
|
||||
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
|
||||
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
|
||||
}
|
||||
if len(sp.Images) != 2 {
|
||||
t.Fatalf("expected 2 images, got %d", len(sp.Images))
|
||||
}
|
||||
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
|
||||
t.Fatalf("unexpected images: %v", sp.Images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var cb *ContentBlock
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp != nil {
|
||||
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Hello world",
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
block := cb.Blocks[0]
|
||||
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
|
||||
t.Fatal("expected paragraph block")
|
||||
}
|
||||
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
|
||||
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
|
||||
}
|
||||
elem := block.Paragraph.Elements[0]
|
||||
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected textRun element")
|
||||
}
|
||||
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
|
||||
t.Fatalf("unexpected text: %v", elem.TextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Please review",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
|
||||
t.Fatal("unexpected first element")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: " ",
|
||||
Mention: []string{"ou_123"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Empty text should be skipped, only mention remains
|
||||
if len(elems) != 1 {
|
||||
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected mention element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Test",
|
||||
Mention: []string{"ou_123"},
|
||||
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
|
||||
Images: []string{"https://img.png"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Docs and images are ignored in input conversion
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Simulate round-trip: output format has @{userID} in text,
|
||||
// input conversion should strip them to avoid duplicate mentions
|
||||
sp := &SemiPlainContent{
|
||||
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
|
||||
Mention: []string{"ou_zhangsan", "ou_lisi"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have 3 elements: 1 text (stripped) + 2 mentions
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
// Text should have placeholders stripped
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected first element to be textRun")
|
||||
}
|
||||
// Note: space before comma is preserved from the placeholder's trailing space
|
||||
expectedText := "任务一 ,任务二"
|
||||
if *elems[0].TextRun.Text != expectedText {
|
||||
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
|
||||
}
|
||||
// Mentions should be preserved as separate elements
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Text that is only placeholders should result in no text element
|
||||
sp := &SemiPlainContent{
|
||||
Text: " @{ou_123} @{ou_456} ",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have only 2 mention elements, no text element
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected first element to be mention")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected second element to be mention")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var sp *SemiPlainContent
|
||||
cb := sp.ToContentBlock()
|
||||
if cb != nil {
|
||||
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentBlock_Conversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements, got %d", len(elems))
|
||||
}
|
||||
if *elems[0].TextRun.Text != "Test text" {
|
||||
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
|
||||
}
|
||||
if *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
|
||||
}
|
||||
if *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSimpleMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test Objective.ToSimple()
|
||||
text := "Objective text"
|
||||
obj := &Objective{
|
||||
ID: "obj-1",
|
||||
Content: BuildContentBlock(text, []string{"ou_123"}),
|
||||
Notes: BuildContentBlock("Note text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
|
||||
CycleID: "cycle-1",
|
||||
Score: float64Ptr(0.7),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1735776000000"),
|
||||
}
|
||||
simpleObj := obj.ToSimple()
|
||||
if simpleObj == nil {
|
||||
t.Fatal("expected non-nil RespObjectiveSimple")
|
||||
}
|
||||
if simpleObj.ID != "obj-1" {
|
||||
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedContentText := "Objective text @{ou_123} "
|
||||
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
|
||||
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
|
||||
}
|
||||
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
|
||||
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
|
||||
}
|
||||
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
|
||||
t.Fatalf("unexpected score: %v", simpleObj.Score)
|
||||
}
|
||||
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
|
||||
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
|
||||
}
|
||||
|
||||
// Test KeyResult.ToSimple()
|
||||
kr := &KeyResult{
|
||||
ID: "kr-1",
|
||||
ObjectiveID: "obj-1",
|
||||
Content: BuildContentBlock("KR text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
|
||||
Score: float64Ptr(0.5),
|
||||
}
|
||||
simpleKR := kr.ToSimple()
|
||||
if simpleKR == nil {
|
||||
t.Fatal("expected non-nil RespKeyResultSimple")
|
||||
}
|
||||
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
|
||||
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
|
||||
}
|
||||
|
||||
// Test ProgressV1.ToSimple()
|
||||
progress := &ProgressV1{
|
||||
ID: "prog-1",
|
||||
ModifyTime: "1735776000000",
|
||||
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
|
||||
}
|
||||
simpleProgress := progress.ToSimple()
|
||||
if simpleProgress == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple")
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedProgressText := "Progress text @{ou_mention} "
|
||||
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
|
||||
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
|
||||
}
|
||||
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
|
||||
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
|
||||
}
|
||||
|
||||
// Test Progress.ToSimple() (V2 progress record)
|
||||
progressV2 := &Progress{
|
||||
ID: "prog-v2-1",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
|
||||
ProgressRate: &ProgressRate{
|
||||
ProgressPercent: float64Ptr(80.0),
|
||||
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
|
||||
},
|
||||
}
|
||||
simpleProgressV2 := progressV2.ToSimple()
|
||||
if simpleProgressV2 == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
|
||||
}
|
||||
if simpleProgressV2.ID != "prog-v2-1" {
|
||||
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
|
||||
}
|
||||
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
|
||||
t.Fatal("expected non-empty CreateTime for Progress V2")
|
||||
}
|
||||
expectedV2Text := "V2 progress text @{ou_v2_mention} "
|
||||
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
|
||||
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
|
||||
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
|
||||
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
|
||||
}
|
||||
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
|
||||
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
// listTypePtr returns a pointer to the given ListType value.
|
||||
func listTypePtr(v ListType) *ListType { return &v }
|
||||
|
||||
311
shortcuts/okr/okr_patch.go
Normal file
311
shortcuts/okr/okr_patch.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// patchParams holds the parsed parameters for the patch operation.
|
||||
type patchParams struct {
|
||||
Level string
|
||||
TargetID string
|
||||
Style string
|
||||
Content *ContentBlock
|
||||
Notes *ContentBlock
|
||||
Score *float64
|
||||
Deadline *string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
|
||||
p := &patchParams{
|
||||
Level: runtime.Str("level"),
|
||||
TargetID: runtime.Str("target-id"),
|
||||
Style: runtime.Str("style"),
|
||||
UserIDType: runtime.Str("user-id-type"),
|
||||
}
|
||||
|
||||
hasField := false
|
||||
|
||||
// Parse content if provided
|
||||
if contentStr := runtime.Str("content"); contentStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
p.Content = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
p.Content = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse notes if provided (only for objective)
|
||||
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
|
||||
}
|
||||
p.Notes = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
p.Notes = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse score if provided
|
||||
if scoreStr := runtime.Str("score"); scoreStr != "" {
|
||||
hasField = true
|
||||
score, err := strconv.ParseFloat(scoreStr, 64)
|
||||
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
|
||||
}
|
||||
if score < 0 || score > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
|
||||
}
|
||||
// Check for exactly one decimal place
|
||||
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
|
||||
parts := strings.Split(scoreStrTrimmed, ".")
|
||||
if len(parts) == 2 && len(parts[1]) > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
|
||||
}
|
||||
// Validation ensures at most one decimal place, so score is already correctly formatted
|
||||
p.Score = &score
|
||||
}
|
||||
|
||||
// Parse deadline if provided
|
||||
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
|
||||
hasField = true
|
||||
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
|
||||
}
|
||||
if deadlineMs <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
|
||||
}
|
||||
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
|
||||
// Anything less than 1e12 is likely seconds or a wrong unit
|
||||
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
|
||||
}
|
||||
p.Deadline = &deadlineStr
|
||||
}
|
||||
|
||||
// At least one field must be provided
|
||||
if !hasField {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// OKRPatch patches an objective or key result.
|
||||
var OKRPatch = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+patch",
|
||||
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
|
||||
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
if targetID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||
}
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
// Delegate content/notes/score/deadline validation to parsePatchParams
|
||||
if _, err := parsePatchParams(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("").
|
||||
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
api := common.NewDryRunAPI()
|
||||
if p.Level == "objective" {
|
||||
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
|
||||
Set("objective_id", p.TargetID)
|
||||
} else {
|
||||
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
|
||||
Set("key_result_id", p.TargetID)
|
||||
}
|
||||
return api.Params(params).Body(body).
|
||||
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
|
||||
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
var path string
|
||||
if p.Level == "objective" {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
|
||||
} else {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"level": p.Level,
|
||||
"target_id": p.TargetID,
|
||||
"patched": map[string]bool{
|
||||
"content": p.Content != nil,
|
||||
"notes": p.Notes != nil,
|
||||
"score": p.Score != nil,
|
||||
"deadline": p.Deadline != nil,
|
||||
},
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
|
||||
if p.Content != nil {
|
||||
fmt.Fprintf(w, " - content: updated\n")
|
||||
}
|
||||
if p.Notes != nil {
|
||||
fmt.Fprintf(w, " - notes: updated\n")
|
||||
}
|
||||
if p.Score != nil {
|
||||
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
1350
shortcuts/okr/okr_patch_test.go
Normal file
1350
shortcuts/okr/okr_patch_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
|
||||
|
||||
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
||||
style := runtime.Str("style")
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
var contentV1 *ContentBlockV1
|
||||
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
// Validate mention IDs are non-empty
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
// Build ContentBlock from semi-plain content (text + mentions)
|
||||
contentV1 = sp.ToContentBlock().ToV1()
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 = cb.ToV1()
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
targetType := runtime.Str("target-type")
|
||||
targetTypeVal := targetTypeAllowed[targetType]
|
||||
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||
{Name: "progress-percent", Desc: "progress percentage"},
|
||||
@@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
|
||||
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate content is valid JSON and can be parsed as ContentBlock
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
// Validate content based on style
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
// If user provided docs or images in simple mode, warn that they are ignored
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
|
||||
}
|
||||
|
||||
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
||||
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
@@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", "not-json",
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "abc",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "invalid",
|
||||
})
|
||||
@@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--user-id-type", "invalid",
|
||||
@@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "999999999999",
|
||||
@@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "abc",
|
||||
@@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-status", "invalid_status",
|
||||
@@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--dry-run",
|
||||
@@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "75",
|
||||
@@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "456",
|
||||
"--target-type", "key_result",
|
||||
})
|
||||
@@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "789",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple mode tests ---
|
||||
|
||||
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v1/progress_records/",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "300",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Use default style (simple) without specifying --style
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "300" {
|
||||
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v1/progress_records/",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "400",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Explicitly specify --style simple with mentions
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
|
||||
"--style", "simple",
|
||||
"--target-id", "456",
|
||||
"--target-type", "key_result",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "400" {
|
||||
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"missing closing brace`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid semi-plain JSON")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":" ","mention":[]}`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty text in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for docs in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "POST") {
|
||||
t.Fatalf("dry-run output should contain POST method, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
@@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
style := runtime.Str("style")
|
||||
|
||||
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
||||
|
||||
@@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
if len(resp.Content.Mention) > 0 {
|
||||
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -25,12 +26,35 @@ type updateProgressRecordParams struct {
|
||||
|
||||
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
|
||||
style := runtime.Str("style")
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
var contentV1 *ContentBlockV1
|
||||
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
contentV1 = sp.ToContentBlock().ToV1()
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 = cb.ToV1()
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
var progressRate *ProgressRateV1
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
@@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "progress-percent", Desc: "progress percentage"},
|
||||
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
@@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
// Validate content based on style
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
@@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --progress-id")
|
||||
@@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "abc",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --progress-id")
|
||||
@@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", "not-json",
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --content JSON")
|
||||
@@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-percent", "-999999999999",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-status", "invalid_status",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "456",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "456",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-percent", "50",
|
||||
"--progress-status", "overdue",
|
||||
"--dry-run",
|
||||
@@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "789",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "999",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple mode tests ---
|
||||
|
||||
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v1/progress_records/500",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "500",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Use default style (simple) without specifying --style
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "500",
|
||||
"--content", validSemiPlainJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "500" {
|
||||
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v1/progress_records/600",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "600",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Explicitly specify --style simple with mentions and progress rate
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "600",
|
||||
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
|
||||
"--style", "simple",
|
||||
"--progress-percent", "80",
|
||||
"--progress-status", "normal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "600" {
|
||||
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"invalid json`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid semi-plain JSON")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty mention in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for images in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "700",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "PUT") {
|
||||
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
OKRPatch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
|
||||
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
||||
|
||||
@@ -1,23 +1,77 @@
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段。
|
||||
|
||||
## 路由优先级(先判断是不是审批,再选命令)
|
||||
|
||||
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
|
||||
|
||||
### 明确归 `lark-approval` 的高优先级语义
|
||||
|
||||
出现以下任一语义时,优先走 `lark-approval`:
|
||||
|
||||
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
|
||||
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
|
||||
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
|
||||
|
||||
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
| 想做什么 | 命令 | 按需读取 reference |
|
||||
|---|---|---------------------------------------------------------------------------------|
|
||||
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
|
||||
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
|
||||
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
|
||||
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
|
||||
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
|
||||
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
|
||||
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作
|
||||
|
||||
## 执行原则(减少误路由、误重试和无效消耗)
|
||||
|
||||
### 1) 先拿最小必要信息,再执行
|
||||
|
||||
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
|
||||
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
|
||||
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
|
||||
|
||||
### 2) 已知对象时直达动作
|
||||
|
||||
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
|
||||
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
|
||||
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
|
||||
|
||||
### 3) 错误码驱动,而不是盲目重试
|
||||
|
||||
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
|
||||
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
|
||||
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次
|
||||
|
||||
## 写操作失败处理:1395001 决策树
|
||||
|
||||
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
|
||||
|
||||
1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次
|
||||
2. 优先从以下角度解释:
|
||||
- 任务可能已被他人处理
|
||||
- 单据状态已变化,当前动作已不再允许
|
||||
- 当前用户已不具备该任务的操作资格
|
||||
- 当前节点或单据状态不支持该操作
|
||||
3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环
|
||||
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
|
||||
|
||||
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
@@ -27,14 +81,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
## 不在本 skill 范围
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
@@ -8,28 +8,83 @@ metadata:
|
||||
cliHelp: "lark-cli approval --help"
|
||||
---
|
||||
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段;**references 是第一信息源**,只有在 reference 未覆盖的原生 / 高级场景下,才额外用 `lark-cli ... --help`、`lark-cli schema` 等方式补充确认字段。
|
||||
|
||||
## 路由优先级(先判断是不是审批,再选命令)
|
||||
|
||||
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
|
||||
|
||||
### 明确归 `lark-approval` 的高优先级语义
|
||||
|
||||
出现以下任一语义时,优先走 `lark-approval`:
|
||||
|
||||
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
|
||||
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
|
||||
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
|
||||
|
||||
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
| 想做什么 | 命令 | 按需读取 reference |
|
||||
|---|---|---------------------------------------------------------------------------------|
|
||||
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
|
||||
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
|
||||
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
|
||||
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
|
||||
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
|
||||
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
|
||||
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作
|
||||
|
||||
## 执行原则(减少误路由、误重试和无效消耗)
|
||||
|
||||
### 1) 先拿最小必要信息,再执行
|
||||
|
||||
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
|
||||
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
|
||||
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
|
||||
|
||||
### 2) 已知对象时直达动作
|
||||
|
||||
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
|
||||
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
|
||||
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
|
||||
|
||||
### 3) 错误码驱动,而不是盲目重试
|
||||
|
||||
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
|
||||
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
|
||||
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次
|
||||
|
||||
## 写操作失败处理:1395001 决策树
|
||||
|
||||
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
|
||||
|
||||
1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次
|
||||
2. 优先从以下角度解释:
|
||||
- 任务可能已被他人处理
|
||||
- 单据状态已变化,当前动作已不再允许
|
||||
- 当前用户已不具备该任务的操作资格
|
||||
- 当前节点或单据状态不支持该操作
|
||||
3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环
|
||||
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
|
||||
|
||||
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
@@ -39,18 +94,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
128
skills/lark-approval/references/lark-approval-approvals-get.md
Normal file
128
skills/lark-approval/references/lark-approval-approvals-get.md
Normal file
@@ -0,0 +1,128 @@
|
||||
|
||||
# approval approvals get
|
||||
|
||||
获取单个审批定义详情(用户级只读操作)。适合在发起审批实例前,先确认审批名称、表单控件结构、选项值范围以及流程节点信息。
|
||||
|
||||
需要的 scopes: ["approval:approval:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按 approval_code 查询审批定义详情
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览顶层字段
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;通常来自 `approval approvals search` 的结果 |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批定义详情通常按当前用户可见范围读取 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 常见输入来源
|
||||
|
||||
如果你已经有 `approval_code`,可直接查询:
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
```
|
||||
|
||||
如果你还没有 `approval_code`,先搜索可发起审批定义:
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
```
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中,优先关注以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `approval_code` | 审批定义 Code |
|
||||
| `approval_name` | 审批定义名称;确认是不是用户想发起的那张单 |
|
||||
| `form` | 表单定义快照;用于识别控件 `id`、`type`、选项值范围、明细子控件结构 |
|
||||
| `node_list` | 流程节点列表;用于识别节点 key、是否需要补充审批人、是否允许多人 |
|
||||
|
||||
## form 的使用重点
|
||||
|
||||
`form` 最重要的作用是帮助 agent **识别怎么组装 `instances.create.data.form`**,而不是直接把它原样提交出去。
|
||||
|
||||
重点看:
|
||||
|
||||
| 字段 / 结构 | 说明 |
|
||||
|------|------|
|
||||
| `form[].id` | 控件 ID;后续创建实例时必须使用 |
|
||||
| `form[].type` | 控件类型,例如 `input`、`date`、`radio`、`checkbox`、`fieldList` |
|
||||
| `form[].value` / 选项定义 | 用来识别可选值范围、默认值或选项值 |
|
||||
| 明细 / 子控件结构 | 用于识别 `fieldList`、控件组等复杂控件的子字段结构 |
|
||||
|
||||
**注意:`approvals.get.form` 不是 `instances.create` 可直接复用的 payload 模板。** 它是“定义快照”,主要用于识别字段结构与选项值范围。
|
||||
|
||||
## node_list 的使用重点
|
||||
|
||||
`node_list` 主要用于后续决定是否要补 `node_approver_list` / `node_cc_list`。
|
||||
|
||||
重点看:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `node_list[].custom_node_id` | 自定义节点标识;后续补节点参数时优先作为 key |
|
||||
| `node_list[].node_id` | 节点 ID;若没有 `custom_node_id`,通常退回用它做 key |
|
||||
| `node_list[].need_approver` | 是否要求发起人补充审批人 |
|
||||
| `node_list[].approver_chosen_multi` | 是否允许为该节点选择多个审批人 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是发起原生审批实例前的必要只读步骤。** 推荐固定走:`approvals search` -> `approvals get` -> `instances create`。
|
||||
- **如果用户已经明确给了 `approval_code`,直接用这个命令。** 不必再走 `approvals search`。
|
||||
- **先确认 `approval_name`。** 避免把相似名称的审批定义搞混。
|
||||
- **先用 `form` 识别控件结构,再组装创建 payload。** 不要在未看详情时猜控件 `id`、`type` 或选项值。
|
||||
- **先用 `node_list` 看是否需要补审批人。** 若某节点 `need_approver=true`,创建实例时通常要补 `node_approver_list`。
|
||||
- **`node_list` 的 key 优先取 `custom_node_id`。** 若不存在,再使用 `node_id`。
|
||||
- **`approver_chosen_multi=false` 时,一个节点通常只能补一个审批人。**
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
读取定义详情后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 发起原生审批实例
|
||||
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
|
||||
```
|
||||
|
||||
如果需要进一步理解控件取值与节点参数,优先参考:
|
||||
|
||||
- `lark-approval-instance-form-control-parameters.md`
|
||||
- `lark-approval-instance-value-sourcing.md`
|
||||
- `lark-approval-initiate.md`
|
||||
|
||||
## 结果整理方式
|
||||
|
||||
**将结果整理为“审批定义概览 + 表单结构摘要 + 节点要求摘要”。**
|
||||
|
||||
建议输出成下面这种结构:
|
||||
|
||||
```text
|
||||
审批定义:请假申请
|
||||
approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
|
||||
|
||||
表单控件摘要:
|
||||
- leave_type: radio,可选值 [annual_leave, sick_leave]
|
||||
- reason: textarea
|
||||
- start_end: dateInterval
|
||||
|
||||
节点要求摘要:
|
||||
- manager_node:need_approver=true,approver_chosen_multi=false
|
||||
- hr_node:need_approver=false
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
# approval approvals search
|
||||
|
||||
搜索**当前用户可发起**的审批定义(launchable approvals)。只读操作,不会创建审批实例。
|
||||
|
||||
需要的 scopes: ["approval:approval:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词搜索可发起审批定义
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval approvals search --data '{"keyword":"请假", "page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览候选定义
|
||||
lark-cli approval approvals search --data '{"keyword":"出差"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `keyword` | 是 | 搜索关键词,例如 `请假`、`报销`、`出差`、`采购` |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;“可发起审批定义”是面向当前用户的查询 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 这个命令解决什么问题
|
||||
|
||||
当用户只有自然语言意图,还没有 `approval_code` 时,先用它把“可发起的审批定义候选项”找出来。
|
||||
|
||||
典型场景:
|
||||
|
||||
- “帮我找一下请假审批”
|
||||
- “有哪些可以发起的报销单?”
|
||||
- “先搜一下出差审批,再帮我提单”
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果里,优先关注以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `approval_code` | 审批定义 Code;后续 `approvals get` 和 `instances create` 都要用它 |
|
||||
| `approval_name` | 审批定义名称;给用户做候选选择时最关键 |
|
||||
| `is_external` | 是否为三方审批定义;`true` 表示不能走原生 `instances.create` |
|
||||
| `create_link` | 三方审批定义的发起链接;`is_external=true` 时优先返回给用户 |
|
||||
|
||||
## 使用规则
|
||||
|
||||
- **这是发起审批工作流的第一步。** 标准顺序是:`approvals search` -> `approvals get` -> `instances create`。
|
||||
- **搜索结果为空时,不要猜。** 直接告诉用户当前关键词下没有可发起定义,并建议用户换关键词。
|
||||
- **命中多个结果时,不要替用户拍板。** 先把候选定义列出来,让用户选择目标审批定义。
|
||||
- **`is_external=true` 时不要调用 `approval instances create`。** 这类定义属于三方审批,优先返回 `create_link` 并说明需要通过链接发起。
|
||||
- **只有 `is_external=false` 的原生定义,才继续 `approvals get`。**
|
||||
- **如果用户已经明确给出 `approval_code`,不要再 search。** 直接执行 `approval approvals get`。
|
||||
|
||||
## 结果整理方式
|
||||
|
||||
**将结果整理为候选清单,优先展示“名称 + approval_code + 是否三方定义 + 下一步建议”。**
|
||||
|
||||
建议输出成下面这种结构:
|
||||
|
||||
```text
|
||||
找到 3 个可发起审批定义:
|
||||
|
||||
1. 请假申请
|
||||
- approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
|
||||
- is_external: false
|
||||
- next: 可继续读取 definitions 详情(approvals get)
|
||||
|
||||
2. 差旅报销
|
||||
- approval_code: 99887766-xxxx
|
||||
- is_external: true
|
||||
- next: 返回 create_link,引导用户通过链接发起
|
||||
```
|
||||
|
||||
## 常见后续操作
|
||||
|
||||
### 1)用户选中了某个定义,继续查看详情
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
```
|
||||
|
||||
### 2)确认是原生定义后,再准备发起审批实例
|
||||
|
||||
```bash
|
||||
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
|
||||
```
|
||||
|
||||
### 3)确认是三方定义时,直接返回链接
|
||||
|
||||
当 `is_external=true` 时,优先向用户返回 `create_link`,说明该审批需在三方系统或跳转页面中发起,而不是通过原生 `instances.create`。
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
## 执行摘要
|
||||
|
||||
- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。
|
||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。
|
||||
- **原生审批提单如果用户未明确给出 `approval_code`,必须固定走 `approvals search` -> `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||
- **原生审批提单如果用户明确给出 `approval_code`,固定走 `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances create`,应优先使用 `create_link`。
|
||||
- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
|
||||
- **先读控件参数 reference 和值来源 reference,再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`。
|
||||
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
|
||||
- **节点参数只从 `node_list` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
|
||||
- **先读控件参数 reference 和值来源 reference,再读本文里的创建参数规则。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md)。
|
||||
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances create --data.form` 中,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
|
||||
- **节点参数只从 `node_list` 和本文里的节点参数规则里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,不要混用姓名或其他身份标识。
|
||||
- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`。
|
||||
- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`。
|
||||
- **创建实例前先确认。** `approval instances create` 是写操作,执行前,让用户确认最终定义、表单值和节点参数;真正执行时显式传 `--yes`。
|
||||
|
||||
## 适用场景
|
||||
|
||||
@@ -20,11 +21,10 @@
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。**
|
||||
- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。**
|
||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。
|
||||
- **严禁在未先阅读本文中的创建参数规则、[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 的情况下直接提单。**
|
||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances create`。
|
||||
- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。
|
||||
- **严禁对三方定义调用 `instances.create`。**
|
||||
- **严禁对三方定义调用 `instances create`。**
|
||||
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
|
||||
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
|
||||
- **严禁在未得到用户确认前直接执行真实提单。**
|
||||
@@ -33,10 +33,9 @@
|
||||
|
||||
### 1. 搜索可发起审批定义
|
||||
|
||||
先用 `schema` 看参数,再搜索定义:
|
||||
先搜索定义:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.search
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
```
|
||||
|
||||
@@ -44,7 +43,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
|
||||
- 若结果为空,告诉用户当前关键词下没有可发起定义。
|
||||
- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
|
||||
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`。
|
||||
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances create`。
|
||||
- 只有 `is_external=false` 的原生定义才继续下一步。
|
||||
|
||||
### 2. 获取审批定义详情
|
||||
@@ -52,7 +51,6 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
拿到 `approval_code` 后,读取定义详情:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.get
|
||||
lark-cli approval approvals get \
|
||||
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
|
||||
```
|
||||
@@ -63,12 +61,30 @@ lark-cli approval approvals get \
|
||||
- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
|
||||
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
|
||||
|
||||
### 3. 组装 `form`
|
||||
### 3. 创建请求参数速查
|
||||
|
||||
`instances.create.data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
输入参数如下:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 |
|
||||
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
|
||||
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
|
||||
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
|
||||
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
|
||||
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
### 4. 组装 `form`
|
||||
|
||||
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
|
||||
- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
|
||||
- `contact`、`department`、`fieldList`、`dateInterval`、`amount`、`telephone`、`document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。
|
||||
@@ -100,7 +116,7 @@ lark-cli approval approvals get \
|
||||
- `input` / `textarea`: `value` 是字符串
|
||||
- `date`: `value` 是 RFC3339 时间字符串
|
||||
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
|
||||
- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id`
|
||||
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 `option.value`;关联外部选项时传 `options.id`
|
||||
- `checkbox` / `checkboxV2`: `value` 是选项值数组
|
||||
- `number`: `value` 是数字
|
||||
- `amount`: `value` 是数字,还要带 `currency`
|
||||
@@ -129,7 +145,7 @@ lark-cli approval approvals get \
|
||||
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
|
||||
- 不要把控件组整体当成普通字符串或扁平对象提交
|
||||
|
||||
### 4. 组装节点参数
|
||||
### 5. 组装节点参数
|
||||
|
||||
从 `node_list` 推导节点参数:
|
||||
|
||||
@@ -139,13 +155,13 @@ lark-cli approval approvals get \
|
||||
- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。
|
||||
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
|
||||
|
||||
### 5. 创建审批实例
|
||||
### 6. 创建审批实例
|
||||
|
||||
先看 `schema`,确认最终结构后再执行:
|
||||
创建命令使用 `approval instances create`,需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
确认最终表单值和节点参数后再执行:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.instances.create
|
||||
|
||||
lark-cli approval instances create \
|
||||
--data '{
|
||||
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
|
||||
@@ -157,6 +173,8 @@ lark-cli approval instances create \
|
||||
}
|
||||
]
|
||||
}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -170,7 +188,7 @@ lark-cli approval instances create \
|
||||
|
||||
优先级固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。
|
||||
1. 本文中的创建请求参数、节点参数和返回结果说明:决定 `instances create` 要传哪些字段、怎么执行、成功后回什么。
|
||||
2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。
|
||||
3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
|
||||
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
|
||||
@@ -184,8 +202,8 @@ lark-cli approval instances create \
|
||||
|---|---|
|
||||
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
|
||||
| 已经拿到 `approval_code` | 直接 `approvals.get` |
|
||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
|
||||
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
|
||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances create` |
|
||||
| `is_external=true` | 返回 `create_link`,不要调 `instances create` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
@@ -194,3 +212,13 @@ lark-cli approval instances create \
|
||||
- `approval_name`
|
||||
- `instance_code`
|
||||
- `instance_link`
|
||||
|
||||
建议整理为下面这种结构:
|
||||
|
||||
```text
|
||||
审批已创建成功:
|
||||
|
||||
- approval_name: 请假申请
|
||||
- instance_code: 19EAC829-F1CB-527F-BE2A-1330422E60C0
|
||||
- instance_link: https://...
|
||||
```
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
阅读顺序固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create`
|
||||
1. [`lark-approval-initiate.md`](./lark-approval-initiate.md) 中的创建请求参数、节点参数和返回结果说明
|
||||
2. `approval approvals get` 返回的 `form` / `node_list`
|
||||
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
|
||||
4. 本文
|
||||
|
||||
## 总原则
|
||||
|
||||
- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
|
||||
- `lark-approval-initiate.md` 决定创建请求字段名、字段层级、节点参数结构。
|
||||
- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。
|
||||
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
|
||||
- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
|
||||
# approval instances cancel
|
||||
|
||||
撤回一个已发起的审批实例(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后再执行撤回。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要撤回该审批实例且目标实例无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval instances cancel \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 撤回一个审批实例
|
||||
lark-cli approval instances cancel \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体
|
||||
lark-cli approval instances cancel \
|
||||
--data @./cancel-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例撤回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
如果你要找“我发起的审批实例”,可先查询已发起列表:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
```
|
||||
|
||||
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instances[].instance_code` | 审批实例 Code;撤回时必须提供 |
|
||||
| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为撤回输入 |
|
||||
| `tasks[].instance_status` | 审批实例状态;可用于判断是否仍处于可撤回阶段 |
|
||||
|
||||
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **撤回的是审批实例,不是单个任务**:`instances cancel` 只需要 `instance_code`,不需要 `task_id`。
|
||||
- **优先确认实例是否仍可撤回**:已经通过、已拒绝、已撤销或已终止的实例通常不适合继续撤回。
|
||||
- **优先从 `instances initiated` 获取目标实例**:因为撤回通常针对“我发起的审批”,这个入口最直接。
|
||||
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个待办/已办上下文进入时,这样更方便。
|
||||
- **先 `--dry-run` 再执行**:尤其在实例来源不明确、用户只给了标题关键字,或一次要核对多个实例时,先预览更安全。
|
||||
105
skills/lark-approval/references/lark-approval-instances-cc.md
Normal file
105
skills/lark-approval/references/lark-approval-instances-cc.md
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
# approval instances cc
|
||||
|
||||
给一个审批实例追加抄送人(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后,再提供抄送人的用户 ID 执行抄送。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要抄送该审批实例且目标实例、抄送对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给项目 owner 了解进展"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 按 open_id 抄送一个人
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给你知悉"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 一次抄送多个人
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx","ou_yyy"],"comment":"请相关同学同步关注"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 按 user_id 抄送
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["123456789"],"comment":"抄送给财务负责人"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体
|
||||
lark-cli approval instances cc \
|
||||
--data @./cc-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 |
|
||||
| `cc_user_ids` | 是 | 抄送人的用户 ID 数组;需要和 `user_id_type` 保持一致 |
|
||||
| `comment` | 否 | 抄送留言,例如 `抄送给你知悉`、`请同步关注该审批进展` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `cc_user_ids` 内用户 ID 的类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认抄送人的 ID 类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例抄送通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
如果你要找“我发起的审批实例”,可先查询已发起列表:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
```
|
||||
|
||||
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instances[].instance_code` | 审批实例 Code;抄送时必须提供 |
|
||||
| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为抄送输入 |
|
||||
| `tasks[].title` | 任务标题,可用于确认是否是要操作的那个审批 |
|
||||
| `tasks[].instance_status` | 审批实例状态;可用于判断当前审批是否仍处于进行中 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行抄送。
|
||||
|
||||
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **抄送的是审批实例,不是单个任务**:`instances cc` 只需要 `instance_code`,不需要 `task_id`。
|
||||
- **`cc_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **`cc_user_ids` 是数组**:即使只抄送一个人,也要按数组形式传入。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **优先从 `instances initiated` 获取目标实例**:因为抄送常见于“我发起的审批”场景,这个入口最直接。
|
||||
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个审批上下文进入时,这样更方便。
|
||||
- **`comment` 建议简洁明确**:例如 `抄送给你知悉`、`请同步关注审批进展`。避免过长或模糊描述。
|
||||
- **先 `--dry-run` 再执行**:尤其在抄送对象较多、抄送人来源不明确,或需要让用户先核对实例标题时,先预览更安全。
|
||||
145
skills/lark-approval/references/lark-approval-instances-get.md
Normal file
145
skills/lark-approval/references/lark-approval-instances-get.md
Normal file
@@ -0,0 +1,145 @@
|
||||
|
||||
# approval instances get
|
||||
|
||||
获取单个审批实例详情(用户级只读操作)。适合在执行 approve / reject / transfer / rollback / cancel / cc / remind 之前,先查看审批表单、当前节点、任务列表、审批动态和整体状态。
|
||||
|
||||
需要的 scopes: ["approval:instance:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按实例 Code 查询详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览顶层字段
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例详情查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 常见输入来源
|
||||
|
||||
如果你已经有实例 Code,可直接查询:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
如果你还没有实例 Code,可先从以下命令获取:
|
||||
|
||||
```bash
|
||||
# 查询我发起的审批实例
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
|
||||
# 或从任务列表里拿到关联实例 Code
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instance_code` | 审批实例 Code |
|
||||
| `serial_number` | 审批单编号 |
|
||||
| `definition_code` | 审批定义 Code |
|
||||
| `definition_name` | 审批名称 |
|
||||
| `user_id` | 发起审批的用户 ID |
|
||||
| `department_id` | 发起人所在部门 ID |
|
||||
| `status` | 审批实例状态,见下方“status 枚举” |
|
||||
| `reverted` | 单据是否已被撤销 |
|
||||
| `start_time` | 审批创建时间 |
|
||||
| `end_time` | 审批完成时间,未完成时通常为 `0` |
|
||||
| `form` | 表单数据,JSON 字符串 |
|
||||
| `current_nodes` | 当前审批节点列表 |
|
||||
| `tasks` | 审批任务列表 |
|
||||
| `operation_records` | 审批动态,例如通过、拒绝、转交、加签、回退、撤回、抄送 |
|
||||
| `comments` | 评论列表 |
|
||||
|
||||
## status 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `PENDING` | 审批中 |
|
||||
| `APPROVED` | 已通过 |
|
||||
| `REJECTED` | 已拒绝 |
|
||||
| `CANCELED` | 已撤回 |
|
||||
| `DELETED` | 已删除 |
|
||||
|
||||
## current_nodes 重点字段
|
||||
|
||||
`current_nodes` 常用于判断审批流当前卡在哪一层:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------------------------------------------|
|
||||
| `current_nodes[].node_id` | 当前审批节点 ID |
|
||||
| `current_nodes[].node_name` | 当前审批节点名称 |
|
||||
| `current_nodes[].type` | 审批方式:`AND` 会签、`OR` 或签、`SEQUENTIAL` 依次审批等 |
|
||||
| `current_nodes[].approvers[].task_id` | 当前审批人关联任务 ID |
|
||||
| `current_nodes[].approvers[].user_id` | 当前审批人用户 ID |
|
||||
|
||||
## tasks 重点字段
|
||||
|
||||
`tasks` 常用于把实例和具体审批任务关联起来:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].id` | 审批任务 ID |
|
||||
| `tasks[].node_id` | 任务所属节点 ID |
|
||||
| `tasks[].node_name` | 任务所属节点名称 |
|
||||
| `tasks[].user_id` | 审批人用户 ID |
|
||||
| `tasks[].status` | 任务状态:`PENDING`、`APPROVED`、`REJECTED`、`TRANSFERRED`、`DONE` |
|
||||
| `tasks[].start_time` | 任务开始时间 |
|
||||
| `tasks[].end_time` | 任务完成时间 |
|
||||
|
||||
## operation_records 重点字段
|
||||
|
||||
`operation_records` 常用于审计审批过程:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `operation_records[].type` | 事件类型,如 `PASS`、`REJECT`、`TRANSFER`、`ROLLBACK`、`CANCEL`、`CC` |
|
||||
| `operation_records[].create_time` | 事件发生时间 |
|
||||
| `operation_records[].user_id` | 触发该事件的用户 ID |
|
||||
| `operation_records[].task_id` | 关联任务 ID |
|
||||
| `operation_records[].node_id` | 关联节点 ID |
|
||||
| `operation_records[].comment` | 理由 / 备注 |
|
||||
| `operation_records[].cc_user_ids` | 被抄送人列表(抄送事件时) |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是最适合做“详情确认”的只读命令**:当你已经拿到 `instance_code`,需要确认表单、当前节点、任务状态、审批动态时,优先使用它。
|
||||
- **在执行写操作前先看详情**:例如做 `tasks rollback` 前确认可退回节点,做 `instances cancel` 前确认实例状态,做 `tasks remind` 前确认当前任务是否仍待处理。
|
||||
- **`form` 是 JSON 字符串**:调用方通常还需要再解析一层,才能拿到表单字段值。
|
||||
- **`current_nodes` 和 `tasks` 可以联动看**:前者看“当前卡在哪个节点”,后者看“每个任务目前由谁处理、状态如何”。
|
||||
- **`operation_records` 适合做时间线回溯**:例如排查谁转交过、谁加签过、什么时候撤回或抄送过。
|
||||
- **优先显式传 `locale` 和 `user_id_type`**:这样 agent 更容易理解返回文本和 ID 语义,减少歧义。
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
读取详情后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 同意审批任务
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>"}' --as user --yes
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
|
||||
# 催办审批任务
|
||||
lark-cli approval tasks remind --data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"]}' --as user --yes
|
||||
```
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
# approval instances initiated
|
||||
|
||||
查询当前用户已发起的审批实例列表(用户级只读操作)。适合在需要查看“我发起了哪些审批”、筛选某类审批定义、获取 `instance_code` 供后续 `instances get` / `instances cancel` / `instances cc` 等命令使用时调用。
|
||||
|
||||
需要的 scopes: ["approval:instance:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询我发起的审批列表
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
|
||||
# 只看某个审批定义下我发起的实例
|
||||
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;已发起审批列表查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `count` | 列表计数,只在第一页返回;大于等于 100 个实例时返回 `99` |
|
||||
| `has_more` | 是否还有更多数据 |
|
||||
| `page_token` | 下一页翻页 Token |
|
||||
| `instances[].instance_code` | 审批实例 Code;后续查询详情或执行撤回 / 抄送时通常需要 |
|
||||
| `instances[].definition_code` | 审批定义 Code |
|
||||
| `instances[].definition_name` | 审批定义名称 |
|
||||
| `instances[].definition_group_id` | 审批定义分组 ID |
|
||||
| `instances[].definition_group_name` | 审批定义分组名称 |
|
||||
| `instances[].initiator` | 发起人 ID |
|
||||
| `instances[].initiator_name` | 发起人姓名 |
|
||||
| `instances[].instance_status` | 审批实例状态,见下方“instance_status 枚举” |
|
||||
| `instances[].instance_external_id` | 第三方审批实例 ID(仅第三方审批实例存在) |
|
||||
| `instances[].link` | 三方审批跳转链接 |
|
||||
| `instances[].summaries` | 摘要字段列表 |
|
||||
|
||||
## instance_status 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `0` | 无流程状态,不展示对应标签 |
|
||||
| `1` | 流程实例流转中 |
|
||||
| `2` | 已通过 |
|
||||
| `3` | 已拒绝 |
|
||||
| `4` | 已撤销 |
|
||||
| `5` | 已终止 |
|
||||
|
||||
## 常见使用场景
|
||||
|
||||
### 1) 找到我要操作的审批实例
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
|
||||
```
|
||||
|
||||
拿到 `instances[].instance_code` 后,可继续:
|
||||
|
||||
```bash
|
||||
# 查看审批实例详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
```
|
||||
|
||||
### 2) 只看某类审批
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated \
|
||||
--params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' \
|
||||
--as user
|
||||
```
|
||||
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。
|
||||
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
|
||||
- **结果很多时优先 `--format table`**:适合人工快速浏览。
|
||||
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
|
||||
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
|
||||
- **摘要字段 `summaries` 很适合做列表预览**:当审批标题不够明确时,可结合摘要值帮助识别目标实例。
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
拿到列表后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 查看单个审批实例详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
|
||||
# 给审批实例追加抄送人
|
||||
lark-cli approval instances cc --data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["<USER_ID>"]}' --params '{"user_id_type":"open_id"}' --as user --yes
|
||||
```
|
||||
120
skills/lark-approval/references/lark-approval-tasks-add-sign.md
Normal file
120
skills/lark-approval/references/lark-approval-tasks-add-sign.md
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
# approval tasks add_sign
|
||||
|
||||
给一个审批任务加签(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被加签人的用户 ID、加签方式等参数执行加签。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要对该审批任务加签且目标任务、加签对象、加签方式都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"前加签给财务复核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 前加签(需要 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"请先补充审核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 后加签(需要 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":2,"add_sign_user_ids":["ou_xxx","ou_yyy"],"approval_method":2,"comment":"当前审批完成后请两位继续审核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 并加签(常见场景可不传 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":3,"add_sign_user_ids":["123456789"],"comment":"并加签给项目 owner"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或较多加签人
|
||||
lark-cli approval tasks add_sign \
|
||||
--data @./add-sign-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `add_sign_type` | 是 | 加签类型:`1` 前加签、`2` 后加签、`3` 并加签 |
|
||||
| `add_sign_user_ids` | 是 | 被加签人 ID 数组;需要和 `user_id_type` 保持一致 |
|
||||
| `approval_method` | 否 | 审批方式:`1` 或签、`2` 会签、`3` 依次审批;**仅在前加签、后加签时需要填写** |
|
||||
| `comment` | 否 | 审批意见或加签说明,例如 `前加签给财务复核`、`请项目 owner 一并确认` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `add_sign_user_ids` 内用户 ID 的类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认被加签人的 ID 类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批加签通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 枚举说明
|
||||
|
||||
### add_sign_type
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `1` | 前加签 |
|
||||
| `2` | 后加签 |
|
||||
| `3` | 并加签 |
|
||||
|
||||
### approval_method
|
||||
|
||||
| 值 | 含义 | 适用场景 |
|
||||
|----|------|----------|
|
||||
| `1` | 或签 | 前加签 / 后加签 |
|
||||
| `2` | 会签 | 前加签 / 后加签 |
|
||||
| `3` | 依次审批 | 前加签 / 后加签 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback / add_sign 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;加签前建议先检查 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行加签。
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行加签操作。
|
||||
- **`add_sign_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **`add_sign_type` 要和业务意图一致**:前加签是在当前审批前插入审批人,后加签是在当前审批后追加审批人,并加签则是增加并行审批人。
|
||||
- **前加签 / 后加签要补 `approval_method`**:不要遗漏,否则请求可能无法准确表达审批方式。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 add_sign 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,加签前应谨慎验证。
|
||||
- **`comment` 建议写明加签原因**:例如 `增加财务复核`、`增加项目 owner 并行确认`,方便相关人员理解上下文。
|
||||
- **先 `--dry-run` 再执行**:尤其在多人加签、跨部门加签或加签对象来源不明确时,先预览更安全。
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
# approval tasks approve
|
||||
|
||||
同意一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行同意。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确同意审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 同意审批任务,并附带审批意见
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 需要回填表单时,传入 form(按当前命令定义,form 为字符串化 JSON)
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意并补充信息","form":"[{\"id\":\"user_name\",\"type\":\"input\",\"value\":\"Alice\"}]"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment / form
|
||||
lark-cli approval tasks approve \
|
||||
--data @./approve-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `comment` | 否 | 审批意见,例如 `同意`、`已确认` |
|
||||
| `form` | 否 | 表单数据;按当前命令定义,字段类型为 `string`,通常传字符串化 JSON;仅在审批动作需要同时回填表单时使用 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批同意通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的两个字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行同意操作。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 approve 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。
|
||||
- **`comment` 建议简洁明确**:例如 `同意`、`同意,信息已核对`。没有审批意见要求时可省略。
|
||||
- **`form` 只在确有需要时传**:大多数简单同意场景只传 `instance_code`、`task_id`、可选 `comment` 即可。
|
||||
- **先 `--dry-run` 再执行**:尤其在批量处理、表单回填或任务来源不明确时,先预览更安全。
|
||||
76
skills/lark-approval/references/lark-approval-tasks-query.md
Normal file
76
skills/lark-approval/references/lark-approval-tasks-query.md
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
# approval tasks query
|
||||
|
||||
查询当前用户的审批任务列表,可用于查看待办、已办、知会等分组。只读操作,不会修改审批状态。
|
||||
|
||||
需要的 scopes: ["approval:task:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询待办审批
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
|
||||
# 查询已办审批
|
||||
lark-cli approval tasks query --params '{"topic":"2"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `topic` | 是 | 任务分组主题,见下方“topic 枚举” |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批任务查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## topic 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `1` | 待办审批 |
|
||||
| `2` | 已办审批 |
|
||||
| `17` | 未读知会 |
|
||||
| `18` | 已读知会 |
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `count` | 列表计数,只在第一页返回;当任务数大于等于 100 时返回 `99` |
|
||||
| `has_more` | 是否还有更多数据 |
|
||||
| `page_token` | 下一页翻页 Token |
|
||||
| `tasks[].task_id` | 任务 ID,全局唯一 |
|
||||
| `tasks[].instance_code` | 审批实例 Code;后续执行 approve / reject / rollback 等操作时通常需要与 `task_id` 成对使用 |
|
||||
| `tasks[].title` | 任务标题 |
|
||||
| `tasks[].status` | 任务状态:`1` 待办、`2` 已办、`17` 未读、`18` 已读、`33` 处理中、`34` 撤回 |
|
||||
| `tasks[].topic` | 任务所属分组主题 |
|
||||
| `tasks[].instance_status` | 审批实例状态:`0` 无状态、`1` 流转中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 已终止 |
|
||||
| `tasks[].definition_code` | 审批定义 Code |
|
||||
| `tasks[].definition_name` | 审批定义名称 |
|
||||
| `tasks[].initiator` | 发起人 ID |
|
||||
| `tasks[].initiator_name` | 发起人姓名 |
|
||||
| `tasks[].summaries` | 表单摘要字段列表 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
|
||||
| `tasks[].user_id` | 任务所属用户 ID |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。
|
||||
- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。
|
||||
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。
|
||||
- 当结果量较大时,优先使用 `--format table` 提升可读性。
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
# approval tasks reject
|
||||
|
||||
拒绝一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行拒绝。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要拒绝该审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks reject \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 拒绝审批任务,并附带审批意见
|
||||
lark-cli approval tasks reject \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝,信息不完整"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment
|
||||
lark-cli approval tasks reject \
|
||||
--data @./reject-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `comment` | 否 | 审批意见,例如 `拒绝`、`拒绝,信息不完整` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批拒绝通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的两个字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行拒绝操作。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 reject 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。
|
||||
- **`comment` 建议写清拒绝原因**:例如 `拒绝,缺少合同附件`、`拒绝,预算字段填写不完整`。这有助于发起人理解原因并补充材料。
|
||||
- **先 `--dry-run` 再执行**:尤其在批量处理或任务来源不明确时,先预览更安全。
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
# approval tasks remind
|
||||
|
||||
对审批实例中的指定任务发起催办(用户级写操作)。通常先通过 `tasks query` 找到待办任务,拿到 `instance_code` 和要催办的 `task_ids`,必要时再用 `instances get` 查看详情,然后执行催办。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要催办该审批且目标实例、目标任务都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快处理"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 催办单个审批任务
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快审批该单据"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 同一实例下催办多个任务
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID_1>","<TASK_ID_2>"],"comment":"请相关审批人尽快处理"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或多个 task_ids
|
||||
lark-cli approval tasks remind \
|
||||
--data @./remind-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances get` 获取 |
|
||||
| `task_ids` | 是 | 被催办的任务 ID 数组;应与 `instance_code` 属于同一审批实例 |
|
||||
| `comment` | 否 | 催办说明,例如 `请尽快处理`、`该单据较急,请优先审批` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批催办通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;催办时必须提供 |
|
||||
| `tasks[].task_id` | 审批任务 ID;放入 `task_ids` 数组中 |
|
||||
| `tasks[].title` | 任务标题,可用于确认催办对象是否正确 |
|
||||
| `tasks[].status` | 任务状态;一般优先催办仍处于待处理状态的任务 |
|
||||
|
||||
如需进一步确认当前审批流、节点和人员信息,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_ids` 要对应同一个审批实例**:不要把不同实例下的任务 ID 混在同一次催办请求中。
|
||||
- **`task_ids` 是数组**:即使只催办一个任务,也要按数组形式传入。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 remind 的输入来源。
|
||||
- **催办前先确认任务仍需处理**:已经审批完成、已撤回或已终止的任务一般不适合继续催办。
|
||||
- **`comment` 建议简洁且明确**:例如 `该单据较急,请优先审批`、`请今天内处理`。避免过长或模糊描述。
|
||||
- **先 `--dry-run` 再执行**:尤其在一次催办多个任务、任务来源不明确或需让用户复核催办对象时,先预览更安全。
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
# approval tasks rollback
|
||||
|
||||
将一个审批任务退回到指定节点(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,再结合实例详情确认可退回的目标节点 `node_ids`,最后执行退回。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要退回该审批且目标任务、退回节点都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"退回补充材料"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 退回到单个节点
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"请补充附件后重新提交"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 传多个候选节点 ID(以实际审批定义支持情况为准)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或较多 node_ids
|
||||
lark-cli approval tasks rollback \
|
||||
--data @./rollback-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;退回前建议先检查 |
|
||||
|
||||
如需确认流程节点、当前进度和可退回位置,可先查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
|
||||
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
|
||||
- **`comment` 建议写清退回原因**:例如 `附件缺失,请补齐后重新提交`、`费用说明不完整,请补充明细`,方便发起人或上一步处理人理解原因。
|
||||
- **先 `--dry-run` 再执行**:尤其在节点来源不明确、审批链路复杂或批量处理时,先预览更安全。
|
||||
@@ -0,0 +1,91 @@
|
||||
|
||||
# approval tasks transfer
|
||||
|
||||
转交一个审批任务给其他用户处理(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被转交人的用户 ID 执行转交。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要转交该审批且目标任务、转交对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"请你继续处理"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 按 open_id 转交审批任务
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"转交给你处理"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 按 user_id 转交审批任务
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"123456789","comment":"请补充审核"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment
|
||||
lark-cli approval tasks transfer \
|
||||
--data @./transfer-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `transfer_user_id` | 是 | 被转交人的用户 ID;需要和 `user_id_type` 保持一致 |
|
||||
| `comment` | 否 | 审批意见或转交说明,例如 `转交给你处理`、`请继续审核该单据` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `transfer_user_id` 的 ID 类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认 `transfer_user_id` 的真实类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批转交通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;转交前建议先检查 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行转交。
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行转交操作。
|
||||
- **`transfer_user_id` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 transfer 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行同意/拒绝等处理动作,转交前也应谨慎验证。
|
||||
- **`comment` 建议写明转交原因**:例如 `你更熟悉该项目,请继续处理`、`转交给预算 owner 审核`,方便接收人理解上下文。
|
||||
- **先 `--dry-run` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。
|
||||
@@ -46,30 +46,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
- `<sub-page-list>` — `<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
|
||||
- `<html5-block>` — 在飞书文档「HTML 块」iframe 里加载的单文件 HTML。
|
||||
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
|
||||
|
||||
## html
|
||||
|
||||
1. 写入 HTML 内容块时,把 HTML 存为本地 `.html` 文件,XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
|
||||
2. 格式如下:
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="use-iframe" content="true">
|
||||
<meta name="html-box-height-mode" content="auto">
|
||||
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
# 四、块级复制与移动
|
||||
|
||||
## 移动(block_move_after)
|
||||
|
||||
@@ -59,6 +59,8 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m
|
||||
|
||||
### Card Messages (Interactive)
|
||||
|
||||
**Before sending or replying with any `interactive` card (`+messages-send` / `+messages-reply`), you MUST read [`references/card/lark-im-card-create.md`](references/card/lark-im-card-create.md) and follow its workflow.** The card JSON passed to `--msg-type interactive --content` must be the output of that workflow — never hand-write or copy a card payload.
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
`interactive` cards support callback events (`card.action.trigger`) — see [`references/lark-im-card-action-reply.md`](references/lark-im-card-action-reply.md).
|
||||
@@ -102,6 +104,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
|
||||
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
@@ -139,10 +142,8 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
|
||||
### chat.members
|
||||
|
||||
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
|
||||
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
|
||||
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
|
||||
### chat.user_setting
|
||||
|
||||
@@ -213,10 +214,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `chats.get` | `im:chat:read` |
|
||||
| `chats.link` | `im:chat:read` |
|
||||
| `chats.update` | `im:chat:update` |
|
||||
| `chat.members.bots` | `im:chat.members:read` |
|
||||
| `chat.members.create` | `im:chat.members:write_only` |
|
||||
| `chat.members.delete` | `im:chat.members:write_only` |
|
||||
| `chat.members.get` | `im:chat.members:read` |
|
||||
| `+chat-members-list` | `im:chat.members:read` |
|
||||
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
|
||||
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
|
||||
| `chat.managers.add_managers` | `im:chat.managers:write_only` |
|
||||
|
||||
107
skills/lark-im/references/card/card-2.0-schema.md
Normal file
107
skills/lark-im/references/card/card-2.0-schema.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 卡片 2.0 组件大纲
|
||||
|
||||
Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声明。先在下表按用途选组件,再点明细看字段:有明细文件的点 `components/<tag>.md`(完整字段+示例+易错点),低频组件点链接看官方文档。
|
||||
|
||||
## 根结构
|
||||
|
||||
顶层固定四字段,先搭骨架再往 `body.elements` 填组件。以下为**推荐完整骨架**(含 type scale、light/dark color token、header 三件套):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "2.0",
|
||||
"config": {
|
||||
"update_multi": true,
|
||||
"width_mode": "default",
|
||||
"style": {
|
||||
"text_size": {
|
||||
"title": { "default": "heading-2", "pc": "heading-2", "mobile": "heading-3" },
|
||||
"body": { "default": "normal", "pc": "normal", "mobile": "normal" },
|
||||
"caption": { "default": "notation", "pc": "notation", "mobile": "notation" }
|
||||
},
|
||||
"color": {
|
||||
"cus-primary": { "light_mode": "rgba(30,120,255,1)", "dark_mode": "rgba(80,150,255,1)" },
|
||||
"cus-primary-bg": { "light_mode": "rgba(30,120,255,0.08)", "dark_mode": "rgba(80,150,255,0.12)" },
|
||||
"cus-muted": { "light_mode": "rgba(100,106,115,1)", "dark_mode": "rgba(150,155,163,1)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
|
||||
"text_tag_list": [
|
||||
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
|
||||
]
|
||||
},
|
||||
"body": { "direction": "vertical", "padding": "12px 12px 20px 12px", "elements": [] }
|
||||
}
|
||||
```
|
||||
|
||||
> **按需裁剪**:`subtitle` / `text_tag_list` / color token 按实际诉求取舍,不强制全用。组件里用 `"text_size": "title"` / `"caption"` 引用 token,用 `"font_color": "cus-muted"` 引用颜色 token;主色系变化时只需改 config 里的 RGBA,全卡自动跟随。
|
||||
|
||||
- `schema` 必须显式为 `"2.0"`,否则按 1.0 渲染。`header` 详见 `components/header.md`。
|
||||
- **元素通用字段**(所有 `elements[]` 组件):`tag`(必填) · `element_id`(卡内唯一,字母开头、≤20 字符) · `margin`(外边距 [-99,99]px)。
|
||||
- `card_link`(整卡跳转):`{url, pc_url, ios_url, android_url}`,至少填 `url`;某端禁跳设 `lark://msgcard/unsupported_action`。
|
||||
- 硬限制:单卡 ≤ **200** 元素;需客户端 **≥ 7.20**(旧版仅显示 header)。
|
||||
- 颜色 / 图标枚举见 `resource/colors.md` · `resource/icons.md`。
|
||||
|
||||
**config**(全局行为,可整体省略):
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `update_multi` | true | 共享卡片,v2 仅支持 true |
|
||||
| `width_mode` | default | `default`(≤600px) / `compact`(400px) / `fill`(撑满) |
|
||||
| `enable_forward` | true | 是否允许转发 |
|
||||
| `summary` | — | 会话列表预览:`{content, i18n_content:{zh_cn,en_us,…}}` |
|
||||
| `streaming_mode` | false | 流式更新模式(配 `streaming_config`) |
|
||||
| `style.text_size` | — | 自定义字号 token,格式 `{"<名称>":{default,pc,mobile}}`;名称可自定义(如 `title`/`caption`),组件 `text_size` 引用该名称 |
|
||||
| `style.color` | — | 自定义颜色 token,格式 `{"<名称>":{light_mode,dark_mode}}`(RGBA);名称可自定义(如 `cus-primary`),组件 `font_color`/`background_style` 等字段引用 |
|
||||
|
||||
> 多语言:`config.locales` 限定生效语种、`use_custom_translation` 优先用自带 i18n。
|
||||
|
||||
**body 布局字段**(均 v2 新增):`direction`(vertical/horizontal) · `padding`([0,99]px) · `horizontal_spacing`/`vertical_spacing`(`small`4/`medium`8/`large`12/`extra_large`16 或 px) · `horizontal_align`/`vertical_align`。
|
||||
|
||||
---
|
||||
|
||||
## 容器类(布局 / 组织交互)
|
||||
|
||||
| 组件 | 用途 |
|
||||
|---|---|
|
||||
| [column_set](components/column_set.md) | 横向分栏,多列图文对齐(数据表、字段对、列表) |
|
||||
| [collapsible_panel](components/collapsible_panel.md) | 折叠面板,收纳备注/长文本等次要信息 |
|
||||
| [form](components/form.md) | 表单容器,批量录入表单项后一次提交 |
|
||||
| [interactive_container](components/interactive_container.md) | 整块可点击区域,可统一定义样式与交互 |
|
||||
| [循环容器](components/recycling_container.md) | 批量渲染同版式不同数据(仅搭建工具) |
|
||||
|
||||
## 展示类(无交互)
|
||||
|
||||
| 组件 | 用途 |
|
||||
|---|---|
|
||||
| [header](components/header.md) | 卡片标题区:主/副标题、后缀标签、主题色 |
|
||||
| [div](components/div.md) | 普通文本,带前缀图标、字段对 |
|
||||
| [markdown](components/markdown.md) | 富文本,最常用;@人、彩色、链接、列表、表格等 |
|
||||
| [img](components/img.md) | 单图 |
|
||||
| [img_combination](components/img_combination.md) | 多图拼排(双图/三图/宫格) |
|
||||
| [person](components/person.md) | 单个人员头像/姓名 |
|
||||
| [person_list](components/person_list.md) | 多个人员头像/姓名 |
|
||||
| [chart](components/chart.md) | VChart 图表(折线/柱/饼/词云等) |
|
||||
| [table](components/table.md) | 多列数据表(只能放根节点) |
|
||||
| [hr](components/hr.md) | 分割线 |
|
||||
|
||||
## 交互类
|
||||
|
||||
| 组件 | 用途 |
|
||||
|---|---|
|
||||
| [button](components/button.md) | 按钮:回调 / 跳转 / 表单提交 |
|
||||
| [input](components/input.md) | 文本输入框(多嵌在 form 内) |
|
||||
| [overflow](components/overflow.md) | 折叠按钮组,收纳多个操作 |
|
||||
| [select_static](components/select_static.md) | 下拉单选 |
|
||||
| [multi_select_static](components/multi_select_static.md) | 下拉多选 |
|
||||
| [select_person](components/select_person.md) | 人员单选 |
|
||||
| [multi_select_person](components/multi_select_person.md) | 人员多选 |
|
||||
| [date_picker](components/date_picker.md) | 日期选择器 |
|
||||
| [picker_time](components/picker_time.md) | 时间选择器 |
|
||||
| [picker_datetime](components/picker_datetime.md) | 日期时间选择器 |
|
||||
| [select_img](components/select_img.md) | 图片选择(单/多选) |
|
||||
| [checker](components/checker.md) | 勾选器,任务勾选回调 |
|
||||
63
skills/lark-im/references/card/components/button.md
Normal file
63
skills/lark-im/references/card/components/button.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 按钮 `button`
|
||||
|
||||
交互按钮,支持跳转 / 回调 / 表单提交三类行为。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "button",
|
||||
"text": { "tag": "plain_text", "content": "确定" },
|
||||
"type": "primary",
|
||||
"behaviors": [{ "type": "callback", "value": { "action": "ok" } }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `button` |
|
||||
| `text` | 否 | Object | / | `{tag:"plain_text", content}`,≤100 字符 |
|
||||
| `type` | 否 | String | default | 见下方 type 枚举 |
|
||||
| `size` | 否 | String | medium | `tiny` / `small` / `medium` / `large` |
|
||||
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
|
||||
| `behaviors` | 是* | Array | / | 交互行为,见下;表单内按钮不用 behaviors 而用 `form_action_type` |
|
||||
| `icon` | 否 | Object | / | 前缀图标(同 `div.icon`) |
|
||||
| `hover_tips` | 否 | Object | / | PC 端悬浮提示,plain_text |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用 |
|
||||
| `disabled_tips` | 否 | Object | / | 禁用后悬浮提示,plain_text |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_text,title 必填) |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
**type 枚举**:`default`(黑字描边) / `primary`(蓝字描边) / `danger`(红字描边) / `text` / `primary_text` / `danger_text`(无边框) / `primary_filled`(蓝底白字) / `danger_filled`(红底白字) / `laser`(镭射)。
|
||||
|
||||
## 按钮主次(强制)
|
||||
|
||||
- 全卡仅 1 个按钮 → `type: "primary_filled"`,并 `width: "fill"` 撑满成强焦点。
|
||||
- 多个并列按钮 → 第一个(主操作)`primary_filled`,其余一律 `default`,形成「一主多次」层级。
|
||||
- 删除 / 拒绝等危险操作用 `danger` 系(`danger` 或 `danger_filled`)。
|
||||
|
||||
## behaviors(交互行为)
|
||||
|
||||
```json
|
||||
// 1. 服务端回调
|
||||
{ "type": "callback", "value": { "key": "v" } }
|
||||
// 2. 跳转链接(可与 callback 同数组共存)
|
||||
{ "type": "open_url", "default_url": "https://x", "pc_url": "", "ios_url": "", "android_url": "" }
|
||||
```
|
||||
|
||||
表单容器内的按钮 **不用 behaviors**,改用根字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `name` | 是 | 表单内唯一标识 |
|
||||
| `form_action_type` | 是 | `submit`(提交表单)/ `reset`(重置) |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
|
||||
- 2.0 已废弃 `action` 交互模块,按钮直接放 `elements`,用间距控制排列。
|
||||
- 旧式 `url`/`value` 顶层字段是 1.0 写法;2.0 一律用 `behaviors`。
|
||||
- 点击触发 `card.action.trigger`,回传 `action.tag="button"` + `action.value`(即 callback 的 value)。
|
||||
57
skills/lark-im/references/card/components/chart.md
Normal file
57
skills/lark-im/references/card/components/chart.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 图表 `chart`
|
||||
|
||||
基于 VChart 的可视化图表(折线/柱/饼/词云等)。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "chart",
|
||||
"chart_spec": {
|
||||
"type": "line",
|
||||
"title": { "text": "趋势" },
|
||||
"data": { "values": [
|
||||
{ "time": "周一", "value": 8 },
|
||||
{ "time": "周二", "value": 14 }
|
||||
] },
|
||||
"xField": "time",
|
||||
"yField": "value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `chart` |
|
||||
| `chart_spec` | 是 | Object | / | VChart 图表定义,见下 |
|
||||
| `aspect_ratio` | 否 | String | 16:9(PC)/1:1(移动) | `1:1` / `2:1` / `4:3` / `16:9` |
|
||||
| `color_theme` | 否 | String | brand | `brand` / `rainbow` / `complementary` / `converse` / `primary`;chart_spec 里声明了样式则此项无效 |
|
||||
| `height` | 否 | String | auto | `auto`(按宽高比) 或 `[1,999]px`(设固定高则 aspect_ratio 失效) |
|
||||
| `preview` | 否 | Boolean | true | 是否可独立窗口/全屏查看 |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## chart_spec 常用类型
|
||||
|
||||
`chart_spec` 是标准 VChart spec。核心字段:`type`、`data.values`(数据数组)、`xField`/`yField`(轴字段)、`seriesField`(分组)、`title.text`、`legends`。
|
||||
|
||||
| 图表 | type | 关键字段 |
|
||||
|---|---|---|
|
||||
| 折线 | `line` | `xField`, `yField` |
|
||||
| 面积 | `area` | `xField`, `yField` |
|
||||
| 柱状 | `bar` | `xField`, `yField`,分组加 `seriesField` |
|
||||
| 条形(横向) | `bar` | `direction:"horizontal"`,`xField`=值,`yField`=类别 |
|
||||
| 饼/环 | `pie` | `valueField`, `categoryField`,环图加 `innerRadius` |
|
||||
| 散点 | `scatter` | `xField`, `yField` |
|
||||
| 词云 | `wordCloud` | `nameField`, `valueField` |
|
||||
|
||||
完整属性参考 [VChart 官方文档](https://www.visactor.io/vchart/option/barChart)。
|
||||
|
||||
## 易错点
|
||||
|
||||
- 不支持 JavaScript 语法,`chart_spec` 必须是纯 JSON。
|
||||
- 单卡建议 ≤5 个图表。
|
||||
- 移动端不支持部分 VChart 属性(纹理 texture、conical 渐变、grid 词云布局等),用了会在移动端加载失败。
|
||||
- 平台默认给 chart_spec 追加 media query 自适应;要自控可设 `"media": []`。
|
||||
38
skills/lark-im/references/card/components/checker.md
Normal file
38
skills/lark-im/references/card/components/checker.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 勾选器 `checker`
|
||||
|
||||
任务勾选场景的交互组件,支持配置回调响应。仅支持手写 JSON,搭建工具不支持构建。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "checker",
|
||||
"name": "check_1",
|
||||
"checked": false,
|
||||
"text": { "tag": "plain_text", "content": "完成新品上市计划报告" },
|
||||
"behaviors": [{ "type": "callback", "value": { "key": "todo1" } }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `checker` |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `checked` | 否 | Boolean | false | 初始勾选状态 |
|
||||
| `text` | 否 | Object | / | `{tag:"plain_text"\|"lark_md", content, text_size?, text_color?, text_align?}`(text_color 见 `../resource/colors.md`) |
|
||||
| `overall_checkable` | 否 | Boolean | true | 悬浮时整体是否有阴影效果 |
|
||||
| `button_area` | 否 | Object | / | `{pc_display_rule:"always"|"on_hover", buttons:[<=3 个 button]}` |
|
||||
| `checked_style` | 否 | Object | / | `{show_strikethrough, opacity}`,勾选后的内容样式 |
|
||||
| `disabled` / `disabled_tips` | 否 | Boolean/Object | false / 空 | 禁用及禁用提示 |
|
||||
| `hover_tips` | 否 | Object | 空 | 悬浮提示;与 `disabled_tips` 同配时后者生效 |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]`;**未配置时仅本地勾选生效,不触发回调** |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `padding`/`margin` | 否 | String | 0 | [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 form / 交互容器 / column_set / collapsible_panel 内。
|
||||
- 不配置 `behaviors` 时勾选仅前端本地生效,不会触发服务端回调——需要业务侧感知必须显式配置。
|
||||
- 回调:`action.tag="checker"` + `action.checked`(布尔值);form 内则读 `form_value[name]`。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 折叠面板 `collapsible_panel`
|
||||
|
||||
折叠次要内容(备注、长文本),点标题展开/收起。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": false,
|
||||
"header": { "title": { "tag": "plain_text", "content": "面板标题" } },
|
||||
"elements": [{ "tag": "markdown", "content": "折叠的内容" }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `collapsible_panel` |
|
||||
| `header` | 是 | Object | / | 标题区,见下 |
|
||||
| `elements` | 否 | Array | / | 面板内组件;**不能放 `form`** |
|
||||
| `expanded` | 否 | Boolean | false | 是否默认展开 |
|
||||
| `background_color` | 否 | String | 透明 | 面板背景,颜色枚举(见 `../resource/colors.md`) |
|
||||
| `border` | 否 | Object | / | `{ color, corner_radius }` |
|
||||
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
|
||||
| `vertical_spacing`/`horizontal_spacing` | 否 | String | 8px | 间距枚举或 [0,99]px |
|
||||
| `padding` | 否 | String | 0 | 内边距 [0,99]px |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
**header 字段**:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `title` | 否 | `{tag:"plain_text"\|"markdown", content}` |
|
||||
| `background_color` | 否 | 标题区背景,颜色枚举 |
|
||||
| `width` | 否 | `fill` / `auto` / `auto_when_fold`(收起时自适应) |
|
||||
| `vertical_align` | 否 | `top`/`center`/`bottom` |
|
||||
| `icon` | 否 | 图标 `{tag, token, color, size}`(同 `div.icon`,多 `size`) |
|
||||
| `icon_position` | 否 | `left` / `right` / `follow_text` |
|
||||
| `icon_expanded_angle` | 否 | 展开时图标旋转角:`-180`/`-90`/`90`/`180` |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 内部不支持 `form`;容器最多嵌套 5 层。
|
||||
- 仅支持写 JSON,搭建工具不支持。
|
||||
53
skills/lark-im/references/card/components/column_set.md
Normal file
53
skills/lark-im/references/card/components/column_set.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 分栏 `column_set` + `column`
|
||||
|
||||
横向多列布局容器。`column_set` 装若干 `column`,每个 `column` 内再放组件。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "column_set",
|
||||
"flex_mode": "none",
|
||||
"columns": [
|
||||
{ "tag": "column", "width": "weighted", "weight": 1,
|
||||
"elements": [{ "tag": "markdown", "content": "左列" }] },
|
||||
{ "tag": "column", "width": "weighted", "weight": 1,
|
||||
"elements": [{ "tag": "markdown", "content": "右列" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## column_set 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `column_set` |
|
||||
| `columns` | 是 | column[] | / | 列数组,子节点只能是 `column` |
|
||||
| `flex_mode` | 否 | String | none | 窄屏自适应:`none`(按比例压缩) / `stretch`(变上下堆叠) / `flow`(自动换行) / `bisect`(两等分) / `trisect`(三等分) |
|
||||
| `horizontal_spacing` | 否 | String | 8px | `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` |
|
||||
| `horizontal_align` | 否 | String | left | `left` / `center` / `right` |
|
||||
| `background_style` | 否 | String | default | `default` 或颜色枚举/RGBA(见 `../resource/colors.md`);嵌套时上层覆盖下层 |
|
||||
| `action` | 否 | Object | / | 整块点击跳转 `{ multi_url:{url,pc_url,ios_url,android_url} }` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
## column 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `column` |
|
||||
| `elements` | 否 | Element[] | / | 列内组件;**不能放 `form` 和 `table`**,可放 `column_set` |
|
||||
| `width` | 否 | String | auto | 仅 `flex_mode:none` 生效:`auto` / `weighted`(配 weight) / `[16,600]px` |
|
||||
| `weight` | 否 | Number | 1 | `width:weighted` 时的宽度占比,1~5 整数 |
|
||||
| `vertical_align` | 否 | String | top | `top` / `center` / `bottom` |
|
||||
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
|
||||
| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px | 同上间距枚举或 `[0,99]px` |
|
||||
| `padding` | 否 | String | 0 | 内边距 [0,99]px |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `background_style` | 否 | String | default | 同上 |
|
||||
| `action` | 否 | Object | / | 点击列跳转,同 column_set.action |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- **column_set 的直接子节点只能是 `column`**;不能 `column_set → column_set`。二级分栏要走 `column_set → column → column_set`。
|
||||
- column 内可放除 `form` / `table` 外的所有组件。
|
||||
- 最多嵌套 5 层,过深会压缩展示空间。
|
||||
34
skills/lark-im/references/card/components/date_picker.md
Normal file
34
skills/lark-im/references/card/components/date_picker.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 日期选择器 `date_picker`
|
||||
|
||||
提供日期选项的交互组件,默认拥有交互能力(无需显式 `behaviors` 也会回调)。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "date_picker",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"initial_date": "2024-01-01"
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `date_picker` |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `initial_date` | 否 | String | / | 初始值,格式 `yyyy-MM-dd`,会覆盖 `placeholder` |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text;未设 `initial_date` 时必填 |
|
||||
| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+) |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。
|
||||
- 提醒用户注意时区语境(如预定海外酒店用酒店所在地时区);服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。
|
||||
- 回调:`action.tag="date_picker"` + `action.option`(日期字符串,如 `"2025-06-10 +0800"`)+ `action.timezone`;form 内则读 `form_value[name]`。
|
||||
36
skills/lark-im/references/card/components/div.md
Normal file
36
skills/lark-im/references/card/components/div.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 普通文本 `div`
|
||||
|
||||
带样式的文本块,支持前缀图标和 label-value 字段对。**Card 2.0**。富文本用 `markdown` 组件。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "div",
|
||||
"text": { "tag": "plain_text", "content": "示例文本" }
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `div` |
|
||||
| `text` | 否 | Object | / | 文本对象,见下 |
|
||||
| `text.tag` | 是 | String | plain_text | `plain_text` 或 `lark_md`(部分 Markdown,语法见 `markdown.md`) |
|
||||
| `text.content` | 是 | String | / | 文本内容 |
|
||||
| `text.text_size` | 否 | String | normal | `heading-0`~`heading-4` / `normal`(14px) / `notation`(12px) 等;可在 `config.style.text_size` 自定义 pc/mobile 不同字号 |
|
||||
| `text.text_color` | 否 | String | default | 颜色枚举(见 `../resource/colors.md`),仅 `plain_text` 生效 |
|
||||
| `text.text_align` | 否 | String | left | `left` / `center` / `right` |
|
||||
| `text.lines` | 否 | Int | / | 最大显示行数,超出 `...` 省略 |
|
||||
| `icon` | 否 | Object | / | 前缀图标,见下 |
|
||||
| `icon.tag` | 否 | String | / | `standard_icon`(用 `token`+`color`,token 见 `../resource/icons.md`)或 `custom_icon`(用 `img_key`) |
|
||||
| `width` | 否 | String | fill | `fill` / `auto` / `[16,999]px` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符;流式更新时 `text.element_id` 指定文本 |
|
||||
|
||||
> `fields` 字段(多列 label-value):数组,每项 `{ is_short, text:{tag,content} }`,`is_short:true` 可并排。
|
||||
|
||||
## 易错点
|
||||
|
||||
- `text_color` 只在 `text.tag` 为 `plain_text` 时生效;`lark_md` 用内联 `<font color=red>` 着色。
|
||||
51
skills/lark-im/references/card/components/form.md
Normal file
51
skills/lark-im/references/card/components/form.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 表单容器 `form`
|
||||
|
||||
批量录入表单项后一次提交:用户在前端填写多个表单项,点击提交按钮后将所有值打包一次性回调到服务端。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "form",
|
||||
"name": "form_1",
|
||||
"elements": [
|
||||
{ "tag": "input", "name": "reason", "required": true },
|
||||
{
|
||||
"tag": "button",
|
||||
"text": { "tag": "plain_text", "content": "提交" },
|
||||
"type": "primary",
|
||||
"form_action_type": "submit",
|
||||
"name": "Button_submit"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `form` |
|
||||
| `name` | 是 | String | / | 表单容器唯一标识,卡片内全局唯一,用于识别提交数据归属 |
|
||||
| `elements` | 是 | Element[] | [] | 子节点,支持除 `table` 和 `form` 外的所有组件 |
|
||||
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
|
||||
| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px/12px | 间距枚举 `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` |
|
||||
| `horizontal_align` | 否 | String | left | `left`/`center`/`right` |
|
||||
| `vertical_align` | 否 | String | top | `top`/`center`/`bottom` |
|
||||
| `padding`/`margin` | 否 | String | 0 | [-99,99]px,支持单值/双值/四值写法 |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
### 子组件内嵌字段(交互组件嵌在 form 内时生效)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `name` | 是 | 表单内组件唯一标识,卡片全局唯一,否则提交失败 |
|
||||
| `required` | 否 | 是否必填;为 true 且未填时点提交会本地拦截,不发起回调 |
|
||||
| `form_action_type` | 是(按钮) | `submit`(提交)/ `reset`(重置初始值);表单内按钮**不用** `behaviors` |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- `form` 不支持嵌套 `table` 和 `form`;且 `form` 本身只能放卡片根节点下,不能被其他组件嵌套。
|
||||
- form 内所有交互组件的 `name` 必须填且全局唯一,否则提交失败。
|
||||
- 表单内必须包含一个 `form_action_type: submit` 的按钮。
|
||||
- 回调来源:`card.action.trigger` 中 `action.tag="button"` + `action.form_value`(按组件 `name` 映射各字段值)。
|
||||
34
skills/lark-im/references/card/components/header.md
Normal file
34
skills/lark-im/references/card/components/header.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 标题 `header`
|
||||
|
||||
卡片顶部标题区(主/副标题、后缀标签、图标、主题色)。**Card 2.0**。挂在卡片根的 `header` 键下,不在 `body.elements` 内,单卡仅一个。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"template": "blue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `title` | 是 | Object | 主标题,`{tag:"plain_text"\|"lark_md", content}`,最多 4 行 |
|
||||
| `subtitle` | 否 | Object | 副标题,同 title,最多 1 行;只配副标题会按主标题展示 |
|
||||
| `template` | 否 | String | 主题色枚举,见下;默认 `default` |
|
||||
| `text_tag_list` | 否 | Array | 后缀标签,最多 3 个,每项 `{tag:"text_tag", text:{tag:"plain_text",content}, color}` |
|
||||
| `i18n_text_tag_list` | 否 | Object | 多语言后缀标签;与 `text_tag_list` 二选一,同配以多语言为准 |
|
||||
| `icon` | 否 | Object | 前缀图标(同 `div.icon`) |
|
||||
| `padding` | 否 | String | 内边距,默认 12px,[0,99]px |
|
||||
|
||||
**template 枚举**(13 色):`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey` / `default`。
|
||||
|
||||
**标签 color 枚举**:`neutral`/`blue`/`turquoise`/`lime`/`orange`/`violet`/`indigo`/`wathet`/`green`/`yellow`/`red`/`purple`/`carmine`。深浅档位及 RGBA 见 `../resource/colors.md`。
|
||||
|
||||
## 选色建议
|
||||
|
||||
按场景选 template 颜色见 `../lark-im-card-style.md` 意图表。常见语义:green=成功/完成,orange=警告,red=错误/危险,grey=失效/归档,blue=通用信息。
|
||||
17
skills/lark-im/references/card/components/hr.md
Normal file
17
skills/lark-im/references/card/components/hr.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 分割线 `hr`
|
||||
|
||||
分隔卡片内容的水平线。**Card 2.0**(1.0 同名 `hr`)。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{ "tag": "hr" }
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `hr` |
|
||||
| `margin` | 否 | String | 0 | 外边距,范围 [-99,99]px,如 `"8px 0"` |
|
||||
| `element_id` | 否 | String | / | 组件唯一标识,字母开头、≤20 字符 |
|
||||
34
skills/lark-im/references/card/components/img.md
Normal file
34
skills/lark-im/references/card/components/img.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 图片 `img`
|
||||
|
||||
展示图片。需先调上传图片接口拿 `img_key`。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "img",
|
||||
"img_key": "img_v3_xxx",
|
||||
"alt": { "tag": "plain_text", "content": "" }
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `img` |
|
||||
| `img_key` | 是 | String | / | 图片 key,上传图片接口获取 |
|
||||
| `alt` | 是 | Object | / | hover 说明,`{tag:"plain_text", content:""}`,不需要传空 |
|
||||
| `title` | 否 | Object | / | 图片标题,plain_text 对象 |
|
||||
| `scale_type` | 否 | String | crop_center | `crop_center` / `crop_top` / `fit_horizontal`(不裁剪) |
|
||||
| `size` | 否 | String | / | 仅 `crop_*` 生效:`stretch`/`large`(160)/`medium`(80)/`small`(40)/`tiny`(16),或 `"100px 100px"` |
|
||||
| `corner_radius` | 否 | String | / | 圆角,`[0,∞]px` 或 `[0,100]%` |
|
||||
| `transparent` | 否 | Boolean | false | 是否透明底 |
|
||||
| `preview` | 否 | Boolean | true | 点击是否放大;配 `card_link` 跳转时设 false |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 通栏效果:2.0 不再支持 `size: stretch_without_padding`,改用负 `margin`(如 `"4px -12px"`)。
|
||||
- 上传规范:≤10M、尺寸 ≤1500×3000px、高:宽 ≤16:9。
|
||||
30
skills/lark-im/references/card/components/img_combination.md
Normal file
30
skills/lark-im/references/card/components/img_combination.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 多图混排 `img_combination`
|
||||
|
||||
多张图片按预设版式拼排。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "img_combination",
|
||||
"combination_mode": "double",
|
||||
"img_list": [{ "img_key": "img_v3_a" }, { "img_key": "img_v3_b" }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `img_combination` |
|
||||
| `combination_mode` | 是 | String | / | `double`(≤2) / `triple`(≤3) / `bisect`(双列,≤6) / `trisect`(三列,≤9) |
|
||||
| `img_list` | 是 | Array | / | 每项 `{ img_key }`,顺序即排列顺序 |
|
||||
| `combination_transparent` | 否 | Boolean | false | 是否透明底 |
|
||||
| `corner_radius` | 否 | String | / | 圆角,`[0,∞]px` 或 `[0,100]%` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 图片数超过 mode 上限:只显示靠前的,其余丢弃;不足则留空白。
|
||||
- 上传规范:≤10M、≤1500×3000px、高:宽 ≤16:9。
|
||||
43
skills/lark-im/references/card/components/input.md
Normal file
43
skills/lark-im/references/card/components/input.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 输入框 `input`
|
||||
|
||||
收集用户文本输入。常嵌在 `form` 内配合提交按钮使用。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "input",
|
||||
"name": "comment",
|
||||
"placeholder": { "tag": "plain_text", "content": "请输入" },
|
||||
"label": { "tag": "plain_text", "content": "备注:" }
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `input` |
|
||||
| `name` | 否* | String | / | 唯一标识;**在 form 内必填且全局唯一**,用于识别提交数据 |
|
||||
| `required` | 否 | Boolean | false | 是否必填(仅 form 内生效) |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text,≤100 字符 |
|
||||
| `default_value` | 否 | String | / | 预填内容 |
|
||||
| `label` | 否 | Object | / | 描述文本,plain_text |
|
||||
| `label_position` | 否 | String | top | `top` / `left`(窄屏自动转 top) |
|
||||
| `input_type` | 否 | String | text | `text` / `multiline_text`(多行,回调含 `\n`) / `password` |
|
||||
| `rows` | 否 | Number | 5 | 多行时默认行数 |
|
||||
| `auto_resize` | 否 | Boolean | false | 多行时高度自适应(仅 PC) |
|
||||
| `max_rows` | 否 | Number | / | `auto_resize` 时最大行数 |
|
||||
| `max_length` | 否 | Number | 1000 | 最大字符数,[1,1000] |
|
||||
| `show_icon` | 否 | Boolean | true | password 时是否显示前缀图标 |
|
||||
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用(配 `disabled_tips` plain_text) |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
|
||||
- 在 form 内为**异步提交**:用户填完点提交按钮才一次性回调全部表单数据。
|
||||
- 回调里 `action.tag="input"` + `action.input_value`(用户输入值);form 提交则值在 `form_value` 内。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 交互容器 `interactive_container`
|
||||
|
||||
整块可点击区域,统一定义内嵌内容的样式和交互(callback/open_url),适合卡片内的列表项、可点击卡片块。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "interactive_container",
|
||||
"width": "fill",
|
||||
"has_border": true,
|
||||
"border_color": "grey",
|
||||
"corner_radius": "8px",
|
||||
"padding": "4px 12px 4px 12px",
|
||||
"behaviors": [{ "type": "callback", "value": { "key": "value" } }],
|
||||
"elements": [
|
||||
{ "tag": "markdown", "content": "帮我生成一篇产品方案的框架" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `interactive_container` |
|
||||
| `elements` | 是 | Element[] | [] | 子节点,支持除 `form`/`table` 外的所有组件 |
|
||||
| `behaviors` | 是 | Array | / | 点击整容器的交互:`callback`(回传)/ `open_url`(跳转),可同数组共存 |
|
||||
| `width` | 否 | String | fill | `fill`/`auto`/`[16,999]px` |
|
||||
| `height` | 否 | String | auto | `auto`/`[10,999]px` |
|
||||
| `direction` | 否 | String | vertical | `vertical`/`horizontal` |
|
||||
| `horizontal_align`/`vertical_align` | 否 | String | left/top | 对齐方式 |
|
||||
| `background_style` | 否 | String | default | `default`/`laser`/颜色枚举/RGBA(见 `../resource/colors.md`) |
|
||||
| `has_border` | 否 | Boolean | false | 是否展示 1px 边框 |
|
||||
| `border_color` | 否 | String | grey | `has_border` 为 true 时生效 |
|
||||
| `corner_radius` | 否 | String | 0px | `[0,∞]px` 或 `[0,100]%` |
|
||||
| `padding`/`margin` | 否 | String | 4px,12px / 0px | 同间距写法 |
|
||||
| `disabled` / `disabled_tips` | 否 | Boolean/Object | false / 空 | 禁用整容器及禁用提示 |
|
||||
| `hover_tips` | 否 | Object | 空 | PC 端悬浮提示 |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套除 `form`/`table` 外的所有组件,包括嵌套自身(列表项常见写法)。
|
||||
- 若容器内有交互组件(如内部 `button`),优先响应该子组件的交互,容器级 `behaviors` 不会触发。
|
||||
- 回调来源:`card.action.trigger`,`action.tag` 取决于内部触发的具体组件;容器本身被点击时 `action.value` 即容器 `behaviors.value`。
|
||||
56
skills/lark-im/references/card/components/markdown.md
Normal file
56
skills/lark-im/references/card/components/markdown.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 富文本 `markdown`
|
||||
|
||||
支持 Markdown + 部分 HTML 的富文本。最常用的内容组件。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "**标题**\n正文,<font color='red'>红字</font>,[链接](https://x)"
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `markdown` |
|
||||
| `content` | 是 | String | / | Markdown 文本;JSON 里用 `\n` 换行 |
|
||||
| `text_size` | 否 | String | normal | `heading-0`~`heading-4` / `normal`(14px) / `notation`(12px) 等;可在 `config.style.text_size` 自定义 pc/mobile 字号 |
|
||||
| `text_align` | 否 | String | left | `left` / `center` / `right` |
|
||||
| `icon` | 否 | Object | / | 前缀图标(同 `div.icon`) |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## 常用语法
|
||||
|
||||
| 效果 | 语法 |
|
||||
|---|---|
|
||||
| 粗 / 斜 / 删除线 | `**粗**`、`*斜*`、`~~删~~`(前后留空格更稳) |
|
||||
| 换行 | JSON 内 `\n`;或 `<br>` |
|
||||
| 文字链接 | `[文字](https://x)`(必须带 http/https) |
|
||||
| 带图标链接 | `<link icon='chat_outlined' …>文案</link>`(icon token 见 `../resource/icons.md`) |
|
||||
| 彩色文本 | `<font color='red'>红字</font>`(color 枚举见 `../resource/colors.md`;链接文本不可着色) |
|
||||
| 标签 | `<text_tag color='blue'>标签</text_tag>`(color:neutral/blue/turquoise/lime/orange/violet/indigo/wathet/green/yellow/red/purple/carmine) |
|
||||
| @ 人 | `<at id=ou_xxx></at>`、`<at id=all></at>`、`<at ids=id1,id2></at>` |
|
||||
| @所有人 | `<at id=all></at>`(需群主开权限,否则发送失败) |
|
||||
| 人员卡片 | `<person id='ou_xxx' show_name=true show_avatar=true style='normal'></person>` |
|
||||
| 数字角标 | `<number_tag>1</number_tag>`(0-99,可加 background_color/font_color/url) |
|
||||
| 国际化时间 | `<local_datetime millisecond='' format_type='date_num'></local_datetime>` |
|
||||
| 标题 | `# 一级` ~ `###### 六级`(大标题显丑,正文优先用加粗,见易错点) |
|
||||
| 列表 | `- 项`(无序)/ `1. 项`(有序),4 空格一层缩进 |
|
||||
| 引用 | `> 引用文字` |
|
||||
| 行内/块代码 | `` `code` `` / ```` ```go ... ``` ````(可指定语言) |
|
||||
| 分割线 | `<hr>` 或 `---`(需单独一行) |
|
||||
| 图片 | `` |
|
||||
| 表格 | 标准 MD 表格;除标题最多 5 行(超出分页),单组件 ≤4 表 |
|
||||
| 飞书表情 | `:DONE:`、`:OK:` |
|
||||
|
||||
## 易错点
|
||||
|
||||
- **慎用大标题**:`#` / `##` / `###` 一~三级标题字号过大、显丑,正文里一律用 `**加粗**` 替代来突出重点。**唯一例外**是「指标卡」里用 `##` 放大数值(见 `../lark-im-card-style.md` 视觉规范)。
|
||||
- **少用 `markdown` 的 `margin`**:间距优先交给父容器的 `vertical_spacing` / `padding`,多数情况置 `0px`;仅精细缩进时设非零值(见 `../lark-im-card-style.md` 间距纪律)。
|
||||
- 2.0 不再支持旧的 `[xx]($urlVal)` + `href` 差异化跳转语法,改用 `<link>`。
|
||||
- 要展示 Markdown 特殊字符(`* ~ > < [ ] ( ) # : _` 等)须 HTML 转义,如 `<`→`<`、`*`→`*`。
|
||||
- `content` 里的引号注意与 JSON 转义;属性值用单引号可减少冲突。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 人员选择-多选 `multi_select_person`
|
||||
|
||||
从候选人员中多选。**Card 2.0**。字段与 `select_person` 基本一致,差别在多选默认值。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "multi_select_person",
|
||||
"name": "reviewers",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"options": [
|
||||
{ "value": "ou_xxx" },
|
||||
{ "value": "ou_yyy" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `multi_select_person` |
|
||||
| `options` | 否 | Array | / | 候选人 `{value: open_id}`;为空或全无效时候选项为会话全体成员 |
|
||||
| `selected_values` | 否 | String[] | / | 默认选中的 open_id 数组 |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text |
|
||||
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用 |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
|
||||
- `options[].value` 只接受 **open_id**;默认选中用 `selected_values`(数组)。
|
||||
- 回调返回选中的多个 open_id。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 下拉多选 `multi_select_static`
|
||||
|
||||
下拉菜单多选。**Card 2.0**。字段与 `select_static` 基本一致,差别在多选默认值。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "multi_select_static",
|
||||
"name": "tags",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"options": [
|
||||
{ "text": { "tag": "plain_text", "content": "选项1" }, "value": "1" },
|
||||
{ "text": { "tag": "plain_text", "content": "选项2" }, "value": "2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `multi_select_static` |
|
||||
| `options` | 否 | Array | / | 选项 `{text:{plain_text}, value, icon?}`,`value` 不可重复 |
|
||||
| `selected_values` | 否 | String[] | / | 默认选中的 value 数组 |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text |
|
||||
| `width` | 否 | String | default | `default`(带框固定282px) / `fill` / `[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用 |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
|
||||
- 选项 `value` 唯一;默认选中用 `selected_values`(数组)而非单选的 `initial_*`。
|
||||
- 回调返回选中的多个值。
|
||||
36
skills/lark-im/references/card/components/overflow.md
Normal file
36
skills/lark-im/references/card/components/overflow.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 折叠按钮组 `overflow`
|
||||
|
||||
折叠多个选项按钮,点击展开。适用于操作较多的场景。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "overflow",
|
||||
"options": [
|
||||
{ "text": { "tag": "plain_text", "content": "选项A" }, "value": "a" },
|
||||
{ "text": { "tag": "plain_text", "content": "选项B" }, "value": "b" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `overflow` |
|
||||
| `options` | 是 | Array | / | 选项按钮,见下 |
|
||||
| `options[].text` | 否 | Object | / | `{tag:"plain_text", content}`,≤100 字符 |
|
||||
| `options[].value` | 否 | String | / | 点击回传值,用于区分点了哪个选项(回调 `action.option`) |
|
||||
| `options[].multi_url` | 否 | Object | / | 跳转链接 `{url, pc_url, ios_url, android_url}` |
|
||||
| `behaviors` | 否 | Array | / | 额外回传:`[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_text) |
|
||||
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 form / collapsible_panel / 循环容器 / interactive_container / column_set 内。
|
||||
- 多按钮时务必给每个 `options[].value`,否则回调无法区分点了哪个。
|
||||
- 点击触发 `card.action.trigger`,回传 `action.tag = "overflow"` + `action.option`。
|
||||
30
skills/lark-im/references/card/components/person.md
Normal file
30
skills/lark-im/references/card/components/person.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 人员 `person`
|
||||
|
||||
展示单个用户的头像/姓名,点击可看名片。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "person",
|
||||
"user_id": "ou_xxx",
|
||||
"show_name": true
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `person` |
|
||||
| `user_id` | 是 | String | / | 人员 ID,支持 open_id / union_id / user_id |
|
||||
| `size` | 否 | String | medium | `extra_small` / `small` / `medium` / `large` |
|
||||
| `show_avatar` | 否 | Boolean | true | 是否显示头像 |
|
||||
| `show_name` | 否 | Boolean | false | 是否显示姓名 |
|
||||
| `style` | 否 | String | normal | `normal` / `capsule`(胶囊) |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 发卡应用需有访问用户 ID 的权限,否则人员信息无法展示。
|
||||
31
skills/lark-im/references/card/components/person_list.md
Normal file
31
skills/lark-im/references/card/components/person_list.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 人员列表 `person_list`
|
||||
|
||||
展示多个用户的头像/姓名。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "person_list",
|
||||
"persons": [{ "id": "ou_xxx" }, { "id": "ou_yyy" }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `person_list` |
|
||||
| `persons` | 是 | Array | / | 每项 `{ id }`,id 支持 open_id / union_id / user_id |
|
||||
| `show_name` | 否 | Boolean | true | 是否显示姓名;关掉且多人时为"葫芦串"叠头像样式 |
|
||||
| `show_avatar` | 否 | Boolean | false | 是否显示头像 |
|
||||
| `size` | 否 | String | medium | `extra_small` / `small` / `medium` / `large` |
|
||||
| `lines` | 否 | Int | / | 最大行数,不可为 0 |
|
||||
| `drop_invalid_user_id` | 否 | Boolean | false | true 忽略无效 ID;false 则有无效 ID 时报错 |
|
||||
| `icon` / `ud_icon` | 否 | Object | / | 前缀图标(同 `div.icon`);两者同设以 `icon` 为准 |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 发卡应用需有访问用户 ID 的权限,否则无法展示人员信息。
|
||||
34
skills/lark-im/references/card/components/picker_datetime.md
Normal file
34
skills/lark-im/references/card/components/picker_datetime.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 日期时间选择器 `picker_datetime`
|
||||
|
||||
提供日期+时间选项的交互组件,默认拥有交互能力。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "picker_datetime",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"initial_datetime": "2024-01-01 08:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `picker_datetime` |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `initial_datetime` | 否 | String | / | 初始值,格式 `yyyy-MM-dd HH:mm`,会覆盖 `placeholder` |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text;未设 `initial_datetime` 时必填 |
|
||||
| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+) |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。
|
||||
- 提醒用户注意时区语境;服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。
|
||||
- 回调:`action.tag="picker_datetime"` + `action.option`(如 `"2025-06-10 19:19 +0800"`)+ `action.timezone`;form 内则读 `form_value[name]`。
|
||||
34
skills/lark-im/references/card/components/picker_time.md
Normal file
34
skills/lark-im/references/card/components/picker_time.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 时间选择器 `picker_time`
|
||||
|
||||
提供时间选项的交互组件,默认拥有交互能力。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "picker_time",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"initial_time": "09:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `picker_time` |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `initial_time` | 否 | String | / | 初始值,格式 `HH:mm`,会覆盖 `placeholder` |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text;未设 `initial_time` 时必填 |
|
||||
| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+) |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。
|
||||
- 提醒用户注意时区语境;服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。
|
||||
- 回调:`action.tag="picker_time"` + `action.option`(时间字符串,如 `"05:05 +0800"`)+ `action.timezone`;form 内则读 `form_value[name]`。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 循环容器(搭建工具专属,无 JSON tag)
|
||||
|
||||
批量渲染同版式不同数据的列表(如商品列表、推荐列表)。**仅支持在飞书卡片搭建工具中可视化构建,不支持手写卡片 JSON 代码实现**——因此没有 `tag` 字段可直接编排。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 在[卡片搭建工具](https://open.feishu.cn/cardkit)中添加循环容器组件,绑定一个对象数组变量。
|
||||
2. 在容器内添加任意展示/交互/分栏组件,并将其字段绑定到对象数组的子变量。
|
||||
3. 发布卡片模板后,发送时通过 `template_variable` 传入实际数据数组,数组每个元素对应一条循环项。
|
||||
|
||||
## 发送示例(模板 + 变量赋值)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "template",
|
||||
"data": {
|
||||
"template_id": "AAqi6xJ8rabcd",
|
||||
"template_version_name": "1.0.0",
|
||||
"template_variable": {
|
||||
"looping": [
|
||||
{ "title": "**和风陶韵**", "description": "...", "image": { "img_key": "img_v3_xxx" } },
|
||||
{ "title": "**匠心之作**", "description": "...", "image": { "img_key": "img_v3_yyy" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将以上 JSON 压缩转义后作为 `messages.create` 的 `content`,`msg_type` 为 `interactive`。
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 不支持再嵌套循环容器(对象数组变量不支持嵌套对象数组类型)。
|
||||
- 数组元素个数即渲染条数,可直接控制列表长度。
|
||||
- 若循环容器内嵌表单容器的交互组件(如 input),交互组件的 `name`(表单项标识)必须绑定到不重复的子变量,否则预览/发送报错。
|
||||
42
skills/lark-im/references/card/components/select_img.md
Normal file
42
skills/lark-im/references/card/components/select_img.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 多图选择 `select_img`
|
||||
|
||||
以图片为选项的交互组件,支持单选/多选(如商品图、模板图、AI 生成图)。仅支持手写 JSON,搭建工具不支持。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "select_img",
|
||||
"name": "select_img_1",
|
||||
"layout": "bisect",
|
||||
"aspect_ratio": "16:9",
|
||||
"options": [
|
||||
{ "img_key": "img_v2_xxx", "value": "picture1" },
|
||||
{ "img_key": "img_v2_yyy", "value": "picture2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `select_img` |
|
||||
| `options` | 是 | Array | / | 选项,每项 `{img_key, value, disabled?, disabled_tips?, hover_tips?}` |
|
||||
| `multi_select` | 否 | Boolean | false | 多选仅支持异步提交,**必须**内嵌在 form 中,否则报错 |
|
||||
| `layout` | 否 | String | bisect | 图片布局:`stretch`(撑满)/`bisect`(二等分)/`trisect`(三等分) |
|
||||
| `aspect_ratio` | 否 | String | 16:9 | `1:1`/`16:9`/`4:3` |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `can_preview` | 否 | Boolean | true | 点击图片是否弹窗放大(仅 form 内生效) |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用整组件 |
|
||||
| `value` | 否 | String/Object | / | 自定义回传参数 |
|
||||
| `behaviors` | 是 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在根节点 / column_set / form / 交互容器(搭建工具暂不支持嵌套交互容器)。
|
||||
- **不在 form 内**:仅支持单选,点击立即提交触发回调,不支持多选/异步提交。
|
||||
- **在 form 内**:支持单选/多选 + 异步提交(随表单一起提交)。
|
||||
- 回调(非 form):`action.tag="select_img"` + `action.options`(单选时仍是该字段);form 内则读 `form_value[name]`。
|
||||
39
skills/lark-im/references/card/components/select_person.md
Normal file
39
skills/lark-im/references/card/components/select_person.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 人员选择-单选 `select_person`
|
||||
|
||||
从候选人员中单选一人。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "select_person",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"options": [
|
||||
{ "value": "ou_xxx" },
|
||||
{ "value": "ou_yyy" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `select_person` |
|
||||
| `options` | 否 | Array | / | 候选人,每项 `{value: open_id}`;**为空或全无效时,候选项为会话内全体成员** |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text |
|
||||
| `initial_option` | 否 | String | / | 初始选中的 open_id,须在 options 内 |
|
||||
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用 |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
|
||||
- `options[].value` 只接受 **open_id**。
|
||||
- 回调 `action.tag="select_person"` + `action.option`(选中人的 open_id)。
|
||||
43
skills/lark-im/references/card/components/select_static.md
Normal file
43
skills/lark-im/references/card/components/select_static.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 下拉单选 `select_static`
|
||||
|
||||
下拉菜单单选。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "select_static",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"options": [
|
||||
{ "text": { "tag": "plain_text", "content": "选项1" }, "value": "1" },
|
||||
{ "text": { "tag": "plain_text", "content": "选项2" }, "value": "2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `select_static` |
|
||||
| `options` | 否 | Array | / | 选项,见下 |
|
||||
| `options[].text` | 是 | Object | / | 选项名,plain_text |
|
||||
| `options[].value` | 是 | String | / | 选项回调值,**同组件内不可重复** |
|
||||
| `options[].icon` | 否 | Object | / | 选项前缀图标(同 `div.icon`) |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text |
|
||||
| `initial_option` | 否 | String | / | 初始选中内容(覆盖 placeholder 和 initial_index) |
|
||||
| `initial_index` | 否 | Int | / | 初始选中序号,0=不选,1=第一个 |
|
||||
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用 |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
|
||||
- 选项 `value` 必须唯一,否则交互异常、服务端无法区分选了哪个。
|
||||
- 回调 `action.tag="select_static"` + `action.option`(选中项的 value)。
|
||||
53
skills/lark-im/references/card/components/table.md
Normal file
53
skills/lark-im/references/card/components/table.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 表格 `table`
|
||||
|
||||
多列数据表,支持文本/数字/选项/人员/日期等列类型。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "table",
|
||||
"columns": [
|
||||
{ "name": "city", "display_name": "城市", "data_type": "text" },
|
||||
{ "name": "qty", "display_name": "数量", "data_type": "number" }
|
||||
],
|
||||
"rows": [
|
||||
{ "city": "北京", "qty": 12 },
|
||||
{ "city": "上海", "qty": 8 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `table` |
|
||||
| `columns` | 是 | column[] | / | 列定义,≤50 列,见下 |
|
||||
| `rows` | 是 | Object[] | / | 行数据,按 `列name: 值` 填充 |
|
||||
| `page_size` | 否 | Number | 5 | 每页行数,[1,10] |
|
||||
| `row_height` | 否 | String | low | `low`/`middle`/`high`/`auto`/`[32,124]px` |
|
||||
| `row_max_height` | 否 | String | 124px | `row_height:auto` 时最大行高 [32,999]px |
|
||||
| `freeze_first_column` | 否 | Boolean | false | 冻结首列 |
|
||||
| `header_style` | 否 | Object | / | 表头样式:`{text_align, text_size, background_style:grey\|none, text_color, bold, lines}` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
**column 字段**:`name`(必填,键名) / `display_name`(表头名) / `data_type`(见下) / `width`(`auto`/`[80,600]px`/`%`) / `horizontal_align` / `vertical_align`;`number` 列可加 `format:{precision, symbol, separator}`;`date` 列可加 `date_format`(如 `YYYY/MM/DD`)。
|
||||
|
||||
**data_type 与行值结构**:
|
||||
|
||||
| data_type | 行值 |
|
||||
|---|---|
|
||||
| `text` | `"飞书"` |
|
||||
| `lark_md` | `"[链接](https://x)"` |
|
||||
| `number` | `168.23` |
|
||||
| `options` | `[{text:"S2", color:"blue"}]`(颜色枚举见 `../resource/colors.md`,文本勿过长) |
|
||||
| `persons` | `"ou_xxx"` 或 `["ou_a","ou_b"]` |
|
||||
| `date` | `1699341315000`(毫秒时间戳,按本地时区显示) |
|
||||
| `markdown` | `""` 完整 Markdown |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- **table 只能放卡片根 `body.elements`**:不能被任何容器嵌套,自身也不能嵌别的组件。
|
||||
- 单卡最多 5 个 table(多语言每语言 5 个)。
|
||||
- `rows` 的键必须与 `columns[].name` 对应。
|
||||
180
skills/lark-im/references/card/lark-im-card-create.md
Normal file
180
skills/lark-im/references/card/lark-im-card-create.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 发送 Interactive 卡片工作流
|
||||
|
||||
用户需要发送一张飞书互动卡片时,遵循本工作流。每次都必须严格按步骤执行。
|
||||
|
||||
---
|
||||
|
||||
## 入口分支:文字 / 图片 / 图片+文字组合
|
||||
|
||||
判断用户输入类型:
|
||||
|
||||
- **纯文字诉求**(无图片)→ 跳到 Step 1「文字诉求路径」。
|
||||
- **纯图片**(截图 / 设计稿,无额外文字说明内容)→ 走「以图片为输入」路径,图片既是内容源也是风格源。
|
||||
- **图片 + 文字组合** → 走「以图片为输入」路径,但**图片仅当样式/布局参考,内容来源以文字为准**(见第 5 点)。
|
||||
|
||||
### 以图片为输入时的处理
|
||||
|
||||
1. **分析图片**:从图片中提取视觉风格信息——
|
||||
- 配色方案(色环定位)、间距节奏、层级关系、分组方式、组件类型
|
||||
- 图片类型(见第 2 点)
|
||||
|
||||
2. **判断图片类型**,决定保真策略:
|
||||
|
||||
| 图片类型 | 判断依据 | 构造策略 |
|
||||
|---|---|---|
|
||||
| **飞书卡片截图** | 能识别出 header / body / components 等飞书卡片结构特征 | **高保真复刻**:将截图中每一块视觉结构映射到相近的卡片 2.0 组件;复刻后仍需过 P0–P7 硬 Gate |
|
||||
| **其它设计稿 / 海报 / 网页 UI** | 无飞书卡片结构特征 | **风格萃取 + 按原则重构**:提取配色、间距、层级关系等风格 token;布局按 P0–P7 原则重构(**不像素级仿制**),产出说明偏差 |
|
||||
|
||||
3. **确定内容来源**:
|
||||
- **纯图片**:从图片提取内容/信息点(文字、字段、操作)作为诉求(喂给 P0)
|
||||
- **图片 + 文字组合**:**以文字/文档为内容来源**,图片仅提供样式和布局参考。将文字内容按图片风格组织进卡片
|
||||
|
||||
4. **冲突处理**:当图片样式与 P0–P7 或卡片组件能力冲突时——
|
||||
- 飞书卡片截图:在组件能力允许范围内**尽量保真**,冲突处微调并告知用户偏差原因
|
||||
- 其它设计稿 / 海报 / UI:**以 P0–P7 为准**,图片仅当风格参考,冲突时不硬搬
|
||||
|
||||
5. 分析明确后,向用户简要说明你的**类型判断结论 + 保真策略 + 内容来源方案**。然后进入 Step 2 加载组件文档,进入构造。
|
||||
|
||||
---
|
||||
|
||||
## Step 1(文字诉求路径):分析意图,输出设计方案
|
||||
|
||||
**目标**:在动手写 JSON 之前,先明确所有决策并告知用户。Step 2 的文档加载量取决于这里的组件列表,所以要尽量在这一步想清楚。
|
||||
|
||||
分析以下内容并向用户简要说明:
|
||||
|
||||
1. **版本**:Card 2.0 支持的组件更丰富,**推荐使用 Card 2.0**;仅当用户明确要求 1.0 时才用 1.0。
|
||||
2. **组件组合**:在 `lark-im-card-style.md` 「意图 → 组件组合」表里匹配最接近的意图行,参考推荐组件组合和该行的 `header.template` 颜色(部分行为"无 header")。推荐组合仅供参考,**最终选型以符合用户意图为准**;使用 Card 2.0 时,可同时参考 `card-2.0-schema.md` 中的组件概述来补充或调整组件选择。
|
||||
3. **交互类型**(若有):是否含会回调服务端的交互组件,以及是否有纯跳转(open_url)。回调分两类:① `select` / `multi_select` / `input` / `picker` / `overflow` 操作即默认回调;② `button` / `checker` / `interactive_container` 需显式配置 `behaviors`;`form` 提交统一回调。细则见 Step 5。
|
||||
4. **宽度模式**:`compact`(400px) 适合通知/祝福/轻提醒(内容精简、单焦点);`default`(≤600px) 适合大多数场景;`fill`(撑满) 适合数据看板、含 `table` 的宽表格。默认 `default`,有明确理由才偏离。
|
||||
|
||||
> 输出示例:"Card 2.0,header green,`default`,组件:`column_set` / `column` / `markdown`,无交互。"
|
||||
|
||||
---
|
||||
|
||||
## Step 2:按需加载组件文档
|
||||
|
||||
> ⚠️ **仅 Card 2.0 适用**:`card-2.0-schema.md`、`components/` 明细都是 2.0 结构。若 Step 1 定为 **Card 1.0**(含 Step 4 降级场景),这些**不可参考**,跳过本步,直接按 1.0 结构构造。
|
||||
|
||||
**目标**:读组件明细 + 「好看的标准」,不全量加载。
|
||||
|
||||
> 组件列表来源:**文字路径** = Step 1 的设计方案;**图片路径** = 入口分支图片分析阶段确定的组件列表。
|
||||
|
||||
1. 阅读 `card-2.0-schema.md` —— 同时满足两个目的:① 了解组件概述,辅助组件选型;② 找到各组件的明细文档路由链接。**仅读一次,不重复加载。**
|
||||
2. 按路由逐个读取 `components/<tag>.md`(如 `components/column_set.md`、`components/button.md`)
|
||||
3. 阅读 `lark-im-card-style.md` 开头的「**好看的标准(P0–P7)**」和「视觉规范」——这是 Step 3 构造和自检的裁判基准,**构造前先内化**。
|
||||
|
||||
---
|
||||
|
||||
## Step 3:构造卡片 JSON
|
||||
|
||||
按 Step 2 中对应版本的根结构骨架构造卡片,组件选型遵循 Step 1(或图片分析阶段)的设计方案。
|
||||
|
||||
- Card 2.0 必须有 `"schema": "2.0"`,否则卡片不渲染
|
||||
- `form` 容器内按钮用 `form_action_type: "submit"`,不写 `behaviors`
|
||||
- `column_set` 的子节点只能是 `column`,不能直接放其他组件
|
||||
- `table` **只能放 body 根节点**,不能嵌套进 `column_set` / `interactive_container` 等容器
|
||||
- `collapsible_panel` 内**不能包含 `form`**;`interactive_container` 内**不能包含 `form`/`table`**
|
||||
|
||||
### 发送前硬 Gate(按 P0–P7 自检,不过不许进 Step 4)
|
||||
|
||||
构造完成后,逐条用 `lark-im-card-style.md` 的「好看的标准」做**结构化自检**。**P0 + P1–P3 是阻断项,任一不过必须回到本步修正后重判**,不得带病发送。
|
||||
|
||||
**阻断项(必须全过):**
|
||||
- [ ] **P0 符合诉求**:把用户诉求拆成信息点清单,逐点在 JSON 里找到承载组件;需要的操作(按钮/表单/跳转)都齐备;无与诉求无关的填充
|
||||
- [ ] **P1 层级**:body 内有且仅有**一个**最强焦点;标题用 `**加粗**` 与正文拉开,次要信息用 grey
|
||||
- [ ] **P2 分组**:同主题字段收进同一容器,不同主题分容器;**没有「一路 hr 平铺」或多主题挤在同一 markdown**
|
||||
- [ ] **P3 复杂度适中**:视觉块 **2–5** 个、主色系 ≤3;且 >1 个块、至少含一个非纯文本结构元素(背景块/指标卡/图标/表格)——**既不能纯文字流水账,也不能堆砌过载**
|
||||
|
||||
**基础卫生(应满足):**
|
||||
- [ ] **P4 对比**:标题与正文在字号或粗细上至少差一档;正文不滥用 `#/##/###`(指标卡数值放大除外)
|
||||
- [ ] **P5 对齐**:不滥用散设 `margin`,间距优先交容器;间距取值种类 ≤4;非末尾顶级容器间距一致
|
||||
|
||||
**加分项(尽量满足):**
|
||||
- [ ] **P6 语义一致**:同色同义(红=降/警、绿=升/成、grey=次要);主色系起始色与 header 一致、取邻近色环
|
||||
- [ ] **P7 健壮**:并列/指标列默认 `weighted`/`none`、慎用 `stretch`;必要时配 `config.style.color` light/dark
|
||||
|
||||
---
|
||||
|
||||
## Step 4:发送卡片
|
||||
|
||||
```bash
|
||||
# 发送到群聊
|
||||
lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '<card_json>'
|
||||
|
||||
# 发送给指定用户(私聊)
|
||||
lark-cli im +messages-send --user-id ou_xxx --msg-type interactive --content '<card_json>'
|
||||
```
|
||||
|
||||
**发送失败时**:先对照下方常见失败列表排查,若能匹配则按对应处理方式修复后重新发送;否则根据错误信息修复 JSON 后重新发送。最多尝试 **3 次**。若 3 次后仍失败,**降级为 Card 1.0 卡片**重新构造并发送。**不参考之前发送 2.0 的记忆**,完全根据用户意图重新构造 1.0 卡片。1.0 无本地参考文档(components/、resource/ 均为 2.0)。
|
||||
**常见失败列表**
|
||||
|
||||
| # | 错误信息 | 处理方式 |
|
||||
|---|---|---|
|
||||
| 1 | `there is an invalid user resource (at/person) in your card` | 卡片中含有 at/person 组件,但使用了无效的用户 ID。询问用户其真实的 open_id / user_id,替换后重新发送。 |
|
||||
|
||||
---
|
||||
|
||||
## Step 5:交互回调(可选)
|
||||
|
||||
若卡片含**会回调服务端的交互组件**,则**支持**监听 `card.action.trigger` 回调(是否监听由实际需求决定,非必须):
|
||||
|
||||
**需显式配置 `behaviors: [{type:"callback"}]` 才会回调:**
|
||||
- `button`(带 callback behavior)
|
||||
- `checker` —— 未配置 behaviors 时仅本地勾选生效,不触发服务端回调
|
||||
- `interactive_container` —— behaviors 为必填,支持 callback / open_url
|
||||
|
||||
**选中 / 输入即默认回调,无需显式 `behaviors`:**
|
||||
- `select_static` / `multi_select_static` / `select_person` / `multi_select_person`
|
||||
- `overflow` / `input` / `date_picker` / `picker_time` / `picker_datetime`
|
||||
|
||||
**form 提交统一回调(按钮用 `form_action_type: "submit"`,无需 behaviors):**
|
||||
- form 内所有表单组件的值通过 `action.form_value` 一次性回传
|
||||
|
||||
> 纯 `open_url` 跳转按钮在客户端本地跳转,不回调服务端。
|
||||
|
||||
如需处理回调(监听事件、读取字段、更新卡片),见 `../lark-im-card-action-reply.md`。
|
||||
|
||||
---
|
||||
|
||||
## Step 6:用户反馈修正(按需进入)
|
||||
|
||||
用户看到已发送卡片后提出修改意见时,遵循以下流程。**不要整卡重做,外科手术式修改。**
|
||||
|
||||
### 1. 定位改动范围
|
||||
|
||||
把用户意见逐条映射到具体组件和字段:
|
||||
|
||||
| 用户反馈类型 | 映射目标 |
|
||||
|---|---|
|
||||
| 文案/措辞不满意 | 对应 `markdown.content` / `button.text` / `header.title` |
|
||||
| 颜色/风格不满意 | 对应 `background_style` / `font_color` / `header.template` / config color token |
|
||||
| 布局/排列不满意 | 对应 `column_set.flex_mode` / `width` / `weight` / `padding` |
|
||||
| 缺少某个字段/信息 | 新增 `div.fields` 条目或 `markdown` 行 |
|
||||
| 某个块太拥挤/太空 | 调整 `padding` / `vertical_spacing` / `margin` |
|
||||
| 交互行为问题 | 对应 `behaviors` / `confirm` / `disabled` |
|
||||
|
||||
### 2. 最小改动原则
|
||||
|
||||
- 只改被指出的组件,不动周边结构。
|
||||
- 改完后**只对被修改组件所涉及的 P 项重新自检**(改颜色 → 过 P6;改分组 → 过 P1+P2;改间距 → 过 P5)。
|
||||
|
||||
### 3. 重发
|
||||
|
||||
修正完成后,重新发送一张新卡(同 Step 4),告知用户"已重新发送修正版"。
|
||||
|
||||
### 4. 执行前告知
|
||||
|
||||
向用户复述"我将修改 ×××",确认后再执行,不要静默改动。
|
||||
|
||||
---
|
||||
|
||||
## 执行清单
|
||||
|
||||
- [ ] 入口:判断是文字诉求(→ Step 1)还是图片输入(→ 图片分支 → 判断类型→保真策略→组件映射)
|
||||
- [ ] Step 1:分析意图,输出设计方案(版本 / 宽度模式 / 颜色 / 组件)
|
||||
- [ ] Step 2:读 schema.md + 组件明细 + 「好看的标准 P0–P7」
|
||||
- [ ] Step 3:构造 JSON → 过 P0–P7 硬 Gate(P0+P1–P3 阻断),不过先修
|
||||
- [ ] Step 4:发送,失败按常见失败表排查重试(≤3 次);仍失败则降级 Card 1.0 重构发送
|
||||
- [ ] Step 5:若有交互,参考 ../lark-im-card-action-reply.md
|
||||
- [ ] Step 6:用户提出修改意见时,定位组件→最小改动→原地更新或重发
|
||||
281
skills/lark-im/references/card/lark-im-card-style.md
Normal file
281
skills/lark-im/references/card/lark-im-card-style.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Card Style Guide
|
||||
|
||||
选择组件组合和视觉样式的决策指南。字段写法见 `card-2.0-schema.md`。
|
||||
|
||||
---
|
||||
|
||||
## 好看的标准(P0–P7,唯一裁判基准)
|
||||
|
||||
**先读这一节。** 下面的「意图→组件」表和「视觉规范」都是为这套标准服务的手段;构造和自检卡片时**以 P0–P7 为准**。
|
||||
|
||||
**目标函数**:一张好卡片 = 让收件人在**约 2 秒一瞥**内 get 到「这是什么 + 最重要的是什么 + 要不要操作」,且观感**有序、克制、不嘈杂**。高效传达与视觉舒适在此统一。
|
||||
|
||||
**用力分配**:P0 必过(前置闸)→ P1–P3 强约束(阻断)→ P4–P5 基础卫生 → P6–P7 加分。
|
||||
|
||||
每条都附**结构化验证句**——卡片不能渲染成图,只能对 JSON 结构推理,所以验证靠「数结构」而非「眯眼看」。
|
||||
|
||||
| | 准则 | 可操作要求 | 结构化验证(自检句) |
|
||||
|---|---|---|---|
|
||||
| **P0** | **符合诉求**(前置闸·阻断) | 精确承载用户要的信息/意图/操作,不缺、不多、不跑题;意图类型与组件组合匹配 | 把诉求拆成信息点清单,逐点在 JSON 里找到承载组件;操作诉求逐个找到交互组件。有缺=不过 |
|
||||
| **P1** | **层级**(强约束·阻断) | header 承载「这是什么」;body 内**有且仅有一个**最强焦点(最大字号/最重色/指标卡大数字),其余为支撑;标题用 `**加粗**`、次要信息用 grey | 列出所有文本的「字号+粗细+颜色」三元组,能否排出主>次>辅三层;焦点是否唯一 |
|
||||
| **P2** | **分组**(强约束·阻断) | 同主题字段收进同一容器(`column_set`/`interactive_container`/背景块),不同主题分容器;块边界靠容器底色/描边/间距,**而非一路 `hr` 平铺** | 数顶层视觉块个数;是否存在「多主题挤在同一无分隔 markdown / 一路 hr 平铺」反模式 |
|
||||
| **P3** | **复杂度适中**(强约束·阻断·双边带) | 下限:不得纯文字流水账,至少有分块+层级+适度色彩/图标;上限:视觉块 2–5、主色系 ≤3、组件不堆砌、焦点唯一 | ①是否 >1 个视觉块且含≥1 个非纯文本结构元素(背景块/指标卡/图标/表格);②块数 ≤5、主色系 ≤3。两端都满足才过 |
|
||||
| **P4** | **对比**(基础卫生) | 标题与正文字号或粗细至少差一档;强调用色/放大;正文不滥用 `#/##/###`(数值焦点放大除外,见 P1) | 标题与正文是否在「字号或粗细」上至少差一档 |
|
||||
| **P5** | **对齐**(基础卫生) | 间距优先交容器 `vertical_spacing`/`horizontal_spacing`/`padding`,**不滥用散设 margin 造成疏密无规律**;间距值收敛到一套档位(2/4/8/12px);顶层容器间距一致 | 是否存在无规律的散落 margin;间距取值种类是否 ≤4 |
|
||||
| **P6** | **语义一致**(加分) | 红=降/警/失败、绿=升/成/通过、grey=次要;主色系起始色由 header 决定、取邻近色环;同色同义 | 同一颜色是否对应同一语义;header 模板色与块色是否同色系 |
|
||||
| **P7** | **健壮**(加分) | 并列/指标列默认 `weighted` 或 `none`、**慎用 `stretch`**(防移动端拉伸);需要时配 `config.style.color` 的 light/dark;不靠固定像素宽硬排 | 是否存在 stretch 拉伸风险;深浅色是否都可读 |
|
||||
|
||||
---
|
||||
|
||||
## 意图 → 组件组合
|
||||
|
||||
### 通知类(无交互或只读)
|
||||
|
||||
| 用户意图 | 推荐组件组合 | header.template |
|
||||
|---|---|---|
|
||||
| 纯文字通知 / 系统公告 | `column_set`(通知正文,带 `blue-50` 背景)+ `button(open_url)` | `blue` |
|
||||
| 活动公告(带主视觉图) | `img`(主图)+ `markdown`(时间/地点)+ `column_set`(详情对)+ `button(open_url)` | `turquoise` / `blue` |
|
||||
| 成功 / 完成状态通知 | `column_set`(关键字段,带 `green-50` 背景)+ `markdown`(结论加粗) | `green` |
|
||||
| 审批结果反馈(已通过 / 已拒绝) | `column_set`(申请信息)+ `column_set`(审批结论 + icon,带 `green-50`/`red-50` 背景) | `green` / `red` |
|
||||
| 生日 / 节日祝福 | `img`(主图)+ `column_set`(人名/日期)+ `button(open_url)` | `orange` |
|
||||
| 产品 / 功能上线推广 | `img`(主图)+ `markdown`(亮点)+ `column_set`(功能高亮块)+ `button(open_url)` | `blue` / `violet` |
|
||||
| 多图展示(图集、AI 生成图) | `img_combination` 或 多个 `img` + `markdown`(说明)+ `button(callback)` | `default` |
|
||||
|
||||
### 提醒 + 操作类
|
||||
|
||||
| 用户意图 | 推荐组件组合 | header.template |
|
||||
|---|---|---|
|
||||
| 提醒 + 一键操作 | `column_set`(详情,带 `yellow-50` 背景)+ `button(callback)` | `yellow` |
|
||||
| 任务清单 / 待办跟踪 | `checker` × N(每项带 `behaviors: callback`)+ `button(callback)`(全部完成操作) | `blue` |
|
||||
| 告警触发(需立即处理) | `column_set`(告警指标,带 `red-50` 背景)+ `column_set`(描述 + input 快速备注)+ `button(callback)` | `red` |
|
||||
| 告警已解决 / 状态变更 | `column_set`(解决时间 / 负责人,带 `green-50` 背景)+ `markdown`(结论加粗) | `green` |
|
||||
| 审批待处理(含备注输入) | `column_set`(申请信息,带 `grey-50` 背景)+ `column_set`(input 审批意见)+ `button(callback)` × 2(通过 / 拒绝) | `default` |
|
||||
| 日历 / 日程提醒(含参与人) | `column_set`(时间 / 地点,带 `yellow-50` 背景)+ `person_list`(参与人)+ `button(callback)` | `yellow` |
|
||||
| 危险操作确认 | `column_set`(说明,带 `red-50` 背景)+ `button(callback)` + `confirm` 弹窗配置 | `red` |
|
||||
|
||||
### 数据 / 报告类
|
||||
|
||||
| 用户意图 | 推荐组件组合 | header.template |
|
||||
|---|---|---|
|
||||
| 日报 / 工作汇报 | `column_set`(指标,带背景色)+ `interactive_container`(进展分块,带描边)× N;内容过长的块用 `collapsible_panel` 折叠次要细节 | `blue` / `default` |
|
||||
| 数据看板(含图表) | `column_set`(指标,带 `blue-50` 背景)+ `chart` + `table`(根节点,不可嵌套)+ `markdown`(说明) | `blue` |
|
||||
| 排行榜 | `column_set` 固定列宽(序号 + 头像 `img` + 名字 + 指标)循环条目 | `grey` |
|
||||
| 订单 / 工单详情 | `div.fields`(字段对)或 `column_set`(需彩色背景块时)+ `button(callback)` | `orange` |
|
||||
|
||||
### 表单 / 收集类
|
||||
|
||||
| 用户意图 | 推荐组件组合 | header.template |
|
||||
|---|---|---|
|
||||
| 纯文字表单收集 | `form`(内含 `input` + `button(form_action_type: submit)`) | `blue` |
|
||||
| 带下拉选择的表单(单选) | `form`(内含 `select_static` / `select_person` + `input` + `button`) | `wathet` |
|
||||
| 带多选的表单 | `form`(内含 `multi_select_static` / `multi_select_person` + `input` + `button`) | `wathet` |
|
||||
| 含日期 / 时间的表单 | `form`(内含 `date_picker` / `picker_time` / `picker_datetime` + `input` + `button`) | `blue` |
|
||||
| 设备 / 服务反馈 | `form`(内含 `select_static`(满意度)+ `input`(备注)+ `button`) | `yellow` |
|
||||
| 多步骤进度 / 引导 | `column_set`(横向步骤,带 `blue-50` 背景)+ `markdown`(当前状态)+ `button` | `blue` |
|
||||
|
||||
### 推荐 / 选择类
|
||||
|
||||
| 用户意图 | 推荐组件组合 | header.template |
|
||||
|---|---|---|
|
||||
| 推荐列表(带图卡片,可点击) | `interactive_container`(内含 `img` + `markdown`)× N + `button(open_url)` | `blue` |
|
||||
| AI 引导选项 / 功能菜单 | `markdown`(欢迎语)+ `interactive_container`(内含 `markdown` 选项说明)× N | 无 header |
|
||||
| Bot 功能引导 / 教程 | `column_set`(步骤说明,带背景)+ `button` × 2(主操作 / 次操作) | `blue` |
|
||||
| 服务台 / 多操作入口 | `column_set`(说明,带背景)+ `button` × N(≤3 个主操作,`type` 区分主次);次要操作超过 3 个时改用 `overflow`(折叠菜单) | 无 header |
|
||||
|
||||
### 社交 / 互动类
|
||||
|
||||
| 用户意图 | 推荐组件组合 | header.template |
|
||||
|---|---|---|
|
||||
| 工作圈 / 社交分享 | `img_combination`(多图)+ `markdown`(正文)+ `button(open_url)` × 2 | `blue` |
|
||||
| 成交 / 业绩公告 | `img`(庆祝图)+ `markdown`(成绩)+ `column_set`(关键数字) | `green` |
|
||||
|
||||
---
|
||||
|
||||
## 视觉规范(实现 P0–P7 的具体战术)
|
||||
|
||||
组件选型只解决「有没有」,下面各条是落地上面 P0–P7 的具体手段,括号标注它主要服务的原则。
|
||||
|
||||
> **P3 特例 — 数据看板类**:`chart + table + column_set + markdown` 是四种不同组件各出现一次,不算「堆砌」,P3 上限照常满足;但仍须保证每类只出现一次。
|
||||
|
||||
### 0. Header 图标(服务 P3 · 视觉质感底线)
|
||||
|
||||
**几乎所有卡片都应配 header icon**——这是提升「精致感」成本最低的一步,缺失会让 header 显得空洞、平价。
|
||||
|
||||
```json
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "mail_colorful" }
|
||||
}
|
||||
```
|
||||
|
||||
- `token` 从 `resource/icons.md` 按场景选取;彩色图标用 `*_colorful` 后缀,单色用普通名称。
|
||||
- 常用速查:通知 `notice_colorful`、告警 `warning_colorful`、审批 `approve_colorful`、日历 `calendar_colorful`、数据 `chart_colorful`、任务 `todo_colorful`、AI `myai_colorful`。
|
||||
|
||||
### 1. 配色纪律(服务 P6 语义一致)
|
||||
|
||||
- **邻近色环**:`Red → Carmine → Orange → Yellow → Green → Turquoise → Wathet → Blue → Violet → Purple →(回到)Red`。一张卡只能取色环上**相邻**的颜色,严禁跳跃(❌ blue + green + red)。
|
||||
- **最多 3 种主色系**(不含 grey / white)。
|
||||
- **起始色由 header 决定**:
|
||||
- header `blue` → blue / violet / purple
|
||||
- header `green` → green / turquoise / wathet
|
||||
- header `red` → red / carmine / orange
|
||||
- 无 header → 默认 blue / violet / purple
|
||||
- **深浅语义**(写法 `blue-50`、`blue-600`、`grey-500`):
|
||||
- `-50` 区块背景 · `-100` 标签背景 · `-500` 正文文字 · `-600`/`-700` 强调文字
|
||||
|
||||
### 2. 间距纪律(服务 P5 对齐 · 视觉决定性因素)
|
||||
|
||||
- **body padding 推荐**:`"padding": "12px 12px 20px 12px"`(上右下左;底部 20px 留白更舒适)。
|
||||
- **优先不用 `markdown` / `column` 的 `margin` 控间距**:交给父容器的 `vertical_spacing` / `horizontal_spacing` / `padding` 统一管理,多数情况显式置 `0px`;仅在需要精细缩进(如层级左缩进)时才设非零值。
|
||||
- 容器内 `vertical_spacing` 推荐值:`2px`(高亮块内标题↔正文)/ `4px`(正文段落、列表项)/ `8px`(需拉开的元素)。
|
||||
- **容器间智能 margin**:某个顶级容器若**不是** body 最后一个元素 → 设 `"margin": "0px 0px 12px 0px"`;若**是**最后一个 → `"0px"` 或不设,避免卡片底部多余留白。
|
||||
|
||||
### 3. 指标卡模式(服务 P1 焦点 · 出现 KPI / 数值 / 统计词时强制使用)
|
||||
|
||||
触发:内容含 `KPI/ROI/CTR/UV/PV/DAU/GMV/转化率/增长率/总数/营收` 等数值类信息。
|
||||
|
||||
- 多个指标并列放进一个 `column_set`,`flex_mode` **默认用 `"none"`、慎用 `"stretch"`**(防移动端拉伸变形,P7);仅在各列内容等宽、确认移动端不变形时才用 stretch。
|
||||
- 数值:用 `##` 放大(**唯一允许用 markdown 标题的特例**),可配 `<font>` 上色。
|
||||
- 描述:`<font color='grey'>` + `text_size: "notation"`。
|
||||
- 居中 `text_align: "center"`;列背景 `background_style: "grey-50"`;`padding: "12px"`;`vertical_spacing: "2px"`。
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "column_set",
|
||||
"flex_mode": "none",
|
||||
"horizontal_spacing": "12px",
|
||||
"columns": [
|
||||
{ "tag": "column", "width": "weighted", "weight": 1,
|
||||
"background_style": "grey-50", "corner_radius": "8px",
|
||||
"padding": "12px", "vertical_spacing": "2px",
|
||||
"elements": [
|
||||
{ "tag": "markdown", "content": "## <font color='blue'>5,483</font>", "text_align": "center" },
|
||||
{ "tag": "markdown", "content": "<font color='grey'>GMV($)</font>", "text_align": "center", "text_size": "notation" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 描边卡片模式(服务 P2 分组 · 进展 / 事项 / 列表项分块展示)
|
||||
|
||||
用 `interactive_container` 给每个事项块加描边 + 圆角,视觉上比彩色底色更轻盈,适合进展/工单/任务列表等「多条目」场景。
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "interactive_container",
|
||||
"width": "fill",
|
||||
"has_border": true,
|
||||
"border_color": "blue-100",
|
||||
"corner_radius": "8px",
|
||||
"background_style": "blue-50",
|
||||
"padding": "12px 12px 12px 12px",
|
||||
"vertical_spacing": "4px",
|
||||
"margin": "0px 0px 12px 0px",
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "**<font color='blue'>事项标题</font>**"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "事项正文内容……",
|
||||
"text_size": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `border_color` 跟随主色系(蓝系用 `blue-100`,绿系用 `green-100`)。
|
||||
- 不需要交互时可省略 `behaviors`;需要点击回调时加 `"behaviors": [{"type":"callback","value":{...}}]`。
|
||||
- **不能在内部放 `form` 或 `table`**。
|
||||
|
||||
### 5. 高亮块模式(服务 P2 分组 · 多分类信息成块展示)
|
||||
|
||||
两层结构:外层 `column_set` 管布局,内层 `column` 管样式(彩色背景)。
|
||||
|
||||
- 每个 `column` 设 `background_style` 用浅色(如 `blue-50` / `green-50`),`padding: "12px 12px 12px 12px"`,`vertical_spacing: "4px"`,`weight: 1`。
|
||||
- 块内首行用 `**<font color='blue'>分类标题</font>**` 着色加粗,正文紧随。
|
||||
- **布局选择**:分类 ≤ 3 个且内容简短 → 水平,优先用 `flex_mode: "bisect"`(2 列)或 `"trisect"`(3 列);各列字数严格等宽且已确认移动端不变形时才用 `stretch`(慎用,见 §9);**分类 ≥ 4 个、奇数、或任一块内容 > 3 行 → 垂直**(每块独占一行)。配色按上面第 1 条邻近色环依次取色。
|
||||
- ⚠️ **版本依赖**:`column.background_style` 需客户端 **≥ v7.9**,旧版静默丢背景。要求强健壮性时改用 `interactive_container` 的 `background_style`(无版本限制)替代 column 背景色。
|
||||
|
||||
### 6. Header 三件套(服务 P1 层级 · 语境补全)
|
||||
|
||||
header 有三层能力,**尽量用满**(至少用 `title` + `icon`;`subtitle` 和 `text_tag_list` 按实际诉求取舍)——这是成本最低、语境最清晰的一步:
|
||||
|
||||
- `title`:这是什么(必填)
|
||||
- `subtitle`:一句上下文(谁发 / 什么时间 / 什么状态),≤1 行,`plain_text`
|
||||
- `text_tag_list`:状态标签,≤3 个,颜色语义与 P6 保持一致(`blue`=信息、`yellow`=待处理、`red`=紧急、`green`=完成)
|
||||
|
||||
```json
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "发版审批" },
|
||||
"subtitle": { "tag": "plain_text", "content": "2026-06-25 · 后端服务" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "approve_colorful" },
|
||||
"text_tag_list": [
|
||||
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "待审批" }, "color": "yellow" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**禁止**:在 `header.title` 里写 emoji;把 subtitle 信息改塞进 body 第一行 markdown,让 header 空洞;严肃场景(审批/告警/财务)在 title 或 body 标题里用装饰性 emoji。
|
||||
|
||||
### 7. 字段对用 `div.fields`,不要用 `column_set` 模拟(服务 P5 对齐)
|
||||
|
||||
详情型"label: value"(订单字段、审批信息、日程详情)首选 `div.fields`——原生对齐,结构更轻:
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "div",
|
||||
"fields": [
|
||||
{ "is_short": true, "text": { "tag": "lark_md", "content": "**提交人**\n张三" } },
|
||||
{ "is_short": true, "text": { "tag": "lark_md", "content": "**部门**\n研发中台" } },
|
||||
{ "is_short": true, "text": { "tag": "lark_md", "content": "**提交时间**\n2026-06-25 10:30" } },
|
||||
{ "is_short": true, "text": { "tag": "lark_md", "content": "**优先级**\n<font color='red'>P0</font>" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`is_short: true` 的字段自动两两并排,对齐由组件保证。`column_set` 留给**需要彩色背景块 / 不等宽 / 嵌套复杂结构**的场景,不要用它模拟简单字段对。
|
||||
|
||||
### 8. 长文本必须设 `lines` 截断(服务 P3 复杂度上限)
|
||||
|
||||
凡接收动态数据的文本字段,必须设最大行数避免卡片被撑爆:
|
||||
|
||||
| 位置 | 字段 | 推荐上限 |
|
||||
|---|---|---|
|
||||
| `div.text` | `lines` | 正文 ≤4,次要说明 ≤2 |
|
||||
| `person_list` | `lines` | ≤2 |
|
||||
| `table.header_style` | `lines` | ≤1 |
|
||||
| `collapsible_panel` | 默认折叠 | 长文本优先用折叠面板而非截断 |
|
||||
|
||||
不设 `lines` 的动态文本 = P3 上限的隐患。
|
||||
|
||||
### 9. `flex_mode` 决策表(服务 P7 健壮)
|
||||
|
||||
| 场景 | 推荐 flex_mode | 原因 |
|
||||
|---|---|---|
|
||||
| 指标卡并列(内容不等长) | `none` + `width: weighted` | 防移动端拉伸;各列按比例压缩 |
|
||||
| 2 列等宽内容(字数相近) | `bisect` | 语义最清晰的两等分 |
|
||||
| 3 列等宽内容 | `trisect` | 三等分,不写 weight |
|
||||
| 多 tag / 多图标横排,允许换行 | `flow` | 窄屏自动折行,不挤压 |
|
||||
| 明确要求两端对齐撑满且内容等宽 | `stretch` | 慎用:移动端窄屏内容过长时会拉伸变形 |
|
||||
|
||||
> `stretch` 只在各列字数高度相近、且已确认移动端不变形时使用;其余场景默认 `none`。
|
||||
|
||||
### 10. `chart` 配色纳入 P6 纪律
|
||||
|
||||
`chart.color_theme` 必须与全卡色系保持一致:
|
||||
|
||||
- **默认**:`brand`(单色系,跟随飞书品牌色)或 `primary`(主色单色系),安全选项。
|
||||
- **禁止**:`rainbow`——会把色环上的跳跃色全打进图表,直接击穿 P6 的"主色系 ≤3 + 邻近色环"约束。
|
||||
- **例外**:数据维度 ≥4 个系列、且各系列无主次关系(如区域对比图)时,可用 `complementary` 或在 `chart_spec` 里自定义与主色系邻近的颜色数组。
|
||||
|
||||
### 11. `laser` 样式的克制规则(服务 P6 语义一致)
|
||||
|
||||
`button.type: "laser"` 和 `background_style: "laser"` 是高饱和渐变效果:
|
||||
|
||||
- **允许**:AI 生成类、节日庆祝类、营销推广类,每卡 **≤1 处**,且位置在主操作按钮或视觉焦点块。
|
||||
- **禁止**:审批、告警、财务、工单、日程等严肃场景——laser 在这些场景里显得轻浮廉价。
|
||||
- **默认不用**;Step 1 设计方案里若要用,需显式说明"×× 场景适合 laser 风格"并得到确认。
|
||||
34
skills/lark-im/references/card/resource/colors.md
Normal file
34
skills/lark-im/references/card/resource/colors.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 颜色枚举
|
||||
|
||||
卡片所有颜色字段(`font_color` / `text_color` / `background_style` / `border_color` / icon `color` 等)共用同一套枚举,按属性名区分用途,无单独的文字/背景色表。
|
||||
|
||||
## 基础色名(14 色系)
|
||||
|
||||
`blue` `carmine` `green` `indigo` `lime` `orange` `purple` `red` `sunflower` `turquoise` `violet` `wathet` `yellow` `grey`
|
||||
|
||||
> **标签例外**:`text_tag` / `<text_tag>` 的灰色用 `neutral`(不是 `grey`);标签枚举无 `grey`。
|
||||
|
||||
## 深浅后缀
|
||||
|
||||
- 彩色系(13 个非 grey):`-50 -100 -200 -300 -350 -400 -500 -600 -700 -800 -900`,数字越大越深。
|
||||
- **无后缀基础名(如 `blue`)= `-600`**(同色值)。
|
||||
- grey 范围更细:`-00 -50 -100 … -650 … -950 -1000`。
|
||||
- 用法语义:`-50` 区块背景 · `-100` 标签背景 · `-500` 正文 · `-600/-700` 强调文字。
|
||||
|
||||
## 特殊值
|
||||
|
||||
`white`(白)· `bg-white`(背景白:浅色 #ffffff / 深色 #1A1A1A)。无 `transparent` 枚举。
|
||||
|
||||
## 自定义 RGBA
|
||||
|
||||
在 `config.style.color` 定义 token 再引用:
|
||||
|
||||
```json
|
||||
"config": { "style": { "color": {
|
||||
"cus-0": { "light_mode": "rgba(5,157,178,0.52)", "dark_mode": "rgba(...)" }
|
||||
} } }
|
||||
```
|
||||
|
||||
组件里写 `"font_color": "cus-0"`。RGBA 支持的属性同枚举(font/text_color、background_style、border_color、icon color 等)。
|
||||
|
||||
> `column` 的 `background_style` 需客户端 v7.9+。配色搭配规则见 `../lark-im-card-style.md` 视觉规范。
|
||||
38
skills/lark-im/references/card/resource/icons.md
Normal file
38
skills/lark-im/references/card/resource/icons.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 图标枚举
|
||||
|
||||
用于 `header.icon`、`div.icon`、`markdown` 的 `<link icon=...>` 等。
|
||||
|
||||
## 结构
|
||||
|
||||
```json
|
||||
// 系统图标(推荐):用 token
|
||||
{ "tag": "standard_icon", "token": "info_outlined", "color": "blue" }
|
||||
// 自定义图标:用上传的 img_key
|
||||
{ "tag": "custom_icon", "img_key": "img_v3_xxx" }
|
||||
```
|
||||
|
||||
`color` 取颜色枚举(见 `colors.md`),仅对 `standard_icon` 生效。
|
||||
|
||||
## token 命名
|
||||
|
||||
- 线性:后缀 `_outlined`;面性(实心):后缀 `_filled`。
|
||||
- 主体 kebab-case,如 `calendar-add_outlined`、`delete-trash_outlined`。
|
||||
|
||||
## 常用 token(业务卡片)
|
||||
|
||||
| 含义 | token | 含义 | token |
|
||||
|---|---|---|---|
|
||||
| 完成/对勾 | `done_outlined` | 关闭/叉 | `close_outlined` |
|
||||
| 新增 | `add_outlined` | 编辑 | `edit_outlined` |
|
||||
| 删除 | `delete-trash_outlined` | 搜索 | `search_outlined` |
|
||||
| 设置 | `setting_outlined` | 信息 | `info_outlined` |
|
||||
| 警告 | `warning_outlined` | 时间 | `time_outlined` |
|
||||
| 日历 | `calendar_outlined` | 成员 | `member_outlined` |
|
||||
| 群组 | `group_outlined` | 会话 | `chat_outlined` |
|
||||
| 邮件 | `mail_outlined` | 链接 | `link-copy_outlined` |
|
||||
| 分享 | `share_outlined` | 下载 | `download_outlined` |
|
||||
| 通知/铃铛 | `bell_outlined` | 定位 | `pin_outlined` |
|
||||
| 附件 | `attachment_outlined` | 审批 | `approval_outlined` |
|
||||
|
||||
> token 必须与官方完全一致,否则图标不渲染。上表为常用项,全量(数百个,分系统/商务/沟通/用户/媒体/文档等类目)以官方图标库为准:
|
||||
> https://open.larkoffice.com/document/feishu-cards/enumerations-for-icons
|
||||
83
skills/lark-im/references/lark-im-chat-members-list.md
Normal file
83
skills/lark-im/references/lark-im-chat-members-list.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# im +chat-members-list
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
List the members of a chat. Users and bots are returned in **separate buckets** — `users[]` and `bots[]` — with per-bucket totals (`user_total` / `bot_total`). Use `--member-types` to return only one kind.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +chat-members-list` (internally calls `GET /open-apis/im/v1/chats/{chat_id}/members/list`).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Single page (default)
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx
|
||||
|
||||
# Only users, or only bots
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user,bot
|
||||
|
||||
# Walk every page (capped by --page-limit; 0 = unlimited)
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --page-all --page-limit 0
|
||||
|
||||
# Resume from a specific cursor (single page; --page-all is ignored)
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --page-token "xxx"
|
||||
|
||||
# JSON output / preview the request
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --format json
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Limits | Description |
|
||||
|------|------|------|------|
|
||||
| `--chat-id <id>` | Yes | `oc_xxx` | Target chat |
|
||||
| `--member-types <strings>` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all |
|
||||
| `--member-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Results per page. With `--page-all` and no explicit `--page-size`, the max (100) is used automatically to minimize round-trips |
|
||||
| `--page-token <token>` | No | - | Pagination cursor; **implies a single-page fetch** (disables auto-pagination) |
|
||||
| `--page-all` | No | - | Automatically walk every page (capped by `--page-limit`) |
|
||||
| `--page-limit <n>` | No | default 10, `0` = unlimited | Max pages to fetch with `--page-all` |
|
||||
| `--page-delay <ms>` | No | default 200, `0` = no delay | Delay between pages during `--page-all` (throttle to avoid rate limits on large lists) |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
> Supports both `--as user` (default) and `--as bot`. The caller must be in the target chat, and must belong to the same tenant for internal chats.
|
||||
|
||||
## Output Fields
|
||||
|
||||
| Field | Description |
|
||||
|------|------|
|
||||
| `chat_id` | The queried chat ID |
|
||||
| `users` | Array of user members (`member_id`, `name`, `tenant_key`, …) |
|
||||
| `bots` | Array of bot members (`member_id`, `app_id`, `name`, …) |
|
||||
| `user_total` / `bot_total` | Server-reported totals for each bucket |
|
||||
| `truncations` | Non-empty when the server **capped a bucket** due to security config — see below |
|
||||
| `has_more` / `page_token` | Paging signals from the final page fetched |
|
||||
|
||||
## Truncation: the result may be incomplete
|
||||
|
||||
The server applies a security cap to large member lists. When a bucket is capped, the response carries a `truncations[]` entry (e.g. `[{"limit": 100, "member_type": "user"}]`) **on the final page only**. The shortcut surfaces this two ways so it is never missed:
|
||||
|
||||
- **stderr**: `⚠️ member list truncated by server security config: user bucket capped at 100 — the list is INCOMPLETE.`
|
||||
- **stdout JSON**: the `truncations` array is preserved verbatim in the output.
|
||||
|
||||
A truncated result is *not* fixable by paging further — it is a server-side cap. Treat `users`/`bots` as a partial list whenever `truncations` is non-empty.
|
||||
|
||||
## Pagination notes
|
||||
|
||||
- Default fetches a single page. Pass `--page-all` to drain every page.
|
||||
- With `--page-all` and no explicit `--page-size`, the shortcut uses the maximum page size (100) so a full walk takes the fewest round-trips. An explicit `--page-size` is always honored.
|
||||
- `--page-all` sleeps `--page-delay` ms (default 200) between pages to avoid hammering the API when a tenant has no server-side member cap and the list spans many pages. Set `--page-delay 0` to disable.
|
||||
- `--page-all` stops at `--page-limit` pages (default 10). When it stops early, `has_more` stays `true` so you know the result is incomplete; re-run with `--page-limit 0` for everything.
|
||||
- `--page-token` and `--page-all` together: `--page-token` wins (single-page fetch from the supplied cursor); a stderr warning is emitted.
|
||||
- Across pages, `users[]` and `bots[]` are concatenated; `truncations` / `has_more` / `page_token` come from the last page fetched.
|
||||
|
||||
## Common Errors and Troubleshooting
|
||||
|
||||
| Symptom | Root Cause | | Solution |
|
||||
|---------|---------|---|---------|
|
||||
| `--chat-id is required` | `--chat-id` omitted | | Provide the `oc_xxx` chat ID |
|
||||
| `--page-size must be an integer between 1 and 100` | out of range | | Use 1-100 |
|
||||
| `--member-types contains invalid value` | value other than `user`/`bot` | | Use `user`, `bot`, or both |
|
||||
| Permission denied | missing `im:chat.members:read` | | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:read"` |
|
||||
@@ -155,6 +155,15 @@ lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency-
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' --dry-run
|
||||
|
||||
# ===== Interactive Card =====
|
||||
# 🚫 STOP — before constructing ANY interactive card JSON, you MUST read
|
||||
# card/lark-im-card-create.md and follow its workflow. Do NOT
|
||||
# hand-write or copy a card payload. The JSON passed to --content must be
|
||||
# the OUTPUT of that workflow. This is non-negotiable.
|
||||
|
||||
# Once the workflow has produced the card JSON, reply with it:
|
||||
lark-cli im +messages-reply --message-id om_xxx --msg-type interactive --content '<card_json_from_workflow>'
|
||||
```
|
||||
|
||||
## Media Input Rules
|
||||
@@ -265,3 +274,4 @@ Card content is **not** normalized — use the card-native `<at>` syntax inside
|
||||
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the reply is sent as the authorized end user
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
|
||||
- **Interactive cards are gated:** you MUST read and follow the [`card/lark-im-card-create.md`](card/lark-im-card-create.md) workflow to produce the card JSON *before* replying. Do not hand-write or copy a card payload — the JSON given to `--msg-type interactive --content` must be the workflow's output. This applies every time, with no exception
|
||||
|
||||
@@ -158,6 +158,15 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my-
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run
|
||||
|
||||
# ===== Interactive Card =====
|
||||
# 🚫 STOP — before constructing ANY interactive card JSON, you MUST read
|
||||
# card/lark-im-card-create.md and follow its workflow. Do NOT
|
||||
# hand-write or copy a card payload from the examples below. The JSON passed
|
||||
# to --content must be the OUTPUT of that workflow. This is non-negotiable.
|
||||
|
||||
# Once the workflow has produced the card JSON, send it:
|
||||
lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '<card_json_from_workflow>'
|
||||
```
|
||||
|
||||
## Media Input Rules
|
||||
@@ -213,7 +222,9 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
| `media` | `{"file_key":"file_xxx","image_key":"img_xxx"}` (video; `image_key` is the cover from `--video-cover` — **required**) |
|
||||
| `share_chat` | `{"chat_id":"oc_xxx"}` |
|
||||
| `share_user` | `{"user_id":"ou_xxx"}` |
|
||||
| `interactive` | Card JSON (see Feishu interactive card documentation) |
|
||||
| `interactive` | Card JSON — **MUST** be produced by the [`card/lark-im-card-create.md`](card/lark-im-card-create.md) workflow. Read it before writing any card; never hand-craft the JSON here |
|
||||
|
||||
> **`post` vs `interactive`:** `post` is a static rich-text message (title, paragraphs, @mentions, links, inline images) — content is fixed once sent. `interactive` is a card with structured layout and UI components (buttons, forms, selects, date pickers, charts) — content can be updated after sending and supports user-action callbacks. Use `post` for read-only content; use `interactive` when the message needs user interaction or dynamic updates.
|
||||
|
||||
`interactive` cards support callback events (`card.action.trigger`) — see [`lark-im-card-action-reply.md`](lark-im-card-action-reply.md).
|
||||
|
||||
@@ -265,3 +276,4 @@ Card content is **not** normalized — use the card-native `<at>` syntax inside
|
||||
- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
|
||||
- **Interactive cards are gated:** you MUST read and follow the [`card/lark-im-card-create.md`](card/lark-im-card-create.md) workflow to produce the card JSON *before* sending. Do not hand-write or copy a card payload — the JSON given to `--msg-type interactive --content` must be the workflow's output. This applies every time, with no exception
|
||||
|
||||
@@ -31,12 +31,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
|
||||
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
|
||||
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
|
||||
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值 |
|
||||
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值(简单场景推荐)。更复杂的指标操作见 [量化指标管理](references/lark-okr-indicators.md) |
|
||||
| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KR(content、notes、score、deadline) |
|
||||
|
||||
## 格式说明
|
||||
|
||||
- [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明,以及简化的半纯文本(SemiPlainContent)格式的进一步说明。
|
||||
- **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念
|
||||
|
||||
## API Resources
|
||||
@@ -46,6 +47,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
- `delete` — 删除对齐关系
|
||||
- `get` — 获取对齐关系
|
||||
|
||||
> **操作指南:** [OKR 对齐关系管理](references/lark-okr-alignments.md) 包含 list/create/delete 完整工作流
|
||||
|
||||
### categories
|
||||
|
||||
- `list` — 批量获取分类
|
||||
@@ -71,6 +74,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
|
||||
- `patch` — 更新量化指标
|
||||
|
||||
> **操作指南:** [OKR 量化指标管理](references/lark-okr-indicators.md) 包含目标/KR 指标查询和 patch 更新完整工作流
|
||||
|
||||
### key_results
|
||||
|
||||
- `delete` — 删除关键结果
|
||||
@@ -81,6 +86,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
|
||||
- `list` — 获取关键结果的量化指标
|
||||
|
||||
> **操作指南:** [OKR 量化指标管理](references/lark-okr-indicators.md)
|
||||
|
||||
### objectives
|
||||
|
||||
- `delete` — 删除目标
|
||||
|
||||
180
skills/lark-okr/references/lark-okr-alignments.md
Normal file
180
skills/lark-okr/references/lark-okr-alignments.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# OKR 对齐关系管理
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
管理 OKR 目标之间的对齐关系,包括查询、创建和删除对齐。
|
||||
|
||||
## 对齐关系说明
|
||||
|
||||
OKR 对齐关系表示两个目标之间的关联:
|
||||
- **对齐(aligning)**:目标 A 对齐到目标 B,表示 A 的完成有助于 B 的完成
|
||||
- **被对齐(aligned)**:目标 B 被目标 A 对齐
|
||||
|
||||
每个对齐关系有唯一的 `alignment_id`,用于删除操作。
|
||||
|
||||
---
|
||||
|
||||
## 一、查询对齐关系
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
lark-cli okr objective.alignments list --objective-id "<目标ID>" [flags]
|
||||
```
|
||||
|
||||
### 常用示例
|
||||
|
||||
```bash
|
||||
# 获取目标的所有对齐关系(同时包含对齐和被对齐)
|
||||
lark-cli okr objective.alignments list \
|
||||
--objective-id "7652569715131075772"
|
||||
|
||||
# 只查询该目标主动对齐他人的关系
|
||||
lark-cli okr objective.alignments list \
|
||||
--objective-id "7652569715131075772" \
|
||||
--align-type "aligning"
|
||||
|
||||
# 只查询他人对齐该目标的关系
|
||||
lark-cli okr objective.alignments list \
|
||||
--objective-id "7652569715131075772" \
|
||||
--align-type "aligned"
|
||||
|
||||
# 自动分页获取全部数据
|
||||
lark-cli okr objective.alignments list \
|
||||
--objective-id "7652569715131075772" \
|
||||
--page-all
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|----------------------|----|----------------|--------------------------------------------------------------------|
|
||||
| `--objective-id` | 是 | — | 目标 ID |
|
||||
| `--align-type` | 否 | — | 对齐类型:`aligning`(该目标对齐他人)\| `aligned`(他人对齐该目标)。留空返回全部。 |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--page-size` | 否 | `10` | 分页大小,最大 100 |
|
||||
| `--page-all` | 否 | — | 自动分页获取全部数据 |
|
||||
|
||||
### 返回字段说明
|
||||
|
||||
- `items[].id`:对齐关系 ID(删除时需要)
|
||||
- `items[].from_entity_id`:发起对齐的目标 ID
|
||||
- `items[].to_entity_id`:被对齐的目标 ID
|
||||
- `items[].from_owner` / `to_owner`:双方所有者信息
|
||||
|
||||
---
|
||||
|
||||
## 二、创建对齐关系
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
lark-cli okr objective.alignments create --objective-id "<发起对齐的目标ID>" --data '<JSON>'
|
||||
```
|
||||
|
||||
### 常用示例
|
||||
|
||||
```bash
|
||||
# 创建对齐关系:目标 7652569715131075772 对齐到目标 7652569715131075773
|
||||
lark-cli okr objective.alignments create \
|
||||
--objective-id "7652569715131075772" \
|
||||
--data '{"to_entity_id":"7652569715131075773","to_entity_type":2}'
|
||||
|
||||
# 从文件读取请求体
|
||||
lark-cli okr objective.alignments create \
|
||||
--objective-id "7652569715131075772" \
|
||||
--data @alignment.json
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------------------|----|--------------------------------------------------------------------|
|
||||
| `--objective-id` | 是 | 发起对齐的目标 ID("我"的目标) |
|
||||
| `--data` | 是 | JSON 请求体,格式见下方。支持 `@文件路径` 从文件读取。 |
|
||||
|
||||
### 请求体格式
|
||||
|
||||
```json
|
||||
{
|
||||
"to_entity_id": "7652569715131075773", // 被对齐的目标 ID
|
||||
"to_entity_type": 2 // 固定值 2,表示目标类型
|
||||
}
|
||||
```
|
||||
|
||||
### 对齐规则
|
||||
|
||||
- **禁止自对齐**:不能自己对齐自己
|
||||
- **周期时间重叠**:两个目标所在周期的时间范围必须有重叠
|
||||
- **权限要求**:需要对发起对齐的目标有编辑权限
|
||||
|
||||
### 返回
|
||||
|
||||
成功后返回 `alignment_id`,保存好以便后续删除。
|
||||
|
||||
---
|
||||
|
||||
## 三、删除对齐关系
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
lark-cli okr alignments delete --alignment-id "<对齐关系ID>"
|
||||
```
|
||||
|
||||
### 常用示例
|
||||
|
||||
```bash
|
||||
# 删除指定的对齐关系
|
||||
lark-cli okr alignments delete \
|
||||
--alignment-id "7652569715131075780"
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------------------|----|--------------------------------------|
|
||||
| `--alignment-id` | 是 | 对齐关系 ID(从 list 或 create 返回) |
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 删除操作不可逆,请谨慎操作
|
||||
- 需要对关联的目标有编辑权限
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流示例
|
||||
|
||||
### 场景:将目标 A 对齐到目标 B
|
||||
|
||||
1. **查询现有对齐关系**(确认是否已存在)
|
||||
```bash
|
||||
lark-cli okr objective.alignments list \
|
||||
--objective-id "目标A的ID" \
|
||||
--align-type "aligning"
|
||||
```
|
||||
|
||||
2. **创建对齐关系**
|
||||
```bash
|
||||
lark-cli okr objective.alignments create \
|
||||
--objective-id "目标A的ID" \
|
||||
--data '{"to_entity_id":"目标B的ID","to_entity_type":2}'
|
||||
```
|
||||
|
||||
3. **验证对齐结果**
|
||||
```bash
|
||||
lark-cli okr objective.alignments list \
|
||||
--objective-id "目标A的ID" \
|
||||
--align-type "aligning"
|
||||
```
|
||||
|
||||
4. **(如需)删除对齐关系**
|
||||
```bash
|
||||
lark-cli okr alignments delete \
|
||||
--alignment-id "从步骤1返回的alignment_id"
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` 富文本格式。本文档描述其结构和使用方式。
|
||||
|
||||
## 两种输入输出风格
|
||||
|
||||
OKR shortcuts 支持 `--style` 标志控制 content/notes 字段的输入输出格式:
|
||||
|
||||
| `--style` 值 | 说明 | 适用场景 |
|
||||
|--------------|--------------------------------------------------------------------|--------------------------|
|
||||
| `simple`(默认) | 半纯文本格式 `SemiPlainContent`,简化的 JSON 结构,仅包含 text、mention、docs、images | 大多数场景,简单易用 |
|
||||
| `richtext` | 原始 `ContentBlock` 富文本格式,完整的块结构和样式信息 | 需要精确控制@提及用户位置、包含图片/文档链接时 |
|
||||
|
||||
**重要**:输入时严格根据 `--style` 值验证格式,不会自动检测。输出时读操作(如 `+cycle-detail`、`+progress-get`)根据 `--style` 返回对应格式。
|
||||
|
||||
## ContentBlock 结构概览
|
||||
|
||||
```json
|
||||
@@ -215,9 +226,66 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock`
|
||||
|-------|----------|--------|
|
||||
| `url` | `string` | 链接 URL |
|
||||
|
||||
## SemiPlainContent 半纯文本格式
|
||||
|
||||
`SemiPlainContent` 是 `ContentBlock` 的简化、有损表示形式,适用于大多数不需要复杂格式的场景。
|
||||
|
||||
### 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
|
||||
"mention": ["ou_zhangsan", "ou_lisi"],
|
||||
"docs": [
|
||||
{
|
||||
"title": "产品需求文档",
|
||||
"url": "https://larkoffice.com/docx/xxx"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
"https://example.com/image.png"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 类型定义
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----------|------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `text` | `string` | 纯文本内容(必填,不能为空)。**输出时**包含 ` @{userID} ` 占位符以保留提及的位置上下文;**输入时** `@{...}` 占位符会被自动 strip 掉,只识别 `mention` 字段内容 |
|
||||
| `mention` | `string[]` | 用户 ID 列表(可选),与 text 中的 `@{userID}` 占位符一一对应,输入时按顺序转换为 mention 元素**置于文本末尾** |
|
||||
| `docs` | `SemiPlainDoc[]` | 文档列表(仅输出时包含,输入时 simple 风格不支持) |
|
||||
| `images` | `string[]` | 图片 URL 列表(仅输出时包含,输入时 simple 风格不支持) |
|
||||
|
||||
### SemiPlainDoc
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|----------|--------|
|
||||
| `title` | `string` | 文档标题 |
|
||||
| `url` | `string` | 文档 URL |
|
||||
|
||||
### 双向转换说明
|
||||
|
||||
- **ContentBlock → SemiPlainContent**(输出时):提取纯文本、提及用户、文档链接和图片 URL,丢弃格式信息(粗体、列表、颜色等)。**提及的位置信息通过 ` @{userID} ` 占位符保留在 text 中**,同时 userID 也会被收集到 mention 数组中
|
||||
- **SemiPlainContent → ContentBlock**(输入时):自动 strip 掉 text 中的 `@{...}` 占位符,然后将 text 和 mention 合并为单个段落,mention 按顺序附加在文本末尾。docs 和 images 在输入时被忽略(simple 风格不支持)
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1:简单文本段落
|
||||
### 示例 0:--style simple 半纯文本格式
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "提升用户满意度",
|
||||
"mention": ["ou_123"]
|
||||
}
|
||||
```
|
||||
|
||||
使用方式:
|
||||
```bash
|
||||
lark-cli okr +patch --level objective --style simple --target-id 123 --content '{"text":"提升用户满意度","mention":["ou_123"]}'
|
||||
```
|
||||
|
||||
### 示例 1:简单文本段落(richtext 风格)
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -7,20 +7,24 @@
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 列出指定周期的目标和关键结果
|
||||
# 列出指定周期的目标和关键结果(默认 simple 风格,半纯文本格式,推荐使用,更简洁)
|
||||
lark-cli okr +cycle-detail --cycle-id 1234567890123456789
|
||||
|
||||
# 列出指定周期的目标和关键结果(richtext 风格,原始 ContentBlock JSON)
|
||||
lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --style richtext
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|--------------|----|--------|-----------------------------------------|
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|--------------|----|----------|-----------------------------------------------------------------------------------------------------------------------------|
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 |
|
||||
| `--style` | 否 | `simple` | 输出风格:`simple`(半纯文本格式,不涉及字体/颜色等信息时推荐使用) \| `richtext`(原始 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
@@ -75,8 +79,11 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run
|
||||
}
|
||||
```
|
||||
|
||||
其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock
|
||||
富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
其中,content 和 notes 字段格式由 `--style` 控制:
|
||||
- `--style simple`(默认):`SemiPlainContent` 对象,包含 `text`、`mention`、`docs` 字段
|
||||
- `--style richtext`:JSON 字符串,为 OKR ContentBlock 富文本格式
|
||||
|
||||
请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解两种格式的详细信息。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -46,20 +46,20 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
"cycles": [
|
||||
{
|
||||
"id": "1234567890123456789",
|
||||
"create_time": "2025-01-01 00:00:00",
|
||||
"update_time": "2025-01-01 00:00:00",
|
||||
"tenant_cycle_id": "789",
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"start_time": "2025-01-01 00:00:00",
|
||||
"end_time": "2025-06-30 00:00:00",
|
||||
"cycle_status": "normal",
|
||||
"score": 0
|
||||
"cycle_status": "normal"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
"total": 1,
|
||||
"current_active_cycles": [
|
||||
{
|
||||
"id": "1234567890123456789",
|
||||
"start_time": "2025-01-01 00:00:00",
|
||||
"end_time": "2025-06-30 00:00:00",
|
||||
"cycle_status": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -67,11 +67,14 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
|
||||
- `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情
|
||||
- `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。
|
||||
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而
|
||||
“2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。
|
||||
- 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是
|
||||
“2025 年” 的年度周期
|
||||
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 "2025-01-01开始,2025-06-30结束" 的周期被称作 "2025 年 1-6 月" 周期,而
|
||||
"2025-01-01开始,2025-01-31结束" 的周期被称作 "2025 年 1 月"周期。
|
||||
- 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 "2025-01-01开始,2025-12-31结束" 的周期就是
|
||||
"2025 年" 的年度周期
|
||||
- `cycle_status` 为周期状态值,参见下文。
|
||||
- `current_active_cycles` 是当前生效的周期列表,不过根据用户的周期设置,可能会出现为空的场景。
|
||||
|
||||
如果需要获取周期的创建时间/总分等信息,可以通过原生 API `okr cycles list` 获取。
|
||||
|
||||
### 周期状态值
|
||||
|
||||
|
||||
223
skills/lark-okr/references/lark-okr-indicators.md
Normal file
223
skills/lark-okr/references/lark-okr-indicators.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# OKR 量化指标管理
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
管理 OKR 目标(Objective)和关键结果(Key Result)的量化指标,包括查询和更新指标。
|
||||
|
||||
> **快速更新当前值:** 如果只需要更新指标的当前值,推荐使用 shortcut [`okr +indicator-update`](lark-okr-indicator-update.md),无需手动查询指标 ID。
|
||||
>
|
||||
> 本指南中的原生 API 适用于需要修改指标其他字段(如 `unit`、`target_value`、`status_calculate_type` 等)的场景。
|
||||
|
||||
---
|
||||
|
||||
## 指标字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----------------------------|------|--------------------------------------------------------------------|
|
||||
| `id` | string | 指标 ID(更新时需要) |
|
||||
| `entity_id` / `entity_type` | string/int | 所属实体 ID 和类型(2=目标,3=关键结果) |
|
||||
| `current_value` | number | 当前值 |
|
||||
| `target_value` | number | 目标值 |
|
||||
| `start_value` | number | 起始值 |
|
||||
| `indicator_status` | int | 状态:-1=未定义,0=正常,1=有风险,2=已延期 |
|
||||
| `status_calculate_type` | int | 状态计算方式:0=手动更新,1=基于进度和当前时间自动更新,2=基于风险最高的 KR 状态更新 |
|
||||
| `current_value_calculate_type` | int | 当前值计算方式:0=手动更新,1=基于 KR 进度自动更新(目标),2=基于拆解 KR 进度更新(KR) |
|
||||
| `unit` | object | 单位,包含 `unit_type`(0=公共,1=自定义)和 `unit_value`(如 PERCENT、YUAN 等) |
|
||||
| `owner` | object | 所有者 |
|
||||
|
||||
---
|
||||
|
||||
## 一、查询目标的量化指标
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
lark-cli okr objective.indicators list --objective-id "<目标ID>" [flags]
|
||||
```
|
||||
|
||||
### 常用示例
|
||||
|
||||
```bash
|
||||
# 获取目标的量化指标
|
||||
lark-cli okr objective.indicators list \
|
||||
--objective-id 7652569715131075772
|
||||
|
||||
# 指定用户 ID 类型
|
||||
lark-cli okr objective.indicators list \
|
||||
--objective-id 7652569715131075772 \
|
||||
--user-id-type "user_id"
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|----------------------|----|----------------|-----------------------------------------------------|
|
||||
| `--objective-id` | 是 | — | 目标 ID |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`open_department_id` \| `department_id` |
|
||||
|
||||
### 返回
|
||||
|
||||
返回 `indicator` 字段,包含该目标的量化指标详情。
|
||||
|
||||
---
|
||||
|
||||
## 二、查询关键结果的量化指标
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
lark-cli okr key_result.indicators list --key-result-id "<关键结果ID>" [flags]
|
||||
```
|
||||
|
||||
### 常用示例
|
||||
|
||||
```bash
|
||||
# 获取关键结果的量化指标
|
||||
lark-cli okr key_result.indicators list \
|
||||
--key-result-id "7652569715131075780"
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|----------------------|----|----------------|-----------------------------------------------------|
|
||||
| `--key-result-id` | 是 | — | 关键结果 ID |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`open_department_id` \| `department_id` |
|
||||
|
||||
### 返回
|
||||
|
||||
返回 `indicator` 字段,包含该关键结果的量化指标详情。
|
||||
|
||||
---
|
||||
|
||||
## 三、更新量化指标
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
lark-cli okr indicators patch --indicator-id "<指标ID>" --data '<JSON>'
|
||||
```
|
||||
|
||||
### 常用示例
|
||||
|
||||
```bash
|
||||
# 更新指标的当前值(手动更新方式)
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id "ind-123" \
|
||||
--data '{"current_value": 75.5, "current_value_calculate_type": 0}'
|
||||
|
||||
# 更新指标状态为"有风险"(需 status_calculate_type=0)
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id "ind-123" \
|
||||
--data '{"indicator_status": 1, "status_calculate_type": 0}'
|
||||
|
||||
# 更新关键结果指标的目标值和单位
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id "ind-456" \
|
||||
--data '{
|
||||
"target_value": 100,
|
||||
"unit": {"unit_type": 0, "unit_value": "PERCENT"}
|
||||
}'
|
||||
|
||||
# 从文件读取请求体
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id "ind-123" \
|
||||
--data @indicator_update.json
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------------------|----|--------------------------------------------------------------------|
|
||||
| `--indicator-id` | 是 | 指标 ID(从 list 接口获取) |
|
||||
| `--data` | 是 | JSON 请求体,包含要更新的字段。支持 `@文件路径` 从文件读取。 |
|
||||
| `--user-id-type` | 否 | 用户 ID 类型 |
|
||||
|
||||
### 请求体字段
|
||||
|
||||
根据需要更新的字段选择传入,支持增量更新:
|
||||
|
||||
| 字段 | 类型 | 适用实体 | 说明 |
|
||||
|-----------------------------|------|------|--------------------------------------------------------------------|
|
||||
| `current_value` | number | 全部 | 当前值,范围 -99999999999 到 99999999999 |
|
||||
| `current_value_calculate_type` | int | 全部 | 当前值计算方式:0=手动,1=基于 KR 进度(目标),2=基于拆解 KR 进度(KR) |
|
||||
| `indicator_status` | int | 全部 | 状态:-1=未定义,0=正常,1=有风险,2=已延期。仅 `status_calculate_type=0` 时可修改 |
|
||||
| `status_calculate_type` | int | 全部 | 状态计算方式:0=手动,1=自动(进度+时间),2=自动(最高风险 KR)。目标支持 0/1/2,KR 支持 0/1 |
|
||||
| `start_value` | number | KR | 起始值。目标不支持修改 |
|
||||
| `target_value` | number | KR | 目标值。目标不支持修改;有承接记录的 KR 不支持修改 |
|
||||
| `unit` | object | KR | 单位。目标不支持修改;有承接记录的 KR 不支持修改 |
|
||||
|
||||
### 单位 (`unit`) 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": {
|
||||
"unit_type": 0, // 0=公共单位,1=自定义单位
|
||||
"unit_value": "PERCENT" // 公共单位枚举:PERCENT、NONE、YUAN、DOLLAR;自定义单位:最长5字符
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 限制说明
|
||||
|
||||
- **目标指标**:不支持修改 `start_value`、`target_value`、`unit`
|
||||
- **关键结果指标**:有承接记录的 KR 不支持修改 `target_value`、`unit`
|
||||
- **自动计算的指标**:`current_value_calculate_type != 0` 时,不能手动修改 `current_value`
|
||||
- **自动状态的指标**:`status_calculate_type != 0` 时,不能手动修改 `indicator_status`
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流示例
|
||||
|
||||
### 场景:更新关键结果的指标当前值和状态
|
||||
|
||||
1. **查询关键结果的指标**(获取 `indicator_id` 和当前配置)
|
||||
```bash
|
||||
lark-cli okr key_result.indicators list \
|
||||
--key-result-id 7652569715131075780
|
||||
```
|
||||
|
||||
2. **检查指标配置**,确认:
|
||||
- `current_value_calculate_type` 为 0(手动更新)才能修改 `current_value`
|
||||
- `status_calculate_type` 为 0(手动更新)才能修改 `indicator_status`
|
||||
|
||||
3. **更新指标**
|
||||
```bash
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id "ind-123" \
|
||||
--data '{
|
||||
"current_value": 65.0,
|
||||
"current_value_calculate_type": 0,
|
||||
"indicator_status": 1,
|
||||
"status_calculate_type": 0
|
||||
}'
|
||||
```
|
||||
|
||||
4. **验证更新结果**
|
||||
```bash
|
||||
lark-cli okr key_result.indicators list \
|
||||
--key-result-id 7652569715131075780
|
||||
```
|
||||
|
||||
### 场景:修改关键结果指标的目标值和单位
|
||||
|
||||
```bash
|
||||
# 1. 查询获取 indicator_id
|
||||
lark-cli okr key_result.indicators list --key-result-id 7652569715131075780
|
||||
|
||||
# 2. 更新目标值和单位
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id 7652569715131075781 \
|
||||
--data '{
|
||||
"target_value": 500,
|
||||
"unit": {"unit_type": 0, "unit_value": "YUAN"}
|
||||
}'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [okr +indicator-update](lark-okr-indicator-update.md) -- 快捷更新指标当前值(推荐)
|
||||
104
skills/lark-okr/references/lark-okr-patch.md
Normal file
104
skills/lark-okr/references/lark-okr-patch.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# okr +patch
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
部分更新 OKR 目标(Objective)或关键结果(Key Result)的 content、notes、score、deadline 字段。支持增量更新,只需提供要修改的字段。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 更新目标的 content(默认 simple 风格,半纯文本格式)
|
||||
lark-cli okr +patch \
|
||||
--level objective \
|
||||
--target-id 1234567890123456789 \
|
||||
--content '{"text":"更新后的目标内容","mention":["ou_123"]}'
|
||||
|
||||
# 更新关键结果的分数(0.0-1.0 的一位小数)
|
||||
lark-cli okr +patch \
|
||||
--level key-result \
|
||||
--target-id 2345678901234567890 \
|
||||
--score 0.7
|
||||
|
||||
# 同时更新目标的多个字段(richtext 风格,完整 ContentBlock 格式)
|
||||
lark-cli okr +patch \
|
||||
--level objective \
|
||||
--target-id 1234567890123456789 \
|
||||
--style richtext \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的目标内容"}}]}}]}' \
|
||||
--notes '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的备注"}}]}}]}' \
|
||||
--score 0.5 \
|
||||
--deadline 1735776000000
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +patch \
|
||||
--level objective \
|
||||
--target-id 1234567890123456789 \
|
||||
--content '{"text":"测试更新"}' \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|----------------|----|-----------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `--level` | 是 | — | 更新级别:`objective`(目标) \| `key-result`(关键结果) |
|
||||
| `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) |
|
||||
| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 |
|
||||
| `--content` | 否¹ | — | 内容。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取。 |
|
||||
| `--notes` | 否¹ | — | 备注(仅 `--level=objective` 时支持)。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取。 |
|
||||
| `--score` | 否¹ | — | 分数值,0-1 之间,最多一位小数(如 0.5、1.0)。 |
|
||||
| `--deadline` | 否¹ | — | 截止时间,毫秒级时间戳(如 1735776000000)。 |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
> ¹ 至少需要提供 `--content`、`--notes`、`--score`、`--deadline` 中的一个字段。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。
|
||||
2. 确定要更新的字段:
|
||||
- **content/notes**:构造内容
|
||||
- **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}`
|
||||
- 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。
|
||||
- **score**:0-1 之间的数字,最多一位小数(如 0.3、0.7、1.0)
|
||||
- **deadline**:毫秒级时间戳
|
||||
3. 执行 `lark-cli okr +patch --level objective --target-id "..." --content "..."`。
|
||||
4. 报告结果:更新的级别、目标 ID、以及哪些字段被更新。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"level": "objective",
|
||||
"target_id": "1234567890123456789",
|
||||
"patched": {
|
||||
"content": true,
|
||||
"notes": true,
|
||||
"score": true,
|
||||
"deadline": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中 `patched` 对象中的每个字段表示该字段是否被更新。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **`--notes` 仅适用于目标**:关键结果(key-result)不支持 notes 字段,使用时会报错。
|
||||
- **score 格式**:必须在 0-1 之间,且最多一位小数(如 0.5 正确,0.51 错误)。
|
||||
- **严格验证**:输入格式严格根据 `--style` 值验证,不会自动检测。使用 ContentBlock JSON 时必须指定 `--style richtext`。
|
||||
- **simple 风格输入限制**:simple 风格的输入不支持 `docs` 和 `images` 字段,如需包含文档或图片请使用 `richtext` 风格。
|
||||
|
||||
## 关于 1001001 错误
|
||||
|
||||
有时,当你涉及修改目标或关键结果的分数时,即使输入的参数完全正确, +patch 也会返回 1001001 错误(invalid parameters)。
|
||||
这可能是因为在用户的租户设置中停用了目标/关键结果的分数功能,或禁用了目标分数的手动计算。此时可以先去掉 --score 参数再修改,并向用户确认是否启用了对应的功能。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [ContentBlock 格式](lark-okr-contentblock.md) -- content/notes 使用的富文本格式
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -7,15 +7,16 @@
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 为目标创建进展记录
|
||||
# 为目标创建进展记录(默认 simple 风格,半纯文本格式)
|
||||
lark-cli okr +progress-create \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"本周完成了核心模块开发"}}]}}]}' \
|
||||
--content '{"text":"本周完成了核心模块开发","mention":["ou_123"]}' \
|
||||
--target-id 1234567890123456789 \
|
||||
--target-type objective
|
||||
|
||||
# 为关键结果创建进展记录(带进度百分比和状态)
|
||||
# 为关键结果创建进展记录(richtext 风格,完整 ContentBlock 格式)
|
||||
lark-cli okr +progress-create \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"指标已达到 80%"}}]}}]}' \
|
||||
--style richtext \
|
||||
--target-id 2345678901234567891 \
|
||||
--target-type key_result \
|
||||
--progress-percent 80 \
|
||||
@@ -32,7 +33,8 @@ lark-cli okr +progress-create \
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|----------------------|----|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--content` | 是 | — | 进展内容。根据 `--style` 指定格式:`simple` 风格为 SemiPlainContent JSON,`richtext` 风格为 ContentBlock JSON。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 |
|
||||
| `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) |
|
||||
| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` |
|
||||
| `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 |
|
||||
@@ -46,7 +48,9 @@ lark-cli okr +progress-create \
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。
|
||||
2. 构造 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。
|
||||
2. 构造进展内容:
|
||||
- **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}`,mention 中提及的用户会统一连接在文本末尾。
|
||||
- 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。若需要插入图片/飞书文档或复杂文本格式,则必须使用 richtext 风格
|
||||
3. 执行 `lark-cli okr +progress-create --content "..." --target-id "..." --target-type objective`。
|
||||
4. 报告结果:新创建的进展记录 ID、修改时间等。
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 获取指定 ID 的进展记录
|
||||
# 获取指定 ID 的进展记录(默认 simple 风格,半纯文本格式)
|
||||
lark-cli okr +progress-get --progress-id 1234567890123456789
|
||||
|
||||
# 获取指定 ID 的进展记录(richtext 风格,原始 ContentBlock JSON)
|
||||
lark-cli okr +progress-get --progress-id 1234567890123456789 --style richtext
|
||||
|
||||
# 使用特定的用户 ID 类型
|
||||
lark-cli okr +progress-get --progress-id 1234567890123456789 --user-id-type open_id
|
||||
|
||||
@@ -19,12 +22,13 @@ lark-cli okr +progress-get --progress-id 1234567890123456789 --dry-run
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-----------|-----------------------------------------------|
|
||||
| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-------------|--------------------------------------------------------------------|
|
||||
| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) |
|
||||
| `--style` | 否 | `simple` | 输出风格:`simple`(半纯文本 SemiPlainContent,推荐) \| `richtext`(原始 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
@@ -34,26 +38,53 @@ lark-cli okr +progress-get --progress-id 1234567890123456789 --dry-run
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
返回 JSON,`content` 字段格式由 `--style` 控制:
|
||||
|
||||
### `--style simple`(默认)输出示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"progress": {
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 10:30:00",
|
||||
"content": "{...}",
|
||||
"content": {
|
||||
"text": "已完成 80% 的开发工作 @{ou_zhangsan} ",
|
||||
"mention": ["ou_zhangsan"],
|
||||
"docs": [],
|
||||
"images": []
|
||||
},
|
||||
"progress_rate": {
|
||||
"percent": 75.0,
|
||||
"status": "normal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
```
|
||||
|
||||
### `--style richtext` 输出示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"progress": {
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 10:30:00",
|
||||
"content": "{\"blocks\":[{\"block_element_type\":\"paragraph\",\"paragraph\":{\"elements\":[{\"paragraph_element_type\":\"textRun\",\"text_run\":{\"text\":\"已完成 80% 的开发工作 \"}},{\"paragraph_element_type\":\"mention\",\"mention\":{\"user_id\":\"ou_zhangsan\"}}]}}]}",
|
||||
"progress_rate": {
|
||||
"percent": 75.0,
|
||||
"status": "normal"
|
||||
}
|
||||
},
|
||||
"style": "richtext"
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `content` 字段是 JSON 字符串,为 OKR ContentBlock
|
||||
富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
- `content` 字段格式由 `--style` 控制:
|
||||
- `--style simple`(默认):`SemiPlainContent` 对象,包含 `text`、`mention`、`docs`、`images` 字段。`text` 中包含 `@{userID}` 占位符用于标识 mention 位置。
|
||||
- `--style richtext`:JSON 字符串,为 OKR ContentBlock 富文本格式
|
||||
- 请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解两种格式的详细信息。
|
||||
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -7,15 +7,16 @@
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 更新进展记录内容
|
||||
# 更新进展记录内容(默认 simple 风格,半纯文本格式)
|
||||
lark-cli okr +progress-update \
|
||||
--progress-id 1234567890123456789 \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的进展内容"}}]}}]}'
|
||||
--content '{"text":"更新后的进展内容","mention":["ou_123"]}'
|
||||
|
||||
# 更新进展记录内容并同时更新进度
|
||||
# 更新进展记录内容并同时更新进度(richtext 风格,完整 ContentBlock 格式)
|
||||
lark-cli okr +progress-update \
|
||||
--progress-id 1234567890123456789 \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"进度已更新至 90%"}}]}}]}' \
|
||||
--style richtext \
|
||||
--progress-percent 90 \
|
||||
--progress-status normal
|
||||
|
||||
@@ -27,7 +28,7 @@ lark-cli okr +progress-update \
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +progress-update \
|
||||
--progress-id 1234567890123456789 \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test"}}]}}]}' \
|
||||
--content '{"text":"test"}' \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
@@ -36,7 +37,8 @@ lark-cli okr +progress-update \
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|----------------------|----|-----------|----------------------------------------------------------------------------------------------------------------|
|
||||
| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) |
|
||||
| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--content` | 是 | — | 进展内容。根据 `--style` 指定格式:`simple` 风格为 SemiPlainContent JSON,`richtext` 风格为 ContentBlock JSON。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 |
|
||||
| `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 |
|
||||
| `--progress-status` | 否 | — | 进度状态:`normal`(正常) \| `overdue`(逾期) \| `done`(已完成)。仅在指定 `--progress-percent` 时生效。 |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
@@ -46,7 +48,9 @@ lark-cli okr +progress-update \
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+progress-get` 获取要更新的进展记录的 ID 和当前内容。
|
||||
2. 修改 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。
|
||||
2. 修改进展内容:
|
||||
- **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}`,mention 中提及的用户会统一连接在文本末尾。
|
||||
- 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。若需要插入图片/飞书文档或复杂文本格式,则必须使用 richtext 风格
|
||||
3. 执行 `lark-cli okr +progress-update --progress-id "..." --content "..."`。
|
||||
4. 报告结果:更新后的进展记录 ID、修改时间、进度百分比等。
|
||||
|
||||
|
||||
@@ -23,13 +23,14 @@ metadata:
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
@@ -40,13 +41,6 @@ metadata:
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
@@ -87,7 +81,6 @@ lark-cli auth login --domain slides
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
@@ -131,7 +124,7 @@ lark-cli auth login --domain slides
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
@@ -147,25 +140,15 @@ lark-cli auth login --domain slides
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 模板与脚本优先流程
|
||||
|
||||
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
|
||||
```
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 澄清主题、受众、页数、风格
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 生成结构化大纲供用户确认
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
- plan 字段、路径命名和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
@@ -212,8 +195,6 @@ lark-cli slides xml_presentation.slide create \
|
||||
```text
|
||||
[PPT 标题] — [定位描述],面向 [目标受众]
|
||||
|
||||
模板:[未使用模板 / <category>/<template>.xml(推荐原因)]
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
@@ -281,7 +262,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,912 +0,0 @@
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>员工培训</title>
|
||||
<theme>
|
||||
<background>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</background>
|
||||
<textStyles>
|
||||
<title fontColor="#000000FF" fontSize="48"/>
|
||||
<headline fontColor="#000000FF" fontSize="36"/>
|
||||
<sub-headline fontColor="#000000FF"/>
|
||||
<body fontColor="#000000FF"/>
|
||||
<caption fontColor="#808080FF" fontSize="14"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillImg src="AiubbdDtOosfbEx6K6Oc9jJPnhb" alpha="1" rotateWithShape="false"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="51" startY="457" endX="904" endY="456">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="300" height="74" topLeftX="51" topLeftY="351" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="18" color="rgba(255, 255, 255, 1)">
|
||||
<p>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="18">主讲人:李天天 </span>
|
||||
</p>
|
||||
<p>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="18">资深研究员,创业公司CEO</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="547" height="176" topLeftX="46" topLeftY="134" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="65" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="65">员工</span>
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="65">培训指南</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="202" height="41" topLeftX="51" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" autoFit="shape-auto-fit">
|
||||
<p>输入你的互联网公司</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="16" height="15" topLeftX="78" topLeftY="60" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="36" height="16" topLeftX="48" topLeftY="50" rotation="90" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="16" height="15" topLeftX="78" topLeftY="39" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="202" height="41" topLeftX="712" topLeftY="28" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
|
||||
<p list="none" textAlign="right">公司名字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="202" height="39" topLeftX="58" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="left" autoFit="shape-auto-fit">
|
||||
<p>输入互联网公司</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
|
||||
<p>2026年第一季度</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="60" startY="152" endX="540" endY="152" alpha="0.5">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="531" height="104" topLeftX="47" topLeftY="38" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="60" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.4">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="60">目录</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="310" height="260" topLeftX="60" topLeftY="203" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:2.3">
|
||||
<ul listStyle="circle-hollow-square">
|
||||
<li>
|
||||
<p lineSpacing="multiple:3">
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="16">培训目的</span>
|
||||
</strong>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p lineSpacing="multiple:3">
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="16">合同签订的注意事项</span>
|
||||
</strong>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p lineSpacing="multiple:3">
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="16">劳动争议风险</span>
|
||||
</strong>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p lineSpacing="multiple:3">
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="16">知识产权及商业机密风险</span>
|
||||
</strong>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p lineSpacing="multiple:3">
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="16">商业道德方面</span>
|
||||
</strong>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="51" startY="457" endX="904" endY="456">
|
||||
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
|
||||
<p>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
|
||||
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="32">培训目的</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
|
||||
<p>01</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
|
||||
<p>2026年第一季度</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="413" height="326" topLeftX="492" topLeftY="150" presetHandlers="8" flipX="true" alpha="0.2" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<shape width="413" height="326" topLeftX="59" topLeftY="150" presetHandlers="8" alpha="0.2" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<img src="JOdrbcqdUoQvVwxO3bpcScHynPJ" width="382" height="191" topLeftX="508" topLeftY="164">
|
||||
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0" presetHandlers="8"/>
|
||||
</img>
|
||||
<img src="PIvhbkqeaoY3O8xp9CEco4ZZnvg" width="382" height="191" topLeftX="75" topLeftY="164">
|
||||
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0" presetHandlers="8"/>
|
||||
</img>
|
||||
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>培训目的</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="382" height="56" topLeftX="507" topLeftY="411" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="382" height="56" topLeftX="75" topLeftY="411" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="382" height="44" topLeftX="508" topLeftY="374" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
|
||||
<p>提高自我防护意识和能力</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="382" height="44" topLeftX="75" topLeftY="374" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
|
||||
<p>增强公司员工法律意识以及法律观念</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="51" startY="457" endX="904" endY="456">
|
||||
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
|
||||
<p>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
|
||||
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="32">合同签订的注意事项</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
|
||||
<p>02</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
|
||||
<p>2026年第一季度</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="63" height="68" topLeftX="59" topLeftY="180" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" textAlign="left">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="32" fontFamily="Arial Black">01</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="63" height="68" topLeftX="60" topLeftY="342" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" textAlign="left">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="32" fontFamily="Arial Black">02</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>合同签订的注意事项</p>
|
||||
</content>
|
||||
</shape>
|
||||
<img src="A0G0btjSBoQYwwxHio7cjyTnnhh" width="344" height="300" topLeftX="561" topLeftY="164">
|
||||
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="22" bottomOffset="22" presetHandlers="12"/>
|
||||
</img>
|
||||
<shape width="341" height="74" topLeftX="59" topLeftY="396" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="341" height="74" topLeftX="59" topLeftY="234" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="411" height="44" topLeftX="115" topLeftY="358" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>签订合同过程中应注意的问题</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="411" height="44" topLeftX="110" topLeftY="195" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>合同签订前的调查工作</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="51" startY="457" endX="904" endY="456">
|
||||
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
|
||||
<p>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
|
||||
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="32">劳动争议风险</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
|
||||
<p>03</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
|
||||
<p>2026年第一季度</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="395" height="395" topLeftX="99" topLeftY="116" type="ellipse">
|
||||
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="6" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<shape width="369" height="369" topLeftX="113" topLeftY="129" type="ellipse">
|
||||
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<shape width="340" height="340" topLeftX="127" topLeftY="144" type="ellipse">
|
||||
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<shape width="395" height="395" topLeftX="465" topLeftY="116" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" width="6" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>劳动争议风险</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="236" height="74" topLeftX="545" topLeftY="306" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(212, 212, 212, 1)" bold="false" textAlign="center">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="236" height="74" topLeftX="179" topLeftY="305" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="395" height="49" topLeftX="465" topLeftY="250" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="21" color="rgba(212, 212, 212, 1)" bold="true" lineSpacing="multiple:1.35" textAlign="center">
|
||||
<p>合同内容与工资</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="395" height="49" topLeftX="99" topLeftY="250" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="21" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35" textAlign="center">
|
||||
<p>劳动合约签约的时间</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="258" height="287" topLeftX="645" topLeftY="165" presetHandlers="8" alpha="0.2" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<shape width="258" height="287" topLeftX="351" topLeftY="165" presetHandlers="8" alpha="0.2" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<shape width="258" height="287" topLeftX="57" topLeftY="165" presetHandlers="8" alpha="0.2" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<img src="Qd97bGCM1oeyAQxRUmvcPUJjnyb" width="231" height="116" topLeftX="658" topLeftY="181">
|
||||
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0.02" bottomOffset="0.02" presetHandlers="8"/>
|
||||
</img>
|
||||
<img src="Z5ldbgltxoBN0XxyNxVcV2tWnWb" width="231" height="116" topLeftX="364" topLeftY="181">
|
||||
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0.02" bottomOffset="0.02" presetHandlers="8"/>
|
||||
</img>
|
||||
<img src="YXs0befYaoW0X5xtARpcuGT0nCb" width="231" height="116" topLeftX="71" topLeftY="181">
|
||||
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0.02" bottomOffset="0.02" presetHandlers="8"/>
|
||||
</img>
|
||||
<shape width="231" height="92" topLeftX="658" topLeftY="355" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="231" height="92" topLeftX="364" topLeftY="355" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="231" height="92" topLeftX="71" topLeftY="355" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="258" height="44" topLeftX="645" topLeftY="313" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
|
||||
<p>解除劳动合同</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="258" height="44" topLeftX="351" topLeftY="313" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
|
||||
<p>工伤</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="258" height="44" topLeftX="57" topLeftY="313" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
|
||||
<p>合同约定内容</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>劳动争议风险</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="51" startY="457" endX="904" endY="456">
|
||||
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
|
||||
<p>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
|
||||
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="32">知识产权及商业机密风险</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
|
||||
<p>04</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
|
||||
<p>2026年第一季度</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="278" height="278" topLeftX="627" topLeftY="202" presetHandlers="8" alpha="0.5" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<line startX="649" startY="303" endX="883" endY="304" alpha="0.25">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<icon width="62" height="62" topLeftX="644" topLeftY="221" iconType="iconpark/Safe/protect.svg">
|
||||
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="2" lineJoin="miter" miterLimit="10"/>
|
||||
</icon>
|
||||
<shape width="278" height="278" topLeftX="59" topLeftY="202" presetHandlers="8" alpha="0.5" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<line startX="81" startY="303" endX="315" endY="304" alpha="0.25">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<icon width="62" height="62" topLeftX="76" topLeftY="221" iconType="iconpark/Clothes/bachelor-cap-one.svg">
|
||||
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="2" lineJoin="miter" miterLimit="10"/>
|
||||
</icon>
|
||||
<shape width="278" height="278" topLeftX="343" topLeftY="202" presetHandlers="8" alpha="0.5" type="round-rect">
|
||||
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
|
||||
</shape>
|
||||
<line startX="365" startY="303" endX="599" endY="304" alpha="0.25">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<icon width="62" height="62" topLeftX="360" topLeftY="221" iconType="iconpark/Edit/bring-to-front-one.svg">
|
||||
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="2" lineJoin="miter" miterLimit="10"/>
|
||||
</icon>
|
||||
<shape width="230" height="38" topLeftX="713" topLeftY="222" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>Topic 03</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="230" height="38" topLeftX="145" topLeftY="222" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>Topic 01</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>知识产权及商业机密风险</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="230" height="38" topLeftX="429" topLeftY="221" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>Topic 02</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="228" height="44" topLeftX="145" topLeftY="245" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>什么是知识产权?</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="237" height="92" topLeftX="649" topLeftY="324" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="237" height="92" topLeftX="361" topLeftY="324" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="237" height="92" topLeftX="78" topLeftY="324" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="228" height="44" topLeftX="713" topLeftY="245" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>风险防范</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="228" height="44" topLeftX="429" topLeftY="245" type="text">
|
||||
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>专利的定义</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="51" startY="457" endX="904" endY="456">
|
||||
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
|
||||
<p>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
|
||||
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="32">商业道德方面</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
|
||||
<p>05</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
|
||||
<p>2026年第一季度</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(13, 20, 32, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<line startX="64" startY="188" endX="185" endY="188">
|
||||
<border color="linear-gradient(90deg,rgba(16, 0, 81, 1) 0%,rgba(62, 0, 239, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<line startX="242" startY="188" endX="363" endY="188">
|
||||
<border color="linear-gradient(90deg,rgba(62, 0, 239, 1) 0%,rgba(48, 206, 196, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<line startX="414" startY="188" endX="535" endY="188">
|
||||
<border color="linear-gradient(90deg,rgba(48, 206, 196, 1) 0%,rgba(255, 122, 0, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<line startX="591" startY="188" endX="712" endY="188">
|
||||
<border color="linear-gradient(90deg,rgba(255, 122, 0, 1) 0%,rgba(152, 16, 174, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<line startX="770" startY="188" endX="891" endY="188">
|
||||
<border color="linear-gradient(90deg,rgba(152, 16, 174, 1) 0%,rgba(5, 0, 36, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="230" height="41" topLeftX="685" topLeftY="64" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14">
|
||||
<p>公司名字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>商业道德标准</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="132" height="200" topLeftX="765" topLeftY="254" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="132" height="200" topLeftX="586" topLeftY="254" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="132" height="200" topLeftX="409" topLeftY="254" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="132" height="200" topLeftX="237" topLeftY="254" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="132" height="200" topLeftX="59" topLeftY="254" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
|
||||
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="131" height="53" topLeftX="765" topLeftY="192" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>05</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="131" height="53" topLeftX="586" topLeftY="192" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>04</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="131" height="53" topLeftX="409" topLeftY="192" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>03</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="131" height="53" topLeftX="237" topLeftY="192" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>02</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="131" height="53" topLeftX="59" topLeftY="192" type="text">
|
||||
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
|
||||
<p>01</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillImg src="YBLRbyEjxo8hiIxsQaFcYLSmnQc" alpha="1" rotateWithShape="false"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<line startX="51" startY="457" endX="904" endY="456">
|
||||
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
|
||||
</line>
|
||||
<shape width="230" height="41" topLeftX="684" topLeftY="28" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14">
|
||||
<p>公司名字</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="678" height="116" topLeftX="236" topLeftY="302" type="text">
|
||||
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="80" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2" textAlign="right">
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgba(255, 255, 255, 1)" fontSize="80">谢谢观看</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="16" height="15" topLeftX="78" topLeftY="60" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="36" height="16" topLeftX="48" topLeftY="50" rotation="90" flipX="true" type="round2diag-rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="16" height="15" topLeftX="78" topLeftY="39" type="ellipse">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape width="185" height="38" topLeftX="51" topLeftY="461" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="left" autoFit="shape-auto-fit">
|
||||
<p list="none" textAlign="left">
|
||||
<span color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" fontSize="12" fontFamily="undefined" bold="false" italic="false" strikethrough="false" underline="false">A座32楼会议室</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="202" height="38" topLeftX="722" topLeftY="461" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
|
||||
<p list="none" textAlign="right">
|
||||
<span color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" fontSize="12" fontFamily="undefined" bold="false" italic="false" strikethrough="false" underline="false">2026年第一季度</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="202" height="42" topLeftX="712" topLeftY="28" type="text">
|
||||
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
|
||||
<p list="none" textAlign="right">公司名字</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user