"""Helix 卡片设计语言 —— 一张飞书卡片长什么样,在这里决定。 这个模块**只有设计令牌,没有渲染**:状态、头色、徽章、图标、字号、间距。渲染在 ``notifications.service._build_interactive_card``,它消费这里的表。分开的理由是 前端的 [`FeishuCardPreview`](../../../web/src/pages/hr-tools/FeishuCardPreview.tsx) 要复刻同一套令牌 —— 一张表两处实现已经骗过一次 HR(卡片预览画橙色、真卡发蓝色), 所以这里是那张表的**唯一书面形式**,前端照抄它,不自己发明颜色。 ## 为什么是 schema 2.0 2026-09-07 之前所有卡片走飞书卡片 **v1**(``config.wide_screen_mode`` + 顶层 ``elements``)。v1 只有 markdown / hr / note / action 四种块,于是: * 头部只能「一行纯文字 + 一个颜色」 —— 没有副标题位、没有状态徽章位、没有图标; * 没有浅底信息盒,所以「续期 90 天」这种关键事实只能塞进正文散文里; * 长清单不能折叠(12 个角色就是 12 行); * 按钮不能通栏 —— v1 的 ``type: "primary"`` 在客户端渲染成**描边窄按钮**, 这就是为什么线上那张续期卡的「去审批」看起来像次要操作。 2.0 把这四样都给了。**逐个真发实测过的可用/不可用清单**(校验器一次只报一个错, 只能一个一个试出来): | 能力 | 2.0 | |---|---| | ``header.subtitle`` / ``text_tag_list`` / ``icon`` | ✅ | | ``column_set`` + ``background_style:"grey"`` (信息盒) | ✅ | | ``collapsible_panel`` (+ ``border.corner_radius``) | ✅ | | ``button`` + ``width:"fill"`` + ``behaviors`` | ✅ | | ``markdown`` + ``text_size`` / ``text_align`` | ✅ | | ``note`` 元素 | ❌ 已删 → 用 ``markdown`` + ``text_size:"notation"`` | | ``action`` 容器 | ❌ 已删 → 单按钮直接放,多按钮放 ``column_set`` | | ``column_set.padding`` / ``.corner_radius`` | ❌ (但 ``column.padding`` 合法) | | ``collapsible_panel.background_style`` | ❌ | ## 状态,不是颜色 旧代码按模板配**颜色**(``_CARD_HEADER_COLOR_BY_TEMPLATE``),于是「这张卡是什么 状态」和「它该是什么颜色」是两个各自维护的概念,徽章和图标无处挂靠。这里换成一个 概念:**状态**。状态决定头色 + 徽章 + 图标,一处改动三处一致。 回执(见 ``notifications.settlement``)就是同一张卡换一个状态重渲染 —— 这是把 状态而不是颜色作为一等概念的直接回报。 """ from __future__ import annotations from dataclasses import dataclass #: 卡片 JSON schema。飞书用它区分 v1 / 2.0 两套元素词汇表。 SCHEMA_VERSION = "2.0" # ── 语义状态 ──────────────────────────────────────────────── #: 有人在等你做一件事。全平台 action-bucket 卡片的默认态。 STATUS_PENDING = "pending" #: 通知你一件已经发生的事,不要求你动手。 STATUS_INFO = "info" #: 有截止日期的提醒(年假到期、盘点、证书续期)。 STATUS_REMINDER = "reminder" #: 需要留意但还没坏(SLA 将至、预算过半)。 STATUS_WARNING = "warning" #: 坏了(预算超支、同步失败、账单异常)。 STATUS_ALERT = "alert" #: 坏了且有时限(SLA 已破、离职闸门超期)。 STATUS_CRITICAL = "critical" #: 回执 —— 批准/通过。 STATUS_APPROVED = "approved" #: 回执 —— 做完了(非审批语义的完成)。 STATUS_DONE = "done" #: 回执 —— 驳回。 STATUS_REJECTED = "rejected" #: 回执 —— 到期自动关单。 STATUS_EXPIRED = "expired" #: 回执 —— 申请人自己撤回。 STATUS_WITHDRAWN = "withdrawn" #: 生日、周年、成就 —— 非运营语气。 STATUS_CELEBRATE = "celebrate" #: 回执类状态:卡片描述的那件事已经结束,卡上不该再有「去处理」按钮。 #: ``settlement`` 用它判断一个状态是否终态,别在别处重新列一遍。 SETTLED_STATUSES: frozenset[str] = frozenset({ STATUS_APPROVED, STATUS_DONE, STATUS_REJECTED, STATUS_EXPIRED, STATUS_WITHDRAWN, }) @dataclass(frozen=True, slots=True) class CardTone: """一个状态在卡片上的全部视觉后果。 ``color`` 是飞书 header 调色板里的值(blue / indigo / green / grey / orange / red / carmine / violet / turquoise / yellow / wathet)。 ``badge`` 是头部右侧那颗徽章的三语文案;``None`` = 不挂徽章(``info`` 态 故意不挂 —— 每张卡都有徽章等于没有徽章)。 ``icon`` 是飞书标准图标 token,``None`` 表示用 Helix logo。语义态(已批准、 告警)压过品牌:一张绿色「已批准」卡如果图标还是中性的 logo,就把颜色和徽章 刚建立起来的状态信号又丢掉了。品牌身份在聊天里由发送方头像承担,不需要每张卡 的头部再重复一次。 """ color: str badge: dict[str, str] | None = None badge_color: str | None = None icon: str | None = None #: 状态 → 视觉。改这张表等于改全平台每一张卡,徽章文案是员工读到的字,按三语写全。 TONE_BY_STATUS: dict[str, CardTone] = { STATUS_PENDING: CardTone( color="indigo", badge={"zh": "待处理", "en": "Action needed", "ja": "対応待ち"}, badge_color="orange", ), STATUS_INFO: CardTone(color="indigo"), STATUS_REMINDER: CardTone( color="orange", badge={"zh": "提醒", "en": "Reminder", "ja": "リマインド"}, badge_color="orange", ), STATUS_WARNING: CardTone( color="orange", badge={"zh": "注意", "en": "Heads-up", "ja": "要注意"}, badge_color="orange", icon="warning_outlined", ), STATUS_ALERT: CardTone( color="red", badge={"zh": "告警", "en": "Alert", "ja": "アラート"}, badge_color="red", icon="warning_outlined", ), STATUS_CRITICAL: CardTone( color="carmine", badge={"zh": "紧急", "en": "Urgent", "ja": "緊急"}, badge_color="red", icon="warning_outlined", ), STATUS_APPROVED: CardTone( color="green", badge={"zh": "已批准", "en": "Approved", "ja": "承認済み"}, badge_color="green", icon="done_outlined", ), STATUS_DONE: CardTone( color="green", badge={"zh": "已完成", "en": "Done", "ja": "完了"}, badge_color="green", icon="done_outlined", ), STATUS_REJECTED: CardTone( color="grey", badge={"zh": "已驳回", "en": "Declined", "ja": "却下"}, badge_color="grey", ), STATUS_EXPIRED: CardTone( color="grey", badge={"zh": "已过期", "en": "Expired", "ja": "期限切れ"}, badge_color="grey", ), STATUS_WITHDRAWN: CardTone( color="grey", badge={"zh": "已撤回", "en": "Withdrawn", "ja": "取り下げ"}, badge_color="grey", ), STATUS_CELEBRATE: CardTone(color="violet"), } #: 模板 → 状态。只列**偏离默认**的模板:action-bucket 的模板自动是 ``pending``, #: 其余自动是 ``info``(见 ``service._card_status``),所以这里不需要把全平台 #: 几百个模板都写一遍。 STATUS_BY_TEMPLATE: dict[str, str] = { "cloud.billing_alert": STATUS_ALERT, "apikey.budget_alert": STATUS_ALERT, "leaver_blocked_sla_warn": STATUS_WARNING, "leaver_blocked_sla_urgent": STATUS_ALERT, "leaver_blocked_sla_breach": STATUS_CRITICAL, "asset_inventory_reminder": STATUS_REMINDER, "asset_inventory_escalation": STATUS_WARNING, # 年假到期提醒:有硬截止日、过期即清零 —— 提醒态(橙)。HR 页面的卡片预览 # 也画橙色,两边必须一致,否则那个预览就是在骗 HR。 "hr_leave.expiry_reminder": STATUS_REMINDER, # Feed —— 庆祝类,和运营红橙刻意区分开。 "feed.blessing.received.birthday": STATUS_CELEBRATE, "feed.blessing.received.anniversary": STATUS_CELEBRATE, "feed.celebration.self.birthday": STATUS_CELEBRATE, "feed.celebration.self.anniversary": STATUS_CELEBRATE, "feed.celebration.manager.birthday": STATUS_CELEBRATE, "feed.celebration.manager.anniversary": STATUS_CELEBRATE, "feed.celebration.hrbp.birthday": STATUS_CELEBRATE, "feed.celebration.hrbp.anniversary": STATUS_CELEBRATE, "feed.comment.added": STATUS_INFO, "feed.mention.post": STATUS_INFO, "feed.mention.comment": STATUS_INFO, "hire_day_it_info.feishu_card": STATUS_INFO, "onboarding.day90_survey": STATUS_INFO, } #: 模板 → 头色**覆盖**。状态表已经给了颜色,这里只留那些「同一状态但故意用不同 #: 颜色」的:庆祝类卡片的暖色分级(生日 carmine / 周年 indigo / 主管周知 yellow / #: 社交互动 turquoise·violet)是一次刻意的设计,不能被 ``celebrate`` 一个紫色 #: 抹平。Day-1 入职卡的 indigo 要和欢迎邮件的深蓝报头呼应。 COLOR_BY_TEMPLATE: dict[str, str] = { "feed.blessing.received.birthday": "carmine", "feed.blessing.received.anniversary": "indigo", "feed.celebration.self.birthday": "carmine", "feed.celebration.self.anniversary": "indigo", "feed.celebration.manager.birthday": "yellow", "feed.celebration.manager.anniversary": "yellow", "feed.celebration.hrbp.birthday": "yellow", "feed.celebration.hrbp.anniversary": "yellow", "feed.comment.added": "turquoise", "feed.mention.post": "violet", "feed.mention.comment": "violet", "hire_day_it_info.feishu_card": "indigo", "onboarding.day90_survey": "turquoise", } # ── 版式常量 ──────────────────────────────────────────────── #: 事实盒里主体与右侧数字的权重比。3:1 让右侧数字有右对齐的余量,又不至于 #: 把主体挤成两行。 FACT_BOX_WEIGHTS = (3, 1) #: 事实盒的内边距。加在 ``column`` 上 —— ``column_set.padding`` 是非法属性。 FACT_BOX_PADDING = "12px 4px 12px 12px" #: 列间距,飞书接受的字符串形式。 COLUMN_GAP = "8px" #: 折叠面板的圆角。2.0 里只有 ``collapsible_panel.border`` 能给圆角。 PANEL_CORNER_RADIUS = "8px" #: 超过这么多行的清单折叠起来。三行以内直接铺开更好读,折叠反而多一次点击。 COLLAPSE_LINE_THRESHOLD = 3 #: 折叠面板收起时的箭头图标(2.0 标准图标 token)。 COLLAPSE_ICON = "down-small-ccm_outlined" #: 展开时箭头转的角度。-180 = 转成朝上。 COLLAPSE_EXPANDED_ANGLE = -180 #: 页脚字号。2.0 没有 ``note`` 元素了,小灰字靠 markdown 的 notation 字号。 FOOTER_TEXT_SIZE = "notation" #: 事实行标签与值之间的分隔。全角空格 —— 中日文里比半角空格更像一个字段间隔, #: 且不会被 markdown 折叠掉。 FACT_SEPARATOR = " " #: 事实行的默认图标。产生者不指定时按标签轮换会显得随机,所以默认无图标 —— #: 图标是**语义**,不是装饰;不知道该配哪个就不配。 FACT_ICONS: dict[str, str] = { "person": "👤", "reason": "📝", "time": "⏱", "money": "💰", "place": "📍", "action": "📌", "device": "💻", "cloud": "☁️", "warn": "⚠️", } def tone_for(status: str | None) -> CardTone: """状态 → 视觉。未知状态退回 ``info``(中性 indigo、无徽章)。 退回而不是抛错:一个拼错的状态值不该让整条通知发不出去 —— 卡片渲染路径上 每一个失败都是「员工没收到那件事」。 """ if not status: return TONE_BY_STATUS[STATUS_INFO] return TONE_BY_STATUS.get(status, TONE_BY_STATUS[STATUS_INFO]) def is_settled(status: str | None) -> bool: """这个状态是否表示「那件事已经结束」。""" return bool(status) and status in SETTLED_STATUSES __all__ = [ "COLLAPSE_EXPANDED_ANGLE", "COLLAPSE_ICON", "COLLAPSE_LINE_THRESHOLD", "COLOR_BY_TEMPLATE", "COLUMN_GAP", "FACT_BOX_PADDING", "FACT_BOX_WEIGHTS", "FACT_ICONS", "FACT_SEPARATOR", "FOOTER_TEXT_SIZE", "PANEL_CORNER_RADIUS", "SCHEMA_VERSION", "SETTLED_STATUSES", "STATUS_ALERT", "STATUS_APPROVED", "STATUS_BY_TEMPLATE", "STATUS_CELEBRATE", "STATUS_CRITICAL", "STATUS_DONE", "STATUS_EXPIRED", "STATUS_INFO", "STATUS_PENDING", "STATUS_REJECTED", "STATUS_REMINDER", "STATUS_WARNING", "STATUS_WITHDRAWN", "TONE_BY_STATUS", "CardTone", "is_settled", "tone_for", ]