@open-feishu-card/renderer 0.2.27 → 0.2.29

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/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") {
@@ -4895,7 +4934,7 @@ function parseInline(text) {
4895
4934
  if (attrs.show_name != null) property.showName = attrs.show_name !== "false";
4896
4935
  if (attrs.show_avatar != null) property.showAvatar = attrs.show_avatar !== "false";
4897
4936
  if (attrs.size) property.size = attrs.size;
4898
- if (attrs.style === "capsule") property.pillShaped = true;
4937
+ if (attrs.style) property.style = attrs.style;
4899
4938
  out.push({ tag: "person_v1", property });
4900
4939
  i += m[0].length;
4901
4940
  continue;
@@ -5496,8 +5535,9 @@ function registerBuiltins() {
5496
5535
  const style = {};
5497
5536
  const pad = boxToStyle(p.padding);
5498
5537
  if (pad) style.padding = pad;
5538
+ if (!pad && ctx.bodyPaddingTop != null) style.paddingTop = ctx.bodyPaddingTop + "px";
5499
5539
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5500
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5540
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5501
5541
  const props = { className: `${CLS}-body` };
5502
5542
  if (Object.keys(style).length) props.style = style;
5503
5543
  return h("div", props, renderElements(getChildElements(el), ctx));
@@ -5507,7 +5547,8 @@ function registerBuiltins() {
5507
5547
  const tplKey = p.template || "default";
5508
5548
  const tpl = HEADER_TEMPLATE[tplKey] || HEADER_TEMPLATE.default;
5509
5549
  const tags = p.text_tag_list || p.textTagList;
5510
- 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) : [];
5511
5552
  const iconRaw = p.udIcon || p.icon;
5512
5553
  let iconVNode = null;
5513
5554
  if (iconRaw && iconRaw.tag) {
@@ -5517,6 +5558,21 @@ function registerBuiltins() {
5517
5558
  const inner = renderIcon(iconEl, ctx);
5518
5559
  if (inner) iconVNode = h("div", { className: `${CLS}-header__icon` }, inner);
5519
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
+ }
5520
5576
  const titleVNode = renderHeaderTextLeaf(p.title, ctx, `${CLS}-header__text-title`) ?? h("div", { className: `${CLS}-header__text-title` });
5521
5577
  const subtitleVNode = renderHeaderTextLeaf(p.subtitle, ctx, `${CLS}-header__text-subtitle`);
5522
5578
  const textChildren = [
@@ -5555,7 +5611,7 @@ function registerBuiltins() {
5555
5611
  )
5556
5612
  );
5557
5613
  });
5558
- registerComponent("markdown", (el, ctx) => {
5614
+ function MarkdownComponent(el, ctx) {
5559
5615
  const p = el.property || {};
5560
5616
  const style = { ...alignToStyle(p.textAlign), ...textStyleToStyle(p.textStyle) };
5561
5617
  applyOuterMargin(style, p.margin);
@@ -5569,12 +5625,28 @@ function registerBuiltins() {
5569
5625
  style.lineHeight = lineHeight + "px";
5570
5626
  }
5571
5627
  const childCtx = { ...ctx, textMetrics: { fontSize, lineHeight } };
5628
+ const linesStyle = lineClampStyle(p.lines ?? p.textLines);
5572
5629
  return h(
5573
5630
  "div",
5574
5631
  { className: `${CLS}-markdown`, style },
5575
- 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))
5576
5636
  );
5577
- });
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
+ }
5578
5650
  registerComponent("plain_text", (el) => {
5579
5651
  const p = el.property || {};
5580
5652
  const mods = textStyleToModifiers(p.textStyle);
@@ -5583,6 +5655,7 @@ function registerBuiltins() {
5583
5655
  delete style.fontWeight;
5584
5656
  delete style.fontStyle;
5585
5657
  delete style.textDecoration;
5658
+ Object.assign(style, lineClampStyle(p.lines));
5586
5659
  return h("span", { className, style }, p.content != null ? String(p.content) : "");
5587
5660
  });
5588
5661
  registerComponent("br", () => h("br", {}));
@@ -5607,15 +5680,15 @@ function registerBuiltins() {
5607
5680
  registerComponent("column_set", (el, ctx) => {
5608
5681
  const p = el.property || {};
5609
5682
  const gapRaw = p.horizontalSpacingValue?.value ?? p.horizontal_spacing;
5610
- const gap = gapRaw != null ? spacingToPx(gapRaw) : 8;
5683
+ const gap = gapRaw != null ? horizontalSpacingToPx(gapRaw) : horizontalSpacingToPx("default");
5611
5684
  const flexMode = p.flexMode || p.flex_mode;
5612
5685
  const hAlign = p.horizontalAlign || p.horizontal_align;
5613
5686
  const style = {
5614
5687
  display: "flex",
5615
5688
  flexWrap: flexMode === "flow" || flexMode === "wrap" ? "wrap" : "nowrap",
5616
- columnGap: gap + "px",
5617
5689
  rowGap: "8px",
5618
- alignItems: "stretch"
5690
+ alignItems: "stretch",
5691
+ columnGap: gap + "px"
5619
5692
  };
5620
5693
  if (hAlign) {
5621
5694
  style.justifyContent = { left: "flex-start", center: "center", right: "flex-end" }[hAlign] || "flex-start";
@@ -5634,7 +5707,8 @@ function registerBuiltins() {
5634
5707
  style.margin = "0";
5635
5708
  style.padding = "0";
5636
5709
  }
5637
- 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));
5638
5712
  });
5639
5713
  function isStateHost(el) {
5640
5714
  if (el.id === "card_state") return true;
@@ -5669,12 +5743,30 @@ function registerBuiltins() {
5669
5743
  } else {
5670
5744
  style.flexGrow = String(p.weight || 1);
5671
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
+ }
5672
5764
  const vAlign = p.verticalAlign || p.vertical_align;
5673
5765
  if (vAlign) {
5674
5766
  style.justifyContent = { top: "flex-start", center: "center", bottom: "flex-end" }[vAlign] || "flex-start";
5675
5767
  }
5676
5768
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5677
- style.rowGap = (vGap != null ? spacingToPx(vGap) : 8) + "px";
5769
+ style.rowGap = (vGap != null ? verticalSpacingToPx(vGap) : 8) + "px";
5678
5770
  const bg = propertyBackgroundColor(p);
5679
5771
  if (bg) style.background = bg;
5680
5772
  const pad = boxToStyle(p.padding);
@@ -5882,7 +5974,7 @@ function registerBuiltins() {
5882
5974
  const radius = resolveCornerRadius(p);
5883
5975
  if (radius != null) style.borderRadius = radius;
5884
5976
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5885
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5977
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5886
5978
  const icWv = p.widthValue;
5887
5979
  const icHv = p.heightValue;
5888
5980
  const w = resolveDimensionPx(p.width, icWv);
@@ -6195,6 +6287,93 @@ function registerBuiltins() {
6195
6287
  const style = textTagStyle(p.backgroundColor || c.bg, c.text);
6196
6288
  return h("span", { className: `${CLS}-text-tag ${CLS}-text-tag--${colorName}`, style }, extractText(p.text));
6197
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
+ });
6198
6377
  const EMOJI_FONT_SIZE = Number((BASE_FONT_SIZE * BASE_LINE_HEIGHT).toFixed(2));
6199
6378
  registerComponent("emoji", (el, ctx) => {
6200
6379
  const p = el.property || {};
@@ -6844,7 +7023,12 @@ function buildCardVNode(card, options) {
6844
7023
  const parts = [];
6845
7024
  if (jc.header) parts.push(renderElement(jc.header, ctx));
6846
7025
  const bodyEl = jc.body || jc.newBody;
6847
- 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
+ }
6848
7032
  return h("div", {
6849
7033
  className: `${CLS}-root ${CLS}`,
6850
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") {
@@ -4894,7 +4933,7 @@ function parseInline(text) {
4894
4933
  if (attrs.show_name != null) property.showName = attrs.show_name !== "false";
4895
4934
  if (attrs.show_avatar != null) property.showAvatar = attrs.show_avatar !== "false";
4896
4935
  if (attrs.size) property.size = attrs.size;
4897
- if (attrs.style === "capsule") property.pillShaped = true;
4936
+ if (attrs.style) property.style = attrs.style;
4898
4937
  out.push({ tag: "person_v1", property });
4899
4938
  i += m[0].length;
4900
4939
  continue;
@@ -5495,8 +5534,9 @@ function registerBuiltins() {
5495
5534
  const style = {};
5496
5535
  const pad = boxToStyle(p.padding);
5497
5536
  if (pad) style.padding = pad;
5537
+ if (!pad && ctx.bodyPaddingTop != null) style.paddingTop = ctx.bodyPaddingTop + "px";
5498
5538
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5499
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5539
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5500
5540
  const props = { className: `${CLS}-body` };
5501
5541
  if (Object.keys(style).length) props.style = style;
5502
5542
  return h("div", props, renderElements(getChildElements(el), ctx));
@@ -5506,7 +5546,8 @@ function registerBuiltins() {
5506
5546
  const tplKey = p.template || "default";
5507
5547
  const tpl = HEADER_TEMPLATE[tplKey] || HEADER_TEMPLATE.default;
5508
5548
  const tags = p.text_tag_list || p.textTagList;
5509
- 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) : [];
5510
5551
  const iconRaw = p.udIcon || p.icon;
5511
5552
  let iconVNode = null;
5512
5553
  if (iconRaw && iconRaw.tag) {
@@ -5516,6 +5557,21 @@ function registerBuiltins() {
5516
5557
  const inner = renderIcon(iconEl, ctx);
5517
5558
  if (inner) iconVNode = h("div", { className: `${CLS}-header__icon` }, inner);
5518
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
+ }
5519
5575
  const titleVNode = renderHeaderTextLeaf(p.title, ctx, `${CLS}-header__text-title`) ?? h("div", { className: `${CLS}-header__text-title` });
5520
5576
  const subtitleVNode = renderHeaderTextLeaf(p.subtitle, ctx, `${CLS}-header__text-subtitle`);
5521
5577
  const textChildren = [
@@ -5554,7 +5610,7 @@ function registerBuiltins() {
5554
5610
  )
5555
5611
  );
5556
5612
  });
5557
- registerComponent("markdown", (el, ctx) => {
5613
+ function MarkdownComponent(el, ctx) {
5558
5614
  const p = el.property || {};
5559
5615
  const style = { ...alignToStyle(p.textAlign), ...textStyleToStyle(p.textStyle) };
5560
5616
  applyOuterMargin(style, p.margin);
@@ -5568,12 +5624,28 @@ function registerBuiltins() {
5568
5624
  style.lineHeight = lineHeight + "px";
5569
5625
  }
5570
5626
  const childCtx = { ...ctx, textMetrics: { fontSize, lineHeight } };
5627
+ const linesStyle = lineClampStyle(p.lines ?? p.textLines);
5571
5628
  return h(
5572
5629
  "div",
5573
5630
  { className: `${CLS}-markdown`, style },
5574
- 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))
5575
5635
  );
5576
- });
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
+ }
5577
5649
  registerComponent("plain_text", (el) => {
5578
5650
  const p = el.property || {};
5579
5651
  const mods = textStyleToModifiers(p.textStyle);
@@ -5582,6 +5654,7 @@ function registerBuiltins() {
5582
5654
  delete style.fontWeight;
5583
5655
  delete style.fontStyle;
5584
5656
  delete style.textDecoration;
5657
+ Object.assign(style, lineClampStyle(p.lines));
5585
5658
  return h("span", { className, style }, p.content != null ? String(p.content) : "");
5586
5659
  });
5587
5660
  registerComponent("br", () => h("br", {}));
@@ -5606,15 +5679,15 @@ function registerBuiltins() {
5606
5679
  registerComponent("column_set", (el, ctx) => {
5607
5680
  const p = el.property || {};
5608
5681
  const gapRaw = p.horizontalSpacingValue?.value ?? p.horizontal_spacing;
5609
- const gap = gapRaw != null ? spacingToPx(gapRaw) : 8;
5682
+ const gap = gapRaw != null ? horizontalSpacingToPx(gapRaw) : horizontalSpacingToPx("default");
5610
5683
  const flexMode = p.flexMode || p.flex_mode;
5611
5684
  const hAlign = p.horizontalAlign || p.horizontal_align;
5612
5685
  const style = {
5613
5686
  display: "flex",
5614
5687
  flexWrap: flexMode === "flow" || flexMode === "wrap" ? "wrap" : "nowrap",
5615
- columnGap: gap + "px",
5616
5688
  rowGap: "8px",
5617
- alignItems: "stretch"
5689
+ alignItems: "stretch",
5690
+ columnGap: gap + "px"
5618
5691
  };
5619
5692
  if (hAlign) {
5620
5693
  style.justifyContent = { left: "flex-start", center: "center", right: "flex-end" }[hAlign] || "flex-start";
@@ -5633,7 +5706,8 @@ function registerBuiltins() {
5633
5706
  style.margin = "0";
5634
5707
  style.padding = "0";
5635
5708
  }
5636
- 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));
5637
5711
  });
5638
5712
  function isStateHost(el) {
5639
5713
  if (el.id === "card_state") return true;
@@ -5668,12 +5742,30 @@ function registerBuiltins() {
5668
5742
  } else {
5669
5743
  style.flexGrow = String(p.weight || 1);
5670
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
+ }
5671
5763
  const vAlign = p.verticalAlign || p.vertical_align;
5672
5764
  if (vAlign) {
5673
5765
  style.justifyContent = { top: "flex-start", center: "center", bottom: "flex-end" }[vAlign] || "flex-start";
5674
5766
  }
5675
5767
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5676
- style.rowGap = (vGap != null ? spacingToPx(vGap) : 8) + "px";
5768
+ style.rowGap = (vGap != null ? verticalSpacingToPx(vGap) : 8) + "px";
5677
5769
  const bg = propertyBackgroundColor(p);
5678
5770
  if (bg) style.background = bg;
5679
5771
  const pad = boxToStyle(p.padding);
@@ -5881,7 +5973,7 @@ function registerBuiltins() {
5881
5973
  const radius = resolveCornerRadius(p);
5882
5974
  if (radius != null) style.borderRadius = radius;
5883
5975
  const vGap = p.verticalSpacingValue?.value ?? p.vertical_spacing;
5884
- if (vGap != null) style.rowGap = spacingToPx(vGap) + "px";
5976
+ if (vGap != null) style.rowGap = verticalSpacingToPx(vGap) + "px";
5885
5977
  const icWv = p.widthValue;
5886
5978
  const icHv = p.heightValue;
5887
5979
  const w = resolveDimensionPx(p.width, icWv);
@@ -6194,6 +6286,93 @@ function registerBuiltins() {
6194
6286
  const style = textTagStyle(p.backgroundColor || c.bg, c.text);
6195
6287
  return h("span", { className: `${CLS}-text-tag ${CLS}-text-tag--${colorName}`, style }, extractText(p.text));
6196
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
+ });
6197
6376
  const EMOJI_FONT_SIZE = Number((BASE_FONT_SIZE * BASE_LINE_HEIGHT).toFixed(2));
6198
6377
  registerComponent("emoji", (el, ctx) => {
6199
6378
  const p = el.property || {};
@@ -6843,7 +7022,12 @@ function buildCardVNode(card, options) {
6843
7022
  const parts = [];
6844
7023
  if (jc.header) parts.push(renderElement(jc.header, ctx));
6845
7024
  const bodyEl = jc.body || jc.newBody;
6846
- 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
+ }
6847
7031
  return h("div", {
6848
7032
  className: `${CLS}-root ${CLS}`,
6849
7033
  style: { fontSize: BASE_FONT_SIZE + "px", lineHeight: String(BASE_LINE_HEIGHT) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-feishu-card/renderer",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
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",