/** * 视图模版工具箱 —— 活动开发者写「呈现」时看到的世界(AM 07)。 * * 模版是**内容不是代码**:这里产出的一律是无脚本、无事件处理器、无外链的静态 HTML; * 数据由基座在推送时填(`data-slot` 取值、`data-rows` 逐条展开)。 * 容器是沙箱 iframe(`sandbox=""`),因此**样式必须自带** —— 页面不继承工作台的 CSS, * 也**不能引外部字体**(净化门拦 `src`/`href` 的 `//`),所以只用系统字族。 * * ── 版面取正式汇报件(2026-07-31 重做,对齐 PRD 原型 `jc-report`)──────────── * * 从前这里是「一个色条 + 几张灰卡」:能读,但不像一份**交得出去的件**。 * 人在这一屏上要签字、要拿去对账,它就该长成一份汇报件的样子: * * 题头(图标徽章 · 类型行 · 标题 · 状态章) * → 元信息一行(编号 / 客户 / 编制 …,竖线分隔,不做格子) * → 关键指标带(判据摊上台面,洗蓝渐变格) * → 编号章节(一 / 二 / 三,方徽 + 标题 + 细线 + 右侧结论签) * → 落款(署名 + 时刻) * * **色彩纪律:结构用品牌蓝,数据才用语义色。** 整屏只有一处渐变(题头徽章), * 绿/橙/红只出现在「这个数达标没达标」上 —— 满屏彩色等于没有重点。 * * 图标一律**内联 SVG**(lucide 线形,1.75 描边):沙箱里取不到图标字体, * 而 emoji 当图标是另一回事 —— 它在不同系统上是不同的画,还带情绪。 * * 六个活动共用这一套外壳:汇报面貌一致,不是每个活动各自发明一套。 */ export type Tone = 'brand' | 'ok' | 'warn' | 'danger' | 'case' | 'archive'; /** 主色一档一个 —— 题头徽章与结构线取它 */ const ACCENT: Readonly> = { brand: '#3370ff', ok: '#278717', warn: '#de7802', danger: '#d83931', case: '#7f3bf5', archive: '#46586a', }; /** 题头徽章的渐变收尾色 —— 与 PRD 的 `--ai-grad` 同一手法(蓝 → 紫) */ const ACCENT_B: Readonly> = { brand: '#7f3bf5', ok: '#0aa294', warn: '#f54a45', danger: '#a3231d', case: '#3370ff', archive: '#6a7590', }; /* ==================== 图标 ==================== */ /** * 内联 lucide 线形图标 —— **只收这套模版真正用得上的**。 * * 不做成「全量图标库」:模版是随包冻结的字符串,塞一百个图标就是给每一份报告 * 都背上一百个 path。要新图标就往这里加一条,加的时候顺手想想是不是真的需要。 */ const ICONS: Readonly> = { 'file-text': '', 'list-checks': '', database: '', search: '', coins: '', handshake: '', archive: '', 'shield-check': '', 'alert-triangle': '', 'circle-check': '', mail: '', workflow: '', package: '', gauge: '', users: '', send: '', 'mail-open': '', building: '', user: '', history: '', boxes: '', sheet: '', 'git-branch': '', }; /** 一枚图标 —— 认不出的名字回一个空盒,不摆一个错的图标充数 */ function ico(name: string, size = 14): string { const path = ICONS[name]; if (path === undefined) return ''; return `${path}`; } /* ==================== 骨架样式 ==================== */ /** 版面取正式汇报件;尺寸一律相对单位,窄栏也不横向溢出 */ const BASE_CSS = (a: string, b: string): string => ` /* * ── 版面**照 PRD 目标件的数值抄**(2026-08-01 重做)──────────────────── * * ~/coding/JCAI_PRD/docs/活动执行页/ 那两份是这个活动「办完该长什么样」的答案。 * 上一版我把「块」都搬齐了,却用了自己那套外壳:题头下多一段灰字摘要、 * 多一条元信息带、章节头是彩色方块编号 + 大标题、字号用 clamp 响应式。 * 结果是**块齐了、版面是两套**,用户一眼就说「跟我给你的完全不一样」。 * * 所以这一版把数值逐条抄过来:题头 16/20/13、章节头 11px/800/.12em + 细线 + 右签、 * 正文 12px、表头 10.5px、落款 13/20/15。**字号一律固定** —— * clamp 会让同一份件在不同宽度下长得不一样,而目标件只有一个样子。 */ :root { color-scheme: light; --a: ${a}; --a-b: ${b}; --a-wash: color-mix(in srgb, ${a} 7%, #fff); --a-soft: color-mix(in srgb, ${a} 20%, #fff); --brand: #3370ff; --brand-soft: #e1eaff; --brand-wash: #f0f4ff; --ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e; --line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb; --cardline: #e1e6ec; --ok: #278717; --ok-soft: #e8f8e4; --ok-line: #bfedb4; --warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5; --danger: #d83931; --danger-soft: #feeae9; --danger-line: #f9c1be; --teal: #0aa294; --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace; --shadow-brand: 0 6px 16px rgba(51,112,255,.22); } * { box-sizing: border-box; } /* * **报告本体就是工作区** —— 不在里面再画一张居中的小卡片。 * 容器(.jc-view-frame)已经是 100% 宽、白底、带圆角的那张卡。 */ body { margin: 0; padding: 0; font: 13px/1.6 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; color: var(--ink); background: #fff; -webkit-font-smoothing: antialiased; } .rp { width: 100%; background: #fff; overflow: hidden; } .ic { flex: none; vertical-align: -.14em; } /* ── 题头:徽章 + 类型行 + 标题 + 状态;下缘粗细双线 = 正式件 ── */ .hd { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); } .hd::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); } .hd::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, color-mix(in srgb, var(--a) 10%, transparent), transparent 70%); pointer-events: none; } .hd > * { position: relative; z-index: 1; } .hd .bdg { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: linear-gradient(135deg, var(--a), var(--a-b)); box-shadow: var(--shadow-brand); } .hd .tt { flex: 1; min-width: 0; } .hd .kick { font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); } .hd h1 { margin: 4px 0 0; font-size: 18px; font-weight: 800; letter-spacing: -.01em; line-height: 1.3; overflow-wrap: anywhere; } .hd h1 .ref { font-family: var(--mono); font-size: .88em; letter-spacing: -.02em; } .hd .stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 5px 9px 4px 14px; font-size: 14px; font-weight: 900; letter-spacing: .3em; color: var(--ok); opacity: .9; } .hd .stamp.bad { color: var(--danger); } .hd .live { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; padding: 4px 12px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--a); background: var(--a-wash); border: 1px solid var(--a-soft); } /* ── 正文区与章节头:**小标签 + 细线 + 右端小签**,没有编号方块 ── */ .bd { padding: 18px 20px 12px; } .sh { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 22px 0 11px; } .s:first-child .sh { margin-top: 0; } .sh b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); } .sh .ln { flex: 1; height: 1px; background: var(--line-soft); } .sh .aside { flex: none; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; } .sh .aside.ok { color: var(--ok); background: var(--ok-soft); } .sh .aside.bad { color: var(--danger); background: var(--danger-soft); } /* ── 关键指标带(要用才用;两份目标件都没有)── */ .stats { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line-soft); border: 1px solid var(--line-soft); border-radius: 10px; overflow: hidden; } .stat { flex: 1 1 140px; min-width: 0; padding: 10px 14px 11px; background: #fff; } .stat i { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .09em; color: var(--ink-4); } .stat b { display: block; margin-top: 3px; font-size: 14px; font-weight: 800; line-height: 1.5; overflow-wrap: anywhere; } /* ── 来件原文:青色左脊 = 收进来的东西 ── */ .lt { border: 1px solid var(--cardline); border-left: 3px solid var(--teal); border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: 0 1px 2px rgba(31,35,41,.05); } .lt .lh { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 11px; } .lt .dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; } .lt .lh .own { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 10.5px; color: #245bdb; } .lt .lh .own b { font-weight: 600; } .lt .lh .own i { font-style: normal; opacity: .55; } .lt .lh .own:has(b:empty) { display: none; } .lt .lh .at { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); } .lt .lm { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); } .lt .lm p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } .lt .lm i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); } .lt .lm b { font-weight: 400; font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; } .lt h4 { margin: 0 0 9px; font-size: 14px; font-weight: 800; line-height: 1.45; overflow-wrap: anywhere; } /* 正文原文照录:pre-wrap 保原始换行,不截断、不折叠 */ .lt .lb { margin: 0; font: inherit; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; } .lt .atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); } .lt .att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: #245bdb; } .lt .att b { font-family: var(--mono); font-weight: 400; font-size: 10.5px; } .lt .att span { color: var(--ink-4); } /* ── 要素卡:顶缘色棱 + 右上光晕 ── */ .facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; } .fact { position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0; padding: 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff; box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35); --t: var(--brand); --t-soft: rgba(51,112,255,.13); } .fact::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--t); } .fact::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--t-soft), transparent 70%); pointer-events: none; } .fact > * { position: relative; z-index: 1; } .fact.t-teal { --t: var(--teal); --t-soft: rgba(10,162,148,.15); } .fact.t-ok { --t: var(--ok); --t-soft: rgba(39,135,23,.14); } .fact.t-warn { --t: var(--warn-accent); --t-soft: rgba(255,136,0,.12); } .fact .k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--t); } .fact .k i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--t-soft); color: var(--t); } .fact .v { font-size: 14px; font-weight: 800; line-height: 1.35; overflow-wrap: anywhere; } .fact .v.mono { font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--ink-2); background: var(--t-soft); border-radius: 8px; padding: 3px 9px; display: inline-block; } .fact .n { margin-top: auto; font-size: 10.5px; line-height: 1.55; color: var(--ink-4); overflow-wrap: anywhere; } .fact .n b { color: var(--ink-2); font-weight: 700; } .fact .row { display: flex; align-items: center; gap: 10px; min-width: 0; } .fact .row > .col { min-width: 0; flex: 1; } .ava { flex: none; width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; color: #fff; background: linear-gradient(135deg, var(--a), var(--a-b)); border-radius: 12px; box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); } .ava.person { border-radius: 999px; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); } /* 大号计数 —— 照目标件:32px 等宽 */ .big { display: flex; align-items: baseline; gap: 6px; } .big > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; letter-spacing: -.04em; } .big > span { font-size: 11px; color: var(--ink-4); } /* 分段刻度条:一条数据一格,按值上色 */ .meter { display: flex; gap: 3px; margin: 0; padding: 0; list-style: none; min-height: 7px; } .meter > li { flex: 1 1 0; min-width: 4px; height: 7px; border-radius: 2px; background: var(--line); } .meter > li.v-a { background: var(--ok); } .meter > li.v-b { background: var(--brand); } .meter > li.v-c { background: var(--warn-accent); } .meter > li.v-0 { background: var(--ok); } .meter > li.v-1, .meter > li.v-2, .meter > li.v-3 { background: var(--danger); } .meter:empty { height: 7px; border-radius: 3px; background: repeating-linear-gradient(45deg, var(--line-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--line); } .mlg { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 8px; font-size: 11px; color: var(--ink-3); } .mlg span { display: inline-flex; align-items: center; gap: 6px; } .mlg i { width: 9px; height: 9px; border-radius: 2px; } .mlg i.v-a { background: var(--ok); } .mlg i.v-b { background: var(--brand); } .mlg i.v-c { background: var(--warn-accent); } .mlg b { font-weight: 800; color: var(--ink); font-family: var(--mono); } /* ── 表 ── */ .tw { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; } table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; } th { text-align: left; font-weight: 800; font-size: 10.5px; letter-spacing: .03em; color: var(--ink-3); padding: 8px 9px; background: var(--soft); border-bottom: 1px solid var(--line-soft); white-space: nowrap; } th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; } td { padding: 6px 9px; border-bottom: 1px solid var(--line-soft); color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; } tbody tr:last-child td { border-bottom: 0; } tbody tr:hover td { background: #f7f9fd; } /* 序号列由 CSS 计数器排 —— 模版里没有循环变量 */ tbody { counter-reset: rn; } td.rn { position: relative; width: 38px; padding-left: 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); } td.rn::before { counter-increment: rn; content: counter(rn); } td.num { white-space: nowrap; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; } td.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); overflow-wrap: break-word; } /* 空格子自己说话:必填红、选填灰;缺必填的整行浅红 + 行首红条 */ td.req:empty::after, td.opt:empty::after { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; } td.req:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; } td.opt:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); } tbody tr:has(td.req:empty) td { background: #fffbfb; } tbody tr:has(td.req:empty):hover td { background: #fff5f5; } tbody tr:has(td.req:empty) td.rn { color: var(--danger); font-weight: 800; } tbody tr:has(td.req:empty) td.rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); } /* 短标签出药丸,按值分档上色(data-class-of 投影) */ td.pill { white-space: nowrap; } td.pill > span { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; font-size: 11px; font-weight: 800; font-family: var(--mono); background: var(--soft); color: var(--ink-3); border: 1px solid var(--line); } td.pill > span.v-a { background: var(--ok-soft); color: var(--ok); border-color: var(--ok-line); } td.pill > span.v-b { background: var(--brand-wash); color: #245bdb; border-color: var(--brand-soft); } td.pill > span.v-c { background: var(--warn-soft); color: var(--warn); border-color: #ffd9a8; } td.pill > span.v-true { background: var(--ok-soft); color: var(--ok); border-color: var(--ok-line); width: auto; padding: 0 9px; } td.pill > span.v-false { width: auto; padding: 0 9px; } .mono { font-family: var(--mono); } .tag { display: inline-block; padding: 1.5px 9px; border-radius: 999px; font-size: 11px; font-weight: 700; background: var(--a-wash); color: var(--a); border: 1px solid var(--a-soft); } /* ── 说明:蓝底一条,摆在数据旁边 ── */ .note { display: flex; gap: 8px; align-items: flex-start; margin: 11px 0 0; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: #245bdb; } .note svg { flex: none; margin-top: 3px; } .note b { font-weight: 700; } /* 剔除行说明:虚线框、灰底 —— 与蓝底那条分得开(它说的是「没做什么」) */ .drop { display: flex; gap: 8px; align-items: flex-start; margin: 10px 0 0; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; line-height: 1.6; color: var(--ink-3); } .drop svg { flex: none; margin-top: 3px; color: var(--ink-4); } .drop b, .drop code { color: var(--ink-2); font-weight: 700; } .drop code { font-family: var(--mono); font-size: 10.5px; } /* ── 分步 / 阶段 ── */ .steps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 7px; } .steps li { display: flex; gap: 9px; align-items: flex-start; font-size: 12px; line-height: 1.65; color: var(--ink-2); } .steps .n { flex: none; width: 19px; height: 19px; margin-top: 1px; border-radius: 999px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: var(--a); background: var(--a-wash); border: 1px solid var(--a-soft); font-family: var(--mono); } .steps .t { font-weight: 700; } .steps .d { color: var(--ink-4); font-size: 11px; } /* ── 按序求值的规则表 ── */ .rules { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; } .rules > div { display: grid; grid-template-columns: 26px minmax(0, 1fr) minmax(0, 160px) minmax(0, 68px); gap: 10px; align-items: center; padding: 10px 14px; font-size: 12px; color: var(--ink-2); } .rules > div + div { border-top: 1px solid var(--line-soft); } .rules > .h { background: var(--soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); } .rules .no { font-family: var(--mono); font-weight: 800; color: var(--ink-4); } .rules .go { font-weight: 700; } .rules .go code { font-family: var(--mono); font-size: 11px; color: var(--a); margin-right: 5px; } .rules .cnt { font-family: var(--mono); font-weight: 800; text-align: right; } /* ── 落地动作 ── */ .acts { margin: 0; padding: 0; list-style: none; border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; } .acts li { display: flex; align-items: flex-start; gap: 10px; padding: 11px 14px; font-size: 12px; line-height: 1.65; color: var(--ink-3); } .acts li + li { border-top: 1px solid var(--line-soft); } .acts .m { flex: none; width: 20px; height: 20px; margin-top: 1px; border-radius: 999px; display: grid; place-items: center; color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-line); } .acts b { display: block; font-weight: 800; color: var(--ink); margin-bottom: 2px; } /* ── 结论章 ── */ .verdict { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 11px 0 0; padding: 11px 14px; border-radius: 10px; background: var(--ok-soft); border: 1px solid var(--ok-line); color: var(--ok); font-size: 13px; font-weight: 800; } .verdict.bad { background: var(--danger-soft); border-color: var(--danger-line); color: var(--danger); } .verdict.wait { background: var(--warn-soft); border-color: #f7d9ab; color: var(--warn); } .verdict span { font-weight: 500; font-size: 11.5px; color: var(--ink-2); } /* ── 落款 ── */ .foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; font-size: 10.5px; color: var(--ink-4); } .foot svg { color: var(--a); } .foot b { font-size: 11.5px; font-weight: 700; color: var(--ink-2); } .foot .ftag { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--a); background: #fff; border: 1px solid var(--a-soft); } @media (max-width: 1040px) { .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 560px) { .facts { grid-template-columns: 1fr; } .hd, .bd, .foot { padding-left: 13px; padding-right: 13px; } } `; /* ==================== 外壳 ==================== */ /** 状态章:办结盖印 / 在办亮灯 —— 两者都不写就没有 */ export type Stamp = | { readonly kind: 'stamp'; readonly text: string; readonly bad?: boolean } | { readonly kind: 'live'; readonly text: string }; /** * 一份汇报件的外壳 —— 题头 + 元信息 + 正文 + 落款。 * * `icon` 认 `ICONS` 里的名字(file-text / list-checks / database / search / coins / * handshake / archive / shield-check / package / gauge / mail / workflow / users / send); * 不给就按 tone 落一个默认的。 */ export function viewShell(input: { title: string; kicker: string; tone: Tone; body: string; /** * 标题后面那半截**由数据填**的取值路径 —— 一般是单号。 * 「询价受理需求」是件的名字,「MD14.1」才说得出是哪一单。 */ titleRef?: string; /** 题头徽章的图标名 */ icon?: string; /** 右上角状态章 */ stamp?: Stamp; /** 落款一句(署名与出处);不给就落一句通用的 */ sign?: string; /** * 落款右端那枚小签 —— 这份件在流里是什么位置(「A1 受理归一 · 交下游」)。 * **写位置,不写状态**:归档件与在办件是同一张模版。 */ footTag?: string; }): string { const a = ACCENT[input.tone]; const b = ACCENT_B[input.tone]; const badge = ico(input.icon ?? DEFAULT_ICON[input.tone], 18); const stamp = input.stamp === undefined ? '' : input.stamp.kind === 'stamp' ? `${input.stamp.text}` : `${ico('gauge', 12)}${input.stamp.text}`; return `
${badge}
${input.kicker}

${input.title}${input.titleRef === undefined ? '' : ` `}

${stamp}
${input.body}
${ico('file-text', 12)}${input.sign ?? '本页由活动按当下局面生成'} · 放行前的一切都还改得动${ input.footTag === undefined ? '' : `${ico('git-branch', 12)}${input.footTag}` }
`; } const DEFAULT_ICON: Readonly> = { brand: 'file-text', ok: 'circle-check', warn: 'alert-triangle', danger: 'alert-triangle', case: 'workflow', archive: 'archive', }; /* ==================== 件 ==================== */ /** * 关键指标带 —— **判据摊上台面**。 * 摆的是人做判断要看的那三五个数,不是所有数;摆多了就没有「关键」可言。 */ export function statsBand(items: ReadonlyArray): string { return `
${items .map(([label, path]) => `
${label}
`) .join('')}
`; } /** * 章节头 —— **小号加粗标签 + 一条细线 + 右端小签**,照 PRD 目标件。 * * 从前这里是「彩色方块编号(一/二/三)+ 大标题」。那是我自己发明的一套: * 编号在一份两三屏的件里没有指路作用(人不会说「翻到第四节」), * 而方块 + 大标题把每一节都拔成一个新的开始 —— 一份件被切成了五份。 * 目标件用的是**分隔而非分段**:一行小字压住,右端一枚 mono 小签说这一段的口径。 */ export function section(title: string, body: string, aside?: { readonly text: string; readonly tone?: 'ok' | 'bad' }): string { const pill = aside === undefined ? '' : `${aside.text}`; return `
${title}${pill}
${body}
`; } export type ColStyle = | 'mono' | 'num' | 'pill' | 'req' | 'opt' | 'req-mono' | 'opt-mono' | 'req-num' | 'opt-num'; const COL_CLASS: Readonly> = { mono: 'mono', num: 'num', pill: 'pill', req: 'req', opt: 'opt', 'req-mono': 'req mono', 'opt-mono': 'opt mono', 'req-num': 'req num', 'opt-num': 'opt num', }; /** * 逐条表:行数不写死,由 `data-rows` 展开。 * * `numbered` 给一列序号 —— 由 CSS 计数器排,模版里没有循环变量。 * 有了行号,「第 9 条缺年用量」才指得出是哪一行。 */ export function tableOnly( rowsPath: string, columns: ReadonlyArray, options?: { readonly numbered?: boolean }, ): string { const numbered = options?.numbered === true; const head = (numbered ? '' : '') + columns .map(([label, , style]) => `${label}`) .join(''); const cells = (numbered ? '' : '') + columns .map(([, path, style]) => { // 药丸要有个套得住的壳:槽位填的是文本,`td` 自己当不了药丸;颜色按值分档 if (style === 'pill') return ``; const cls = style === undefined ? '' : ` class="${COL_CLASS[style]}"`; // 必填 / 选填这两档要**真的空**才标得出来,所以带 data-blank const blank = style !== undefined && (style.startsWith('req') || style.startsWith('opt')) ? ' data-blank' : ''; return ``; }) .join(''); return `
${head}${cells}
`; } /** 只要那一条说明 */ export function noteOnly(html: string): string { return `
${ico('shield-check', 13)}${html}
`; } /** 阶段时间线:loop 按案例模型的阶段与里程碑组织汇报(AM 07) */ export function stagesOnly(rowsPath: string): string { return `
  • ·
`; } /** * **来件原文** —— 这一单是被什么叫起来的。 * * 归一出来的每一个字段都得回溯得到出处;把原文摆在归一结果**上面**, * 人核对时不必切页签、不必去翻沟通记录 —— 一屏之内看得见「客户原话是这么写的」。 * 正文 `pre-wrap` 原样照录:不摘要、不折叠。摘要过的原文就不是原文了。 */ export function letter(input: { /** 谁发来的(取值路径) */ from: string; /** 主题(取值路径) */ subject: string; /** 收件人(取值路径)—— 投给哪个信箱;不给就不摆这一行 */ to?: string; /** 正文(取值路径) */ body: string; /** 附件数组的取值路径 —— 每件取 `name` 与 `note` */ attachments?: string; /** 左上角那枚方向签,默认「收件」 */ direction?: string; }): string { const atts = input.attachments === undefined ? '' : `
${ico('sheet', 12)}
`; /* * 入站时刻由基座填(`case:openedAt`,起单即入站)—— * 一封信摆在这儿而不说几时到的,人核 SLA 时得另开一屏去找。 */ const to = input.to === undefined ? '' : `

收件人

`; /* * 归属签 —— 这一单**怎么来的、归了谁**(`case:origin` / `case:owner`,基座填)。 * 活动写不出也不该写:让被指派的人自己说自己是谁,那不叫归属解析。 */ return `
${ico('mail-open', 12)}${input.direction ?? '收件'}→

发件人

${to}

${atts}
`; } /** * **分段刻度条** —— 一条数据一格,按值上色(`data-class-of`)。 * * 原型里那条是「按条数分宽的分布条」,宽度得算,而模版不许算。 * 改成一条料一格:同类相邻自然连成一段,看着等价,而且每一格都对得上清单里的一行 —— * 数得出来,不是一根示意的色条。 * * `legend` 是图例:`[色档, 标签, 取值路径]`,把「A 5 条」这类计数摆在条子下面。 */ export function meter( rowsPath: string, valueField: string, legend?: ReadonlyArray, ): string { /* * 格子里**不填字**:只用 `data-class-of` 把值投影成颜色。 * 带上 `data-slot` 的下场实测见过 —— 九个格子里排着「0 0 0 0 0 0 0 0 1」, * 一条刻度条成了一串数字,目标件那条是纯色块。 */ const bar = `
`; if (legend === undefined || legend.length === 0) return bar; const keys = legend .map( ([tone, label, path]) => `${label} `, ) .join(''); return `${bar}
${keys}
`; } /** 大号计数 —— 一屏之内最该被一眼看见的那个数(`unit` 是它的分母或量纲) */ export function bigCount(path: string, unit: string): string { return `${unit}`; } /** * **按序求值的规则表** —— 三条互斥条件与各自的去向,连着「本单是几」一起摆。 * * 命中哪一条由编排层求值,这里**不画对勾替它宣布**;但把条件、去向、本单的计数 * 摆在同一行上,人自己一眼就对得出来 —— 这比一句「三条按序求值」有用得多。 */ export function ruleTable( rules: ReadonlyArray<{ readonly no: string; readonly when: string; readonly node: string; readonly to: string; readonly count: string }>, ): string { const head = '
序条件(按序求值)去向本单
'; const rows = rules .map( (r) => `
${r.no}${r.when}` + `${r.node}${r.to}
`, ) .join(''); return `
${head}${rows}
`; } /** 要素卡的色调 —— 静态,不随数据变(数据判不了达标的事,别让颜色替它说) */ export type FactTone = 'brand' | 'teal' | 'ok' | 'warn'; /** * **要素带** —— 这一单的几件事,一件一张卡。 * * 与关键指标带的分别:指标带摆的是**数**(判据),要素带摆的是**谁与什么** * (客户、联系人、属性、成品量)。人接手一单先认这几件,再看数对不对。 */ export function factCards( items: ReadonlyArray<{ /** 卡头那一行小字 */ readonly label: string; /** 主值的取值路径 */ readonly value: string; /** 卡脚一句(HTML,可含 `data-slot`) */ readonly note?: string; readonly icon?: string; readonly tone?: FactTone; /** 主值用等宽(邮箱、编码这类) */ readonly mono?: boolean; /** * 左侧圆标:`org` 是圆角方(组织)、`person` 是圆(人)—— 方与圆一眼分得开。 * 标里放的是**图标不是首字母**:首字母得算,而模版不许算; * 画一个算错的缩写,不如画一个说得清类别的图标。 */ readonly avatar?: 'org' | 'person'; /** 给了就把主值摆成大号计数,这里是它的分母或量纲(如「/ 10 行 BOM」) */ readonly big?: string; /** 主值下面再挂一件(刻度条这类) */ readonly extra?: string; }>, ): string { return `
${items .map((f) => { const value = f.big === undefined ? `` : bigCount(f.value, f.big); const body = f.avatar === undefined ? value : `
` + `${ico(f.avatar === 'person' ? 'user' : 'building', 17)}
${value}
`; return ( `
` + `${ico(f.icon ?? 'file-text', 11)}${f.label}` + `${body}${f.extra ?? ''}` + `${f.note === undefined ? '' : `${f.note}`}
` ); }) .join('')}
`; } /** * **落地动作** —— 放行那一刻系统替人做了什么,一条一行。 * * 放行是草稿态与事实态之间的那道门。门后发生的事人看不见(合并、发事件、起下游单), * 而他要为这些事负责 —— 所以得逐条写出来,不能只留一句「已放行」。 */ export function actionList(items: ReadonlyArray<{ readonly title: string; readonly detail: string }>): string { return `
    ${items .map((a) => `
  • ${ico('circle-check', 12)}${a.title}${a.detail}
  • `) .join('')}
`; } /** 结论章 —— 一屏只该有一个 */ export function verdict(text: string, tone: 'ok' | 'bad' | 'wait', detail?: string): string { const cls = tone === 'ok' ? '' : ` ${tone}`; const mark = tone === 'ok' ? 'circle-check' : tone === 'bad' ? 'alert-triangle' : 'gauge'; return `

${ico(mark, 15)}${text}${detail === undefined ? '' : `${detail}`}

`; }