@open-feishu-card/renderer 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/all.css CHANGED
@@ -384,14 +384,9 @@
384
384
  flex-shrink: 0; height: 1.2em; color: inherit;
385
385
  }
386
386
  .universal-card-button__icon .universal-card-icon { color: inherit; }
387
- /* default 宽度:按内容收缩。列是 flex column(默认 align-items:stretch),
388
- body 又有 > * { width:100% },不加这两条会被撑满所在列。 */
389
- .universal-card-button:not(.universal-card-button--block) {
390
- width: auto;
391
- max-width: 100%;
392
- align-self: flex-start;
393
- }
394
- /* fill(width:"fill"):撑满所在列 */
387
+ /* 宽度:对齐真机——body > * { width:100% }、列 flex stretch 会把按钮撑满所在行/列。
388
+ 不再强制 width:auto(那是网页版观感;真机独立成行的按钮默认占满整行)。
389
+ width:"fill" 显式挂 --block,与缺省撑满同宽,并供 margin calc 识别。 */
395
390
  .universal-card-button--block { width: 100%; }
396
391
 
397
392
  /* —— outlined 描边族 —— */
@@ -413,11 +408,12 @@
413
408
  }
414
409
  .universal-card-button--danger:not(.universal-card-button--static):hover { background: #fef0f0; }
415
410
 
416
- /* —— text 纯文字族(无边框/无底色,按内容宽) —— */
411
+ /* —— text 纯文字族(无边框/无底色;撑满时文字靠左,对齐真机) —— */
417
412
  .universal-card-button--text,
418
413
  .universal-card-button--primary_text,
419
414
  .universal-card-button--danger_text {
420
415
  border-color: transparent; background: transparent; padding: 4px 8px;
416
+ justify-content: flex-start;
421
417
  }
422
418
  .universal-card-button--text { color: #1f2329; }
423
419
  .universal-card-button--primary_text { color: #1456f0; }
package/dist/index.cjs CHANGED
@@ -392,16 +392,36 @@ var TABLE_HEAD_BG = {
392
392
  default: "transparent"
393
393
  };
394
394
  var CHART_PALETTE = [
395
- "#1664FF",
396
- "#1AC6FF",
397
- "#FF8A00",
398
- "#3CC780",
399
- "#7442D4",
400
- "#FFC400",
401
- "#304D77",
402
- "#B48DEB",
403
- "#009488",
404
- "#FF7DDA"
395
+ "#4e83fd",
396
+ "#50cefb",
397
+ "#935af6",
398
+ "#fad355",
399
+ "#f76964",
400
+ "#ffa53d",
401
+ "#82a7fc",
402
+ "#7edafb",
403
+ "#ad82f7",
404
+ "#f7dc82",
405
+ "#f98e8b",
406
+ "#ffba6b",
407
+ "#3370ff",
408
+ "#14c0ff",
409
+ "#7f3bf5",
410
+ "#ffc60a",
411
+ "#f54a45",
412
+ "#ff8800",
413
+ "#bacefd",
414
+ "#b1e8fc",
415
+ "#cdb2fa",
416
+ "#f8e6ab",
417
+ "#fbbfbc",
418
+ "#fed4a4",
419
+ "#245bdb",
420
+ "#049fd7",
421
+ "#6425d0",
422
+ "#dc9b04",
423
+ "#d83931",
424
+ "#de7802"
405
425
  ];
406
426
  var CHART_DEFAULT_ASPECT_RATIO = "16:9";
407
427
  var CHART_MIN_HEIGHT = 150;
@@ -686,6 +706,25 @@ function personAvatarSrc(person, ctx) {
686
706
  const suffix = ctx.options.avatarURLSuffix ?? PERSON_AVATAR_SUFFIX;
687
707
  return base + person.avatar_key + suffix;
688
708
  }
709
+ var HORIZONTAL_SPACING_PX = { small: 4, default: 8, large: 12 };
710
+ var VERTICAL_SPACING_PX = { default: 8, medium: 12, large: 16 };
711
+ function namedSpacingToPx(v, map) {
712
+ const named = map[v];
713
+ if (named != null) return named;
714
+ const m = /^(-?\d+(?:\.\d+)?)(?:px)?$/.exec(v.trim());
715
+ if (m) return Number(m[1]);
716
+ return map.default ?? 8;
717
+ }
718
+ function horizontalSpacingToPx(v) {
719
+ if (typeof v === "number") return v;
720
+ if (typeof v === "string") return namedSpacingToPx(v, HORIZONTAL_SPACING_PX);
721
+ return HORIZONTAL_SPACING_PX.default;
722
+ }
723
+ function verticalSpacingToPx(v) {
724
+ if (typeof v === "number") return v;
725
+ if (typeof v === "string") return namedSpacingToPx(v, VERTICAL_SPACING_PX);
726
+ return VERTICAL_SPACING_PX.default;
727
+ }
689
728
  function spacingToPx(v) {
690
729
  if (typeof v === "number") return v;
691
730
  if (typeof v === "string") {
@@ -1313,14 +1352,9 @@ var BASE_STYLES_CSS = `/**
1313
1352
  flex-shrink: 0; height: 1.2em; color: inherit;
1314
1353
  }
1315
1354
  .universal-card-button__icon .universal-card-icon { color: inherit; }
1316
- /* default \u5BBD\u5EA6\uFF1A\u6309\u5185\u5BB9\u6536\u7F29\u3002\u5217\u662F flex column\uFF08\u9ED8\u8BA4 align-items:stretch\uFF09\uFF0C
1317
- body \u53C8\u6709 > * { width:100% }\uFF0C\u4E0D\u52A0\u8FD9\u4E24\u6761\u4F1A\u88AB\u6491\u6EE1\u6240\u5728\u5217\u3002 */
1318
- .universal-card-button:not(.universal-card-button--block) {
1319
- width: auto;
1320
- max-width: 100%;
1321
- align-self: flex-start;
1322
- }
1323
- /* fill\uFF08width:"fill"\uFF09\uFF1A\u6491\u6EE1\u6240\u5728\u5217 */
1355
+ /* \u5BBD\u5EA6\uFF1A\u5BF9\u9F50\u771F\u673A\u2014\u2014body > * { width:100% }\u3001\u5217 flex stretch \u4F1A\u628A\u6309\u94AE\u6491\u6EE1\u6240\u5728\u884C/\u5217\u3002
1356
+ \u4E0D\u518D\u5F3A\u5236 width:auto\uFF08\u90A3\u662F\u7F51\u9875\u7248\u89C2\u611F\uFF1B\u771F\u673A\u72EC\u7ACB\u6210\u884C\u7684\u6309\u94AE\u9ED8\u8BA4\u5360\u6EE1\u6574\u884C\uFF09\u3002
1357
+ width:"fill" \u663E\u5F0F\u6302 --block\uFF0C\u4E0E\u7F3A\u7701\u6491\u6EE1\u540C\u5BBD\uFF0C\u5E76\u4F9B margin calc \u8BC6\u522B\u3002 */
1324
1358
  .universal-card-button--block { width: 100%; }
1325
1359
 
1326
1360
  /* \u2014\u2014 outlined \u63CF\u8FB9\u65CF \u2014\u2014 */
@@ -1342,11 +1376,12 @@ var BASE_STYLES_CSS = `/**
1342
1376
  }
1343
1377
  .universal-card-button--danger:not(.universal-card-button--static):hover { background: #fef0f0; }
1344
1378
 
1345
- /* \u2014\u2014 text \u7EAF\u6587\u5B57\u65CF\uFF08\u65E0\u8FB9\u6846/\u65E0\u5E95\u8272\uFF0C\u6309\u5185\u5BB9\u5BBD\uFF09 \u2014\u2014 */
1379
+ /* \u2014\u2014 text \u7EAF\u6587\u5B57\u65CF\uFF08\u65E0\u8FB9\u6846/\u65E0\u5E95\u8272\uFF1B\u6491\u6EE1\u65F6\u6587\u5B57\u9760\u5DE6\uFF0C\u5BF9\u9F50\u771F\u673A\uFF09 \u2014\u2014 */
1346
1380
  .universal-card-button--text,
1347
1381
  .universal-card-button--primary_text,
1348
1382
  .universal-card-button--danger_text {
1349
1383
  border-color: transparent; background: transparent; padding: 4px 8px;
1384
+ justify-content: flex-start;
1350
1385
  }
1351
1386
  .universal-card-button--text { color: #1f2329; }
1352
1387
  .universal-card-button--primary_text { color: #1456f0; }
@@ -4899,7 +4934,7 @@ function parseInline(text) {
4899
4934
  if (attrs.show_name != null) property.showName = attrs.show_name !== "false";
4900
4935
  if (attrs.show_avatar != null) property.showAvatar = attrs.show_avatar !== "false";
4901
4936
  if (attrs.size) property.size = attrs.size;
4902
- if (attrs.style === "capsule") property.pillShaped = true;
4937
+ if (attrs.style) property.style = attrs.style;
4903
4938
  out.push({ tag: "person_v1", property });
4904
4939
  i += m[0].length;
4905
4940
  continue;
@@ -5500,8 +5535,9 @@ function registerBuiltins() {
5500
5535
  const style = {};
5501
5536
  const pad = boxToStyle(p.padding);
5502
5537
  if (pad) style.padding = pad;
5538
+ if (!pad && ctx.bodyPaddingTop != null) style.paddingTop = ctx.bodyPaddingTop + "px";
5503
5539
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5504
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5540
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5505
5541
  const props = { className: `${CLS}-body` };
5506
5542
  if (Object.keys(style).length) props.style = style;
5507
5543
  return h("div", props, renderElements(getChildElements(el), ctx));
@@ -5511,7 +5547,8 @@ function registerBuiltins() {
5511
5547
  const tplKey = p.template || "default";
5512
5548
  const tpl = HEADER_TEMPLATE[tplKey] || HEADER_TEMPLATE.default;
5513
5549
  const tags = p.text_tag_list || p.textTagList;
5514
- const tagVNodes = Array.isArray(tags) && tags.length ? renderElements(tags, ctx) : [];
5550
+ const tagCtx = { ...ctx, textMetrics: { fontSize: 16, lineHeight: 24 } };
5551
+ const tagVNodes = Array.isArray(tags) && tags.length ? renderElements(tags.slice(0, 3), tagCtx) : [];
5515
5552
  const iconRaw = p.udIcon || p.icon;
5516
5553
  let iconVNode = null;
5517
5554
  if (iconRaw && iconRaw.tag) {
@@ -5521,6 +5558,21 @@ function registerBuiltins() {
5521
5558
  const inner = renderIcon(iconEl, ctx);
5522
5559
  if (inner) iconVNode = h("div", { className: `${CLS}-header__icon` }, inner);
5523
5560
  }
5561
+ if (!iconVNode) {
5562
+ const avatarEl = p.avatar;
5563
+ const avatarSrc = avatarEl && avatarEl.property ? resolveImageSrc(avatarEl.property, ctx) : null;
5564
+ if (avatarSrc) {
5565
+ iconVNode = h(
5566
+ "div",
5567
+ { className: `${CLS}-header__icon` },
5568
+ h("img", {
5569
+ src: avatarSrc,
5570
+ alt: "",
5571
+ style: { width: "16px", height: "16px", borderRadius: "4px", objectFit: "cover" }
5572
+ })
5573
+ );
5574
+ }
5575
+ }
5524
5576
  const titleVNode = renderHeaderTextLeaf(p.title, ctx, `${CLS}-header__text-title`) ?? h("div", { className: `${CLS}-header__text-title` });
5525
5577
  const subtitleVNode = renderHeaderTextLeaf(p.subtitle, ctx, `${CLS}-header__text-subtitle`);
5526
5578
  const textChildren = [
@@ -5559,7 +5611,7 @@ function registerBuiltins() {
5559
5611
  )
5560
5612
  );
5561
5613
  });
5562
- registerComponent("markdown", (el, ctx) => {
5614
+ function MarkdownComponent(el, ctx) {
5563
5615
  const p = el.property || {};
5564
5616
  const style = { ...alignToStyle(p.textAlign), ...textStyleToStyle(p.textStyle) };
5565
5617
  applyOuterMargin(style, p.margin);
@@ -5573,12 +5625,28 @@ function registerBuiltins() {
5573
5625
  style.lineHeight = lineHeight + "px";
5574
5626
  }
5575
5627
  const childCtx = { ...ctx, textMetrics: { fontSize, lineHeight } };
5628
+ const linesStyle = lineClampStyle(p.lines ?? p.textLines);
5576
5629
  return h(
5577
5630
  "div",
5578
5631
  { className: `${CLS}-markdown`, style },
5579
- h("div", { className: `${CLS}-markdown__children` }, renderElements(p.elements, childCtx))
5632
+ h("div", {
5633
+ className: `${CLS}-markdown__children`,
5634
+ style: Object.keys(linesStyle).length ? linesStyle : void 0
5635
+ }, renderElements(p.elements, childCtx))
5580
5636
  );
5581
- });
5637
+ }
5638
+ registerComponent("markdown", MarkdownComponent);
5639
+ registerComponent("markdown_v1", MarkdownComponent);
5640
+ function lineClampStyle(lines) {
5641
+ const n = Number(lines);
5642
+ if (!Number.isFinite(n) || n <= 0) return {};
5643
+ return {
5644
+ display: "-webkit-box",
5645
+ WebkitLineClamp: String(Math.floor(n)),
5646
+ WebkitBoxOrient: "vertical",
5647
+ overflow: "hidden"
5648
+ };
5649
+ }
5582
5650
  registerComponent("plain_text", (el) => {
5583
5651
  const p = el.property || {};
5584
5652
  const mods = textStyleToModifiers(p.textStyle);
@@ -5587,6 +5655,7 @@ function registerBuiltins() {
5587
5655
  delete style.fontWeight;
5588
5656
  delete style.fontStyle;
5589
5657
  delete style.textDecoration;
5658
+ Object.assign(style, lineClampStyle(p.lines));
5590
5659
  return h("span", { className, style }, p.content != null ? String(p.content) : "");
5591
5660
  });
5592
5661
  registerComponent("br", () => h("br", {}));
@@ -5611,16 +5680,16 @@ function registerBuiltins() {
5611
5680
  registerComponent("column_set", (el, ctx) => {
5612
5681
  const p = el.property || {};
5613
5682
  const gapRaw = p.horizontalSpacingValue?.value ?? p.horizontal_spacing;
5614
- const gap = gapRaw != null ? spacingToPx(gapRaw) : 8;
5683
+ const gap = gapRaw != null ? horizontalSpacingToPx(gapRaw) : 0;
5615
5684
  const flexMode = p.flexMode || p.flex_mode;
5616
5685
  const hAlign = p.horizontalAlign || p.horizontal_align;
5617
5686
  const style = {
5618
5687
  display: "flex",
5619
5688
  flexWrap: flexMode === "flow" || flexMode === "wrap" ? "wrap" : "nowrap",
5620
- columnGap: gap + "px",
5621
5689
  rowGap: "8px",
5622
5690
  alignItems: "stretch"
5623
5691
  };
5692
+ if (gap > 0) style.columnGap = gap + "px";
5624
5693
  if (hAlign) {
5625
5694
  style.justifyContent = { left: "flex-start", center: "center", right: "flex-end" }[hAlign] || "flex-start";
5626
5695
  }
@@ -5638,7 +5707,8 @@ function registerBuiltins() {
5638
5707
  style.margin = "0";
5639
5708
  style.padding = "0";
5640
5709
  }
5641
- return h("div", { className: cls, style }, renderElements(p.columns, ctx));
5710
+ const childCtx = { ...ctx, columnSet: { flexMode, gap } };
5711
+ return h("div", { className: cls, style }, renderElements(p.columns, childCtx));
5642
5712
  });
5643
5713
  function isStateHost(el) {
5644
5714
  if (el.id === "card_state") return true;
@@ -5673,12 +5743,30 @@ function registerBuiltins() {
5673
5743
  } else {
5674
5744
  style.flexGrow = String(p.weight || 1);
5675
5745
  }
5746
+ const cs = ctx.columnSet;
5747
+ if (cs && cs.flexMode) {
5748
+ const fm = String(cs.flexMode).toLowerCase();
5749
+ const g = cs.gap || 0;
5750
+ if (fm === "stretch") {
5751
+ style.width = "100%";
5752
+ style.flexGrow = "0";
5753
+ style.flexBasis = "auto";
5754
+ } else if (fm === "bisect") {
5755
+ style.width = `calc(50% - ${g / 2}px)`;
5756
+ style.flexGrow = "0";
5757
+ style.flexBasis = "auto";
5758
+ } else if (fm === "trisect") {
5759
+ style.width = `calc(33.33% - ${2 * g / 3}px)`;
5760
+ style.flexGrow = "0";
5761
+ style.flexBasis = "auto";
5762
+ }
5763
+ }
5676
5764
  const vAlign = p.verticalAlign || p.vertical_align;
5677
5765
  if (vAlign) {
5678
5766
  style.justifyContent = { top: "flex-start", center: "center", bottom: "flex-end" }[vAlign] || "flex-start";
5679
5767
  }
5680
5768
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5681
- style.rowGap = (vGap != null ? spacingToPx(vGap) : 8) + "px";
5769
+ style.rowGap = (vGap != null ? verticalSpacingToPx(vGap) : 8) + "px";
5682
5770
  const bg = propertyBackgroundColor(p);
5683
5771
  if (bg) style.background = bg;
5684
5772
  const pad = boxToStyle(p.padding);
@@ -5886,7 +5974,7 @@ function registerBuiltins() {
5886
5974
  const radius = resolveCornerRadius(p);
5887
5975
  if (radius != null) style.borderRadius = radius;
5888
5976
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5889
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5977
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5890
5978
  const icWv = p.widthValue;
5891
5979
  const icHv = p.heightValue;
5892
5980
  const w = resolveDimensionPx(p.width, icWv);
@@ -5988,14 +6076,13 @@ function registerBuiltins() {
5988
6076
  const children = iconVNode ? [iconVNode, label] : [label];
5989
6077
  const actionUrl = Array.isArray(p.actions) ? resolveURL(p.actions.find((a) => a.type === "open_url")?.action?.url) : "";
5990
6078
  const url = actionUrl || resolveURL(p.url) || p.behaviors?.[0]?.default_url?.url;
5991
- const isText = type === "text" || type === "primary_text" || type === "danger_text";
5992
6079
  const wv = p.widthValue;
5993
6080
  const widthTok = p.width ?? wv?.value;
5994
- const block = widthTok === "fill" && !isText;
6081
+ const block = widthTok === "fill";
5995
6082
  const base = `${CLS}-button ${CLS}-button--${type}${block ? ` ${CLS}-button--block` : ""}`;
5996
6083
  const style = {};
5997
6084
  if (p.margin) {
5998
- style.width = block ? "100%" : "auto";
6085
+ style.width = "100%";
5999
6086
  applyOuterMargin(style, p.margin);
6000
6087
  }
6001
6088
  const styleProp = Object.keys(style).length ? { style } : {};
@@ -6200,6 +6287,93 @@ function registerBuiltins() {
6200
6287
  const style = textTagStyle(p.backgroundColor || c.bg, c.text);
6201
6288
  return h("span", { className: `${CLS}-text-tag ${CLS}-text-tag--${colorName}`, style }, extractText(p.text));
6202
6289
  });
6290
+ registerComponent("local_datetime", (el) => {
6291
+ const p = el.property || {};
6292
+ let ms = Number(p.milliseconds ?? p.millisecond ?? p.value ?? 0);
6293
+ if (!Number.isFinite(ms) || ms === 0) return null;
6294
+ if (ms < 1e12) ms = ms * 1e3;
6295
+ const d = new Date(ms);
6296
+ if (Number.isNaN(d.getTime())) return null;
6297
+ const ft = String(p.formatType || p.format_type || "").toLowerCase();
6298
+ const opts = ft.includes("time") && !ft.includes("date") ? { hour: "2-digit", minute: "2-digit", hour12: false } : ft.includes("date") && !ft.includes("time") ? { year: "numeric", month: "2-digit", day: "2-digit" } : { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false };
6299
+ let text;
6300
+ try {
6301
+ text = new Intl.DateTimeFormat("zh-CN", opts).format(d);
6302
+ } catch {
6303
+ text = d.toLocaleString();
6304
+ }
6305
+ return h("span", { className: `${CLS}-local-datetime` }, text);
6306
+ });
6307
+ registerComponent("number_tag", (el, ctx) => {
6308
+ const p = el.property || {};
6309
+ const raw = extractText(p.text);
6310
+ const text = raw != null && raw !== "" ? raw : p.content != null ? String(p.content) : "";
6311
+ if (text === "") return null;
6312
+ const parentFs = ctx.textMetrics?.fontSize ?? BASE_FONT_SIZE;
6313
+ const fs = Math.max(11, Math.round(parentFs * 0.85));
6314
+ const c = TAG_COLOR.blue;
6315
+ const style = {
6316
+ backgroundColor: c.bg,
6317
+ color: c.text,
6318
+ fontSize: fs + "px",
6319
+ lineHeight: "1.4",
6320
+ padding: "0 4px",
6321
+ borderRadius: "4px",
6322
+ display: "inline-flex",
6323
+ alignItems: "center",
6324
+ whiteSpace: "nowrap",
6325
+ margin: "0 2px",
6326
+ verticalAlign: "baseline"
6327
+ };
6328
+ return h("span", { className: `${CLS}-number-tag`, style }, text);
6329
+ });
6330
+ registerComponent("vote-card-select", (el) => {
6331
+ const p = el.property || {};
6332
+ const text = extractText(p.text) || (p.content != null ? String(p.content) : "");
6333
+ if (!text) return null;
6334
+ const voted = p.voted === true || p.isVoted === true || p.selected === true;
6335
+ const count = Number(p.count ?? p.voteCount ?? 0) || 0;
6336
+ const total = Number(p.total ?? p.totalCount ?? 0) || 0;
6337
+ let percent = Number(p.percent ?? p.ratio ?? NaN);
6338
+ if (!Number.isFinite(percent)) {
6339
+ percent = total > 0 ? Math.round(count / total * 1e3) / 10 : 0;
6340
+ }
6341
+ const rootCls = `${CLS}-vote-card-select` + (voted ? ` ${CLS}-vote-card-select--voted` : "");
6342
+ const kids = [
6343
+ h("div", { className: `${CLS}-vote-card-select-content` }, text)
6344
+ ];
6345
+ if (voted) {
6346
+ kids.push(
6347
+ h("div", {
6348
+ className: `${CLS}-vote-card-select-result`,
6349
+ style: { fontSize: "12px", color: "#646a73", marginTop: "2px" }
6350
+ }, `${count} \u7968 ${percent}%`),
6351
+ h(
6352
+ "div",
6353
+ {
6354
+ className: `${CLS}-vote-card-select-progress`,
6355
+ style: {
6356
+ height: "4px",
6357
+ borderRadius: "2px",
6358
+ background: "#f2f3f5",
6359
+ marginTop: "4px",
6360
+ overflow: "hidden"
6361
+ }
6362
+ },
6363
+ h("div", {
6364
+ className: `${CLS}-vote-card-select-progress-bar`,
6365
+ style: {
6366
+ width: Math.min(100, Math.max(0, percent)) + "%",
6367
+ height: "100%",
6368
+ background: "#4e83fd",
6369
+ borderRadius: "2px"
6370
+ }
6371
+ })
6372
+ )
6373
+ );
6374
+ }
6375
+ return h("div", { className: rootCls, style: { padding: "8px 0" } }, kids);
6376
+ });
6203
6377
  const EMOJI_FONT_SIZE = Number((BASE_FONT_SIZE * BASE_LINE_HEIGHT).toFixed(2));
6204
6378
  registerComponent("emoji", (el, ctx) => {
6205
6379
  const p = el.property || {};
@@ -6849,7 +7023,12 @@ function buildCardVNode(card, options) {
6849
7023
  const parts = [];
6850
7024
  if (jc.header) parts.push(renderElement(jc.header, ctx));
6851
7025
  const bodyEl = jc.body || jc.newBody;
6852
- if (bodyEl) parts.push(renderElement(bodyEl.tag ? bodyEl : { ...bodyEl, tag: "body" }, ctx));
7026
+ if (bodyEl) {
7027
+ const headerTpl = jc.header?.property?.template;
7028
+ const compactHeader = !!jc.header && (!headerTpl || headerTpl === "default" || headerTpl === "disabled");
7029
+ const bodyCtx = compactHeader ? { ...ctx, bodyPaddingTop: 8 } : ctx;
7030
+ parts.push(renderElement(bodyEl.tag ? bodyEl : { ...bodyEl, tag: "body" }, bodyCtx));
7031
+ }
6853
7032
  return h("div", {
6854
7033
  className: `${CLS}-root ${CLS}`,
6855
7034
  style: { fontSize: BASE_FONT_SIZE + "px", lineHeight: String(BASE_LINE_HEIGHT) }
package/dist/index.d.cts CHANGED
@@ -149,13 +149,14 @@ interface ElementProperty {
149
149
  emoji_type?: string;
150
150
  /** 图标名,形如 "warning_outlined",拼成 class="ud-icon_<token>" */
151
151
  token?: string;
152
- style?: {
152
+ style?: string | {
153
153
  color?: string;
154
154
  size?: string;
155
155
  [key: string]: unknown;
156
156
  };
157
157
  icon?: CardElement;
158
158
  udIcon?: CardElement;
159
+ avatar?: CardElement;
159
160
  /** VChart 规格对象(原样透传给 VChart 构造) */
160
161
  chart_spec?: Record<string, unknown>;
161
162
  chartSpec?: Record<string, unknown>;
@@ -309,6 +310,15 @@ interface RenderOptions {
309
310
  * 交互预览。默认关闭:回调按钮是不可点的 span,输入框是静态占位。
310
311
  * 打开后,回调按钮变成带 data-feishu-action 的 button,输入框/下拉框变成真实表单控件。
311
312
  * 宿主监听这些控件,自己把点击发回卡片服务。
313
+ *
314
+ * #23/D13 —— 宿主上报契约(静态渲染器不替宿主发请求):
315
+ * 飞书客户端点击后组装 event_type="card.action.client_msg" 事件,并经
316
+ * im.v2.PutActionRequest(PUT_ACTION_V2,service id 2199)上报,信封必须自带:
317
+ * - messageId / actionId / traceId(宿主从消息上下文取)
318
+ * - form_value(表单控件当前值,key=字段 name)
319
+ * - client_channel / value(来自 data-feishu-action 上的 action 原文)
320
+ * 若宿主直接复用 data-feishu-action 里的 action 原文上报,会缺上述信封字段,
321
+ * 须自行补齐 PUT_ACTION_V2 信封后再发。
312
322
  */
313
323
  interactive?: boolean;
314
324
  }
@@ -425,6 +435,19 @@ interface RenderContext {
425
435
  fontSize: number;
426
436
  lineHeight: number;
427
437
  };
438
+ /**
439
+ * column_set 注入给子 column 的分栏模式(#17/D7):flexMode 为 stretch/bisect/trisect 时,
440
+ * 子列无视自身 weight,按客户端同款 calc 宽度(用实际 columnGap)。
441
+ */
442
+ columnSet?: {
443
+ flexMode?: string;
444
+ gap: number;
445
+ };
446
+ /**
447
+ * body 顶部 padding(#18/D8):header 存在且模板为 default/disabled/空时,
448
+ * 客户端把 body paddingTop 从 12 改为 8。由 index.ts 在渲染 body 前注入。
449
+ */
450
+ bodyPaddingTop?: number;
428
451
  }
429
452
  /** 组件渲染函数 */
430
453
  type ComponentFn = (el: CardElement, ctx: RenderContext) => VNode | null;
@@ -577,8 +600,9 @@ declare function feishuAudioHydrateJs(): string;
577
600
  */
578
601
 
579
602
  /**
580
- * 图表(chart)默认序列色板。取自 VChart 官方默认主题的分类色序列,
581
- * 与网页版飞书卡片图表观感一致。hydrate 时若 spec 未显式给 color,注入此序列。
603
+ * 图表(chart)默认序列色板。#21/D11:对齐飞书卡片图表 VChart light 主题
604
+ * brand.light.normal 的 30 色序列(首色 #4e83fd),而非 VChart 通用默认。
605
+ * hydrate 时若 spec 未显式给 color,注入此序列。
582
606
  */
583
607
  declare const CHART_PALETTE: string[];
584
608
 
package/dist/index.d.ts CHANGED
@@ -149,13 +149,14 @@ interface ElementProperty {
149
149
  emoji_type?: string;
150
150
  /** 图标名,形如 "warning_outlined",拼成 class="ud-icon_<token>" */
151
151
  token?: string;
152
- style?: {
152
+ style?: string | {
153
153
  color?: string;
154
154
  size?: string;
155
155
  [key: string]: unknown;
156
156
  };
157
157
  icon?: CardElement;
158
158
  udIcon?: CardElement;
159
+ avatar?: CardElement;
159
160
  /** VChart 规格对象(原样透传给 VChart 构造) */
160
161
  chart_spec?: Record<string, unknown>;
161
162
  chartSpec?: Record<string, unknown>;
@@ -309,6 +310,15 @@ interface RenderOptions {
309
310
  * 交互预览。默认关闭:回调按钮是不可点的 span,输入框是静态占位。
310
311
  * 打开后,回调按钮变成带 data-feishu-action 的 button,输入框/下拉框变成真实表单控件。
311
312
  * 宿主监听这些控件,自己把点击发回卡片服务。
313
+ *
314
+ * #23/D13 —— 宿主上报契约(静态渲染器不替宿主发请求):
315
+ * 飞书客户端点击后组装 event_type="card.action.client_msg" 事件,并经
316
+ * im.v2.PutActionRequest(PUT_ACTION_V2,service id 2199)上报,信封必须自带:
317
+ * - messageId / actionId / traceId(宿主从消息上下文取)
318
+ * - form_value(表单控件当前值,key=字段 name)
319
+ * - client_channel / value(来自 data-feishu-action 上的 action 原文)
320
+ * 若宿主直接复用 data-feishu-action 里的 action 原文上报,会缺上述信封字段,
321
+ * 须自行补齐 PUT_ACTION_V2 信封后再发。
312
322
  */
313
323
  interactive?: boolean;
314
324
  }
@@ -425,6 +435,19 @@ interface RenderContext {
425
435
  fontSize: number;
426
436
  lineHeight: number;
427
437
  };
438
+ /**
439
+ * column_set 注入给子 column 的分栏模式(#17/D7):flexMode 为 stretch/bisect/trisect 时,
440
+ * 子列无视自身 weight,按客户端同款 calc 宽度(用实际 columnGap)。
441
+ */
442
+ columnSet?: {
443
+ flexMode?: string;
444
+ gap: number;
445
+ };
446
+ /**
447
+ * body 顶部 padding(#18/D8):header 存在且模板为 default/disabled/空时,
448
+ * 客户端把 body paddingTop 从 12 改为 8。由 index.ts 在渲染 body 前注入。
449
+ */
450
+ bodyPaddingTop?: number;
428
451
  }
429
452
  /** 组件渲染函数 */
430
453
  type ComponentFn = (el: CardElement, ctx: RenderContext) => VNode | null;
@@ -577,8 +600,9 @@ declare function feishuAudioHydrateJs(): string;
577
600
  */
578
601
 
579
602
  /**
580
- * 图表(chart)默认序列色板。取自 VChart 官方默认主题的分类色序列,
581
- * 与网页版飞书卡片图表观感一致。hydrate 时若 spec 未显式给 color,注入此序列。
603
+ * 图表(chart)默认序列色板。#21/D11:对齐飞书卡片图表 VChart light 主题
604
+ * brand.light.normal 的 30 色序列(首色 #4e83fd),而非 VChart 通用默认。
605
+ * hydrate 时若 spec 未显式给 color,注入此序列。
582
606
  */
583
607
  declare const CHART_PALETTE: string[];
584
608
 
package/dist/index.js CHANGED
@@ -391,16 +391,36 @@ var TABLE_HEAD_BG = {
391
391
  default: "transparent"
392
392
  };
393
393
  var CHART_PALETTE = [
394
- "#1664FF",
395
- "#1AC6FF",
396
- "#FF8A00",
397
- "#3CC780",
398
- "#7442D4",
399
- "#FFC400",
400
- "#304D77",
401
- "#B48DEB",
402
- "#009488",
403
- "#FF7DDA"
394
+ "#4e83fd",
395
+ "#50cefb",
396
+ "#935af6",
397
+ "#fad355",
398
+ "#f76964",
399
+ "#ffa53d",
400
+ "#82a7fc",
401
+ "#7edafb",
402
+ "#ad82f7",
403
+ "#f7dc82",
404
+ "#f98e8b",
405
+ "#ffba6b",
406
+ "#3370ff",
407
+ "#14c0ff",
408
+ "#7f3bf5",
409
+ "#ffc60a",
410
+ "#f54a45",
411
+ "#ff8800",
412
+ "#bacefd",
413
+ "#b1e8fc",
414
+ "#cdb2fa",
415
+ "#f8e6ab",
416
+ "#fbbfbc",
417
+ "#fed4a4",
418
+ "#245bdb",
419
+ "#049fd7",
420
+ "#6425d0",
421
+ "#dc9b04",
422
+ "#d83931",
423
+ "#de7802"
404
424
  ];
405
425
  var CHART_DEFAULT_ASPECT_RATIO = "16:9";
406
426
  var CHART_MIN_HEIGHT = 150;
@@ -685,6 +705,25 @@ function personAvatarSrc(person, ctx) {
685
705
  const suffix = ctx.options.avatarURLSuffix ?? PERSON_AVATAR_SUFFIX;
686
706
  return base + person.avatar_key + suffix;
687
707
  }
708
+ var HORIZONTAL_SPACING_PX = { small: 4, default: 8, large: 12 };
709
+ var VERTICAL_SPACING_PX = { default: 8, medium: 12, large: 16 };
710
+ function namedSpacingToPx(v, map) {
711
+ const named = map[v];
712
+ if (named != null) return named;
713
+ const m = /^(-?\d+(?:\.\d+)?)(?:px)?$/.exec(v.trim());
714
+ if (m) return Number(m[1]);
715
+ return map.default ?? 8;
716
+ }
717
+ function horizontalSpacingToPx(v) {
718
+ if (typeof v === "number") return v;
719
+ if (typeof v === "string") return namedSpacingToPx(v, HORIZONTAL_SPACING_PX);
720
+ return HORIZONTAL_SPACING_PX.default;
721
+ }
722
+ function verticalSpacingToPx(v) {
723
+ if (typeof v === "number") return v;
724
+ if (typeof v === "string") return namedSpacingToPx(v, VERTICAL_SPACING_PX);
725
+ return VERTICAL_SPACING_PX.default;
726
+ }
688
727
  function spacingToPx(v) {
689
728
  if (typeof v === "number") return v;
690
729
  if (typeof v === "string") {
@@ -1312,14 +1351,9 @@ var BASE_STYLES_CSS = `/**
1312
1351
  flex-shrink: 0; height: 1.2em; color: inherit;
1313
1352
  }
1314
1353
  .universal-card-button__icon .universal-card-icon { color: inherit; }
1315
- /* default \u5BBD\u5EA6\uFF1A\u6309\u5185\u5BB9\u6536\u7F29\u3002\u5217\u662F flex column\uFF08\u9ED8\u8BA4 align-items:stretch\uFF09\uFF0C
1316
- body \u53C8\u6709 > * { width:100% }\uFF0C\u4E0D\u52A0\u8FD9\u4E24\u6761\u4F1A\u88AB\u6491\u6EE1\u6240\u5728\u5217\u3002 */
1317
- .universal-card-button:not(.universal-card-button--block) {
1318
- width: auto;
1319
- max-width: 100%;
1320
- align-self: flex-start;
1321
- }
1322
- /* fill\uFF08width:"fill"\uFF09\uFF1A\u6491\u6EE1\u6240\u5728\u5217 */
1354
+ /* \u5BBD\u5EA6\uFF1A\u5BF9\u9F50\u771F\u673A\u2014\u2014body > * { width:100% }\u3001\u5217 flex stretch \u4F1A\u628A\u6309\u94AE\u6491\u6EE1\u6240\u5728\u884C/\u5217\u3002
1355
+ \u4E0D\u518D\u5F3A\u5236 width:auto\uFF08\u90A3\u662F\u7F51\u9875\u7248\u89C2\u611F\uFF1B\u771F\u673A\u72EC\u7ACB\u6210\u884C\u7684\u6309\u94AE\u9ED8\u8BA4\u5360\u6EE1\u6574\u884C\uFF09\u3002
1356
+ width:"fill" \u663E\u5F0F\u6302 --block\uFF0C\u4E0E\u7F3A\u7701\u6491\u6EE1\u540C\u5BBD\uFF0C\u5E76\u4F9B margin calc \u8BC6\u522B\u3002 */
1323
1357
  .universal-card-button--block { width: 100%; }
1324
1358
 
1325
1359
  /* \u2014\u2014 outlined \u63CF\u8FB9\u65CF \u2014\u2014 */
@@ -1341,11 +1375,12 @@ var BASE_STYLES_CSS = `/**
1341
1375
  }
1342
1376
  .universal-card-button--danger:not(.universal-card-button--static):hover { background: #fef0f0; }
1343
1377
 
1344
- /* \u2014\u2014 text \u7EAF\u6587\u5B57\u65CF\uFF08\u65E0\u8FB9\u6846/\u65E0\u5E95\u8272\uFF0C\u6309\u5185\u5BB9\u5BBD\uFF09 \u2014\u2014 */
1378
+ /* \u2014\u2014 text \u7EAF\u6587\u5B57\u65CF\uFF08\u65E0\u8FB9\u6846/\u65E0\u5E95\u8272\uFF1B\u6491\u6EE1\u65F6\u6587\u5B57\u9760\u5DE6\uFF0C\u5BF9\u9F50\u771F\u673A\uFF09 \u2014\u2014 */
1345
1379
  .universal-card-button--text,
1346
1380
  .universal-card-button--primary_text,
1347
1381
  .universal-card-button--danger_text {
1348
1382
  border-color: transparent; background: transparent; padding: 4px 8px;
1383
+ justify-content: flex-start;
1349
1384
  }
1350
1385
  .universal-card-button--text { color: #1f2329; }
1351
1386
  .universal-card-button--primary_text { color: #1456f0; }
@@ -4898,7 +4933,7 @@ function parseInline(text) {
4898
4933
  if (attrs.show_name != null) property.showName = attrs.show_name !== "false";
4899
4934
  if (attrs.show_avatar != null) property.showAvatar = attrs.show_avatar !== "false";
4900
4935
  if (attrs.size) property.size = attrs.size;
4901
- if (attrs.style === "capsule") property.pillShaped = true;
4936
+ if (attrs.style) property.style = attrs.style;
4902
4937
  out.push({ tag: "person_v1", property });
4903
4938
  i += m[0].length;
4904
4939
  continue;
@@ -5499,8 +5534,9 @@ function registerBuiltins() {
5499
5534
  const style = {};
5500
5535
  const pad = boxToStyle(p.padding);
5501
5536
  if (pad) style.padding = pad;
5537
+ if (!pad && ctx.bodyPaddingTop != null) style.paddingTop = ctx.bodyPaddingTop + "px";
5502
5538
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5503
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5539
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5504
5540
  const props = { className: `${CLS}-body` };
5505
5541
  if (Object.keys(style).length) props.style = style;
5506
5542
  return h("div", props, renderElements(getChildElements(el), ctx));
@@ -5510,7 +5546,8 @@ function registerBuiltins() {
5510
5546
  const tplKey = p.template || "default";
5511
5547
  const tpl = HEADER_TEMPLATE[tplKey] || HEADER_TEMPLATE.default;
5512
5548
  const tags = p.text_tag_list || p.textTagList;
5513
- const tagVNodes = Array.isArray(tags) && tags.length ? renderElements(tags, ctx) : [];
5549
+ const tagCtx = { ...ctx, textMetrics: { fontSize: 16, lineHeight: 24 } };
5550
+ const tagVNodes = Array.isArray(tags) && tags.length ? renderElements(tags.slice(0, 3), tagCtx) : [];
5514
5551
  const iconRaw = p.udIcon || p.icon;
5515
5552
  let iconVNode = null;
5516
5553
  if (iconRaw && iconRaw.tag) {
@@ -5520,6 +5557,21 @@ function registerBuiltins() {
5520
5557
  const inner = renderIcon(iconEl, ctx);
5521
5558
  if (inner) iconVNode = h("div", { className: `${CLS}-header__icon` }, inner);
5522
5559
  }
5560
+ if (!iconVNode) {
5561
+ const avatarEl = p.avatar;
5562
+ const avatarSrc = avatarEl && avatarEl.property ? resolveImageSrc(avatarEl.property, ctx) : null;
5563
+ if (avatarSrc) {
5564
+ iconVNode = h(
5565
+ "div",
5566
+ { className: `${CLS}-header__icon` },
5567
+ h("img", {
5568
+ src: avatarSrc,
5569
+ alt: "",
5570
+ style: { width: "16px", height: "16px", borderRadius: "4px", objectFit: "cover" }
5571
+ })
5572
+ );
5573
+ }
5574
+ }
5523
5575
  const titleVNode = renderHeaderTextLeaf(p.title, ctx, `${CLS}-header__text-title`) ?? h("div", { className: `${CLS}-header__text-title` });
5524
5576
  const subtitleVNode = renderHeaderTextLeaf(p.subtitle, ctx, `${CLS}-header__text-subtitle`);
5525
5577
  const textChildren = [
@@ -5558,7 +5610,7 @@ function registerBuiltins() {
5558
5610
  )
5559
5611
  );
5560
5612
  });
5561
- registerComponent("markdown", (el, ctx) => {
5613
+ function MarkdownComponent(el, ctx) {
5562
5614
  const p = el.property || {};
5563
5615
  const style = { ...alignToStyle(p.textAlign), ...textStyleToStyle(p.textStyle) };
5564
5616
  applyOuterMargin(style, p.margin);
@@ -5572,12 +5624,28 @@ function registerBuiltins() {
5572
5624
  style.lineHeight = lineHeight + "px";
5573
5625
  }
5574
5626
  const childCtx = { ...ctx, textMetrics: { fontSize, lineHeight } };
5627
+ const linesStyle = lineClampStyle(p.lines ?? p.textLines);
5575
5628
  return h(
5576
5629
  "div",
5577
5630
  { className: `${CLS}-markdown`, style },
5578
- h("div", { className: `${CLS}-markdown__children` }, renderElements(p.elements, childCtx))
5631
+ h("div", {
5632
+ className: `${CLS}-markdown__children`,
5633
+ style: Object.keys(linesStyle).length ? linesStyle : void 0
5634
+ }, renderElements(p.elements, childCtx))
5579
5635
  );
5580
- });
5636
+ }
5637
+ registerComponent("markdown", MarkdownComponent);
5638
+ registerComponent("markdown_v1", MarkdownComponent);
5639
+ function lineClampStyle(lines) {
5640
+ const n = Number(lines);
5641
+ if (!Number.isFinite(n) || n <= 0) return {};
5642
+ return {
5643
+ display: "-webkit-box",
5644
+ WebkitLineClamp: String(Math.floor(n)),
5645
+ WebkitBoxOrient: "vertical",
5646
+ overflow: "hidden"
5647
+ };
5648
+ }
5581
5649
  registerComponent("plain_text", (el) => {
5582
5650
  const p = el.property || {};
5583
5651
  const mods = textStyleToModifiers(p.textStyle);
@@ -5586,6 +5654,7 @@ function registerBuiltins() {
5586
5654
  delete style.fontWeight;
5587
5655
  delete style.fontStyle;
5588
5656
  delete style.textDecoration;
5657
+ Object.assign(style, lineClampStyle(p.lines));
5589
5658
  return h("span", { className, style }, p.content != null ? String(p.content) : "");
5590
5659
  });
5591
5660
  registerComponent("br", () => h("br", {}));
@@ -5610,16 +5679,16 @@ function registerBuiltins() {
5610
5679
  registerComponent("column_set", (el, ctx) => {
5611
5680
  const p = el.property || {};
5612
5681
  const gapRaw = p.horizontalSpacingValue?.value ?? p.horizontal_spacing;
5613
- const gap = gapRaw != null ? spacingToPx(gapRaw) : 8;
5682
+ const gap = gapRaw != null ? horizontalSpacingToPx(gapRaw) : 0;
5614
5683
  const flexMode = p.flexMode || p.flex_mode;
5615
5684
  const hAlign = p.horizontalAlign || p.horizontal_align;
5616
5685
  const style = {
5617
5686
  display: "flex",
5618
5687
  flexWrap: flexMode === "flow" || flexMode === "wrap" ? "wrap" : "nowrap",
5619
- columnGap: gap + "px",
5620
5688
  rowGap: "8px",
5621
5689
  alignItems: "stretch"
5622
5690
  };
5691
+ if (gap > 0) style.columnGap = gap + "px";
5623
5692
  if (hAlign) {
5624
5693
  style.justifyContent = { left: "flex-start", center: "center", right: "flex-end" }[hAlign] || "flex-start";
5625
5694
  }
@@ -5637,7 +5706,8 @@ function registerBuiltins() {
5637
5706
  style.margin = "0";
5638
5707
  style.padding = "0";
5639
5708
  }
5640
- return h("div", { className: cls, style }, renderElements(p.columns, ctx));
5709
+ const childCtx = { ...ctx, columnSet: { flexMode, gap } };
5710
+ return h("div", { className: cls, style }, renderElements(p.columns, childCtx));
5641
5711
  });
5642
5712
  function isStateHost(el) {
5643
5713
  if (el.id === "card_state") return true;
@@ -5672,12 +5742,30 @@ function registerBuiltins() {
5672
5742
  } else {
5673
5743
  style.flexGrow = String(p.weight || 1);
5674
5744
  }
5745
+ const cs = ctx.columnSet;
5746
+ if (cs && cs.flexMode) {
5747
+ const fm = String(cs.flexMode).toLowerCase();
5748
+ const g = cs.gap || 0;
5749
+ if (fm === "stretch") {
5750
+ style.width = "100%";
5751
+ style.flexGrow = "0";
5752
+ style.flexBasis = "auto";
5753
+ } else if (fm === "bisect") {
5754
+ style.width = `calc(50% - ${g / 2}px)`;
5755
+ style.flexGrow = "0";
5756
+ style.flexBasis = "auto";
5757
+ } else if (fm === "trisect") {
5758
+ style.width = `calc(33.33% - ${2 * g / 3}px)`;
5759
+ style.flexGrow = "0";
5760
+ style.flexBasis = "auto";
5761
+ }
5762
+ }
5675
5763
  const vAlign = p.verticalAlign || p.vertical_align;
5676
5764
  if (vAlign) {
5677
5765
  style.justifyContent = { top: "flex-start", center: "center", bottom: "flex-end" }[vAlign] || "flex-start";
5678
5766
  }
5679
5767
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5680
- style.rowGap = (vGap != null ? spacingToPx(vGap) : 8) + "px";
5768
+ style.rowGap = (vGap != null ? verticalSpacingToPx(vGap) : 8) + "px";
5681
5769
  const bg = propertyBackgroundColor(p);
5682
5770
  if (bg) style.background = bg;
5683
5771
  const pad = boxToStyle(p.padding);
@@ -5885,7 +5973,7 @@ function registerBuiltins() {
5885
5973
  const radius = resolveCornerRadius(p);
5886
5974
  if (radius != null) style.borderRadius = radius;
5887
5975
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5888
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5976
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5889
5977
  const icWv = p.widthValue;
5890
5978
  const icHv = p.heightValue;
5891
5979
  const w = resolveDimensionPx(p.width, icWv);
@@ -5987,14 +6075,13 @@ function registerBuiltins() {
5987
6075
  const children = iconVNode ? [iconVNode, label] : [label];
5988
6076
  const actionUrl = Array.isArray(p.actions) ? resolveURL(p.actions.find((a) => a.type === "open_url")?.action?.url) : "";
5989
6077
  const url = actionUrl || resolveURL(p.url) || p.behaviors?.[0]?.default_url?.url;
5990
- const isText = type === "text" || type === "primary_text" || type === "danger_text";
5991
6078
  const wv = p.widthValue;
5992
6079
  const widthTok = p.width ?? wv?.value;
5993
- const block = widthTok === "fill" && !isText;
6080
+ const block = widthTok === "fill";
5994
6081
  const base = `${CLS}-button ${CLS}-button--${type}${block ? ` ${CLS}-button--block` : ""}`;
5995
6082
  const style = {};
5996
6083
  if (p.margin) {
5997
- style.width = block ? "100%" : "auto";
6084
+ style.width = "100%";
5998
6085
  applyOuterMargin(style, p.margin);
5999
6086
  }
6000
6087
  const styleProp = Object.keys(style).length ? { style } : {};
@@ -6199,6 +6286,93 @@ function registerBuiltins() {
6199
6286
  const style = textTagStyle(p.backgroundColor || c.bg, c.text);
6200
6287
  return h("span", { className: `${CLS}-text-tag ${CLS}-text-tag--${colorName}`, style }, extractText(p.text));
6201
6288
  });
6289
+ registerComponent("local_datetime", (el) => {
6290
+ const p = el.property || {};
6291
+ let ms = Number(p.milliseconds ?? p.millisecond ?? p.value ?? 0);
6292
+ if (!Number.isFinite(ms) || ms === 0) return null;
6293
+ if (ms < 1e12) ms = ms * 1e3;
6294
+ const d = new Date(ms);
6295
+ if (Number.isNaN(d.getTime())) return null;
6296
+ const ft = String(p.formatType || p.format_type || "").toLowerCase();
6297
+ const opts = ft.includes("time") && !ft.includes("date") ? { hour: "2-digit", minute: "2-digit", hour12: false } : ft.includes("date") && !ft.includes("time") ? { year: "numeric", month: "2-digit", day: "2-digit" } : { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false };
6298
+ let text;
6299
+ try {
6300
+ text = new Intl.DateTimeFormat("zh-CN", opts).format(d);
6301
+ } catch {
6302
+ text = d.toLocaleString();
6303
+ }
6304
+ return h("span", { className: `${CLS}-local-datetime` }, text);
6305
+ });
6306
+ registerComponent("number_tag", (el, ctx) => {
6307
+ const p = el.property || {};
6308
+ const raw = extractText(p.text);
6309
+ const text = raw != null && raw !== "" ? raw : p.content != null ? String(p.content) : "";
6310
+ if (text === "") return null;
6311
+ const parentFs = ctx.textMetrics?.fontSize ?? BASE_FONT_SIZE;
6312
+ const fs = Math.max(11, Math.round(parentFs * 0.85));
6313
+ const c = TAG_COLOR.blue;
6314
+ const style = {
6315
+ backgroundColor: c.bg,
6316
+ color: c.text,
6317
+ fontSize: fs + "px",
6318
+ lineHeight: "1.4",
6319
+ padding: "0 4px",
6320
+ borderRadius: "4px",
6321
+ display: "inline-flex",
6322
+ alignItems: "center",
6323
+ whiteSpace: "nowrap",
6324
+ margin: "0 2px",
6325
+ verticalAlign: "baseline"
6326
+ };
6327
+ return h("span", { className: `${CLS}-number-tag`, style }, text);
6328
+ });
6329
+ registerComponent("vote-card-select", (el) => {
6330
+ const p = el.property || {};
6331
+ const text = extractText(p.text) || (p.content != null ? String(p.content) : "");
6332
+ if (!text) return null;
6333
+ const voted = p.voted === true || p.isVoted === true || p.selected === true;
6334
+ const count = Number(p.count ?? p.voteCount ?? 0) || 0;
6335
+ const total = Number(p.total ?? p.totalCount ?? 0) || 0;
6336
+ let percent = Number(p.percent ?? p.ratio ?? NaN);
6337
+ if (!Number.isFinite(percent)) {
6338
+ percent = total > 0 ? Math.round(count / total * 1e3) / 10 : 0;
6339
+ }
6340
+ const rootCls = `${CLS}-vote-card-select` + (voted ? ` ${CLS}-vote-card-select--voted` : "");
6341
+ const kids = [
6342
+ h("div", { className: `${CLS}-vote-card-select-content` }, text)
6343
+ ];
6344
+ if (voted) {
6345
+ kids.push(
6346
+ h("div", {
6347
+ className: `${CLS}-vote-card-select-result`,
6348
+ style: { fontSize: "12px", color: "#646a73", marginTop: "2px" }
6349
+ }, `${count} \u7968 ${percent}%`),
6350
+ h(
6351
+ "div",
6352
+ {
6353
+ className: `${CLS}-vote-card-select-progress`,
6354
+ style: {
6355
+ height: "4px",
6356
+ borderRadius: "2px",
6357
+ background: "#f2f3f5",
6358
+ marginTop: "4px",
6359
+ overflow: "hidden"
6360
+ }
6361
+ },
6362
+ h("div", {
6363
+ className: `${CLS}-vote-card-select-progress-bar`,
6364
+ style: {
6365
+ width: Math.min(100, Math.max(0, percent)) + "%",
6366
+ height: "100%",
6367
+ background: "#4e83fd",
6368
+ borderRadius: "2px"
6369
+ }
6370
+ })
6371
+ )
6372
+ );
6373
+ }
6374
+ return h("div", { className: rootCls, style: { padding: "8px 0" } }, kids);
6375
+ });
6202
6376
  const EMOJI_FONT_SIZE = Number((BASE_FONT_SIZE * BASE_LINE_HEIGHT).toFixed(2));
6203
6377
  registerComponent("emoji", (el, ctx) => {
6204
6378
  const p = el.property || {};
@@ -6848,7 +7022,12 @@ function buildCardVNode(card, options) {
6848
7022
  const parts = [];
6849
7023
  if (jc.header) parts.push(renderElement(jc.header, ctx));
6850
7024
  const bodyEl = jc.body || jc.newBody;
6851
- if (bodyEl) parts.push(renderElement(bodyEl.tag ? bodyEl : { ...bodyEl, tag: "body" }, ctx));
7025
+ if (bodyEl) {
7026
+ const headerTpl = jc.header?.property?.template;
7027
+ const compactHeader = !!jc.header && (!headerTpl || headerTpl === "default" || headerTpl === "disabled");
7028
+ const bodyCtx = compactHeader ? { ...ctx, bodyPaddingTop: 8 } : ctx;
7029
+ parts.push(renderElement(bodyEl.tag ? bodyEl : { ...bodyEl, tag: "body" }, bodyCtx));
7030
+ }
6852
7031
  return h("div", {
6853
7032
  className: `${CLS}-root ${CLS}`,
6854
7033
  style: { fontSize: BASE_FONT_SIZE + "px", lineHeight: String(BASE_LINE_HEIGHT) }
package/dist/styles.css CHANGED
@@ -384,14 +384,9 @@
384
384
  flex-shrink: 0; height: 1.2em; color: inherit;
385
385
  }
386
386
  .universal-card-button__icon .universal-card-icon { color: inherit; }
387
- /* default 宽度:按内容收缩。列是 flex column(默认 align-items:stretch),
388
- body 又有 > * { width:100% },不加这两条会被撑满所在列。 */
389
- .universal-card-button:not(.universal-card-button--block) {
390
- width: auto;
391
- max-width: 100%;
392
- align-self: flex-start;
393
- }
394
- /* fill(width:"fill"):撑满所在列 */
387
+ /* 宽度:对齐真机——body > * { width:100% }、列 flex stretch 会把按钮撑满所在行/列。
388
+ 不再强制 width:auto(那是网页版观感;真机独立成行的按钮默认占满整行)。
389
+ width:"fill" 显式挂 --block,与缺省撑满同宽,并供 margin calc 识别。 */
395
390
  .universal-card-button--block { width: 100%; }
396
391
 
397
392
  /* —— outlined 描边族 —— */
@@ -413,11 +408,12 @@
413
408
  }
414
409
  .universal-card-button--danger:not(.universal-card-button--static):hover { background: #fef0f0; }
415
410
 
416
- /* —— text 纯文字族(无边框/无底色,按内容宽) —— */
411
+ /* —— text 纯文字族(无边框/无底色;撑满时文字靠左,对齐真机) —— */
417
412
  .universal-card-button--text,
418
413
  .universal-card-button--primary_text,
419
414
  .universal-card-button--danger_text {
420
415
  border-color: transparent; background: transparent; padding: 4px 8px;
416
+ justify-content: flex-start;
421
417
  }
422
418
  .universal-card-button--text { color: #1f2329; }
423
419
  .universal-card-button--primary_text { color: #1456f0; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-feishu-card/renderer",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "零框架依赖的飞书卡片渲染引擎:Web / DOM / SSR,输入 RAW 或公开 DSL,输出真实 DOM 或 HTML 字符串",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -28,7 +28,7 @@
28
28
  "./dist/*.cjs"
29
29
  ],
30
30
  "dependencies": {
31
- "@open-feishu-card/adapter": "0.3.17"
31
+ "@open-feishu-card/adapter": "0.3.18"
32
32
  },
33
33
  "devDependencies": {
34
34
  "tsup": "^8.3.5",