This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/packages/activity-sdk/src/view.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 13:41:44 +08:00

708 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 视图模版工具箱 —— 活动开发者写「呈现」时看到的世界(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<Record<Tone, string>> = {
brand: '#3370ff',
ok: '#278717',
warn: '#de7802',
danger: '#d83931',
case: '#7f3bf5',
archive: '#46586a',
};
/** 题头徽章的渐变收尾色 —— 与 PRD 的 `--ai-grad` 同一手法(蓝 → 紫) */
const ACCENT_B: Readonly<Record<Tone, string>> = {
brand: '#7f3bf5',
ok: '#0aa294',
warn: '#f54a45',
danger: '#a3231d',
case: '#3370ff',
archive: '#6a7590',
};
/* ==================== 图标 ==================== */
/**
* 内联 lucide 线形图标 —— **只收这套模版真正用得上的**。
*
* 不做成「全量图标库」:模版是随包冻结的字符串,塞一百个图标就是给每一份报告
* 都背上一百个 path。要新图标就往这里加一条,加的时候顺手想想是不是真的需要。
*/
const ICONS: Readonly<Record<string, string>> = {
'file-text':
'<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
'list-checks':
'<path d="M3 5h1l1.5 1.5L8 4"/><path d="M3 12h1l1.5 1.5L8 10"/><path d="M3 19h1l1.5 1.5L8 17"/><path d="M12 5h9"/><path d="M12 12h9"/><path d="M12 19h9"/>',
database:
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
coins:
'<circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/>',
handshake:
'<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.9-3.9a2 2 0 0 1 0-2.8L17 5"/><path d="M21 3 9.6 14.4a2 2 0 0 1-2.8 0l-2.4-2.4a2 2 0 0 1 0-2.8L7 6"/>',
archive:
'<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
'shield-check': '<path d="M20 13c0 5-3.5 7.5-8 9-4.5-1.5-8-4-8-9V6l8-3 8 3Z"/><path d="m9 12 2 2 4-4"/>',
'alert-triangle':
'<path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'circle-check': '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m2 7 8.9 5.3a2 2 0 0 0 2.2 0L22 7"/>',
workflow:
'<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
package:
'<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
users:
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/>',
'mail-open':
'<path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/>',
building:
'<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/>',
user: '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
boxes:
'<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
sheet:
'<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/>',
'git-branch': '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
};
/** 一枚图标 —— 认不出的名字回一个空盒,不摆一个错的图标充数 */
function ico(name: string, size = 14): string {
const path = ICONS[name];
if (path === undefined) return '';
return `<svg class="ic" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
}
/* ==================== 骨架样式 ==================== */
/** 版面取正式汇报件;尺寸一律相对单位,窄栏也不横向溢出 */
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'
? `<span class="stamp${input.stamp.bad === true ? ' bad' : ''}">${input.stamp.text}</span>`
: `<span class="live">${ico('gauge', 12)}${input.stamp.text}</span>`;
return `<style>${BASE_CSS(a, b)}</style>
<div class="rp">
<div class="hd">
<span class="bdg">${badge}</span>
<div class="tt">
<div class="kick">${input.kicker}</div>
<h1>${input.title}${input.titleRef === undefined ? '' : ` <span class="ref" data-slot="${input.titleRef}"></span>`}</h1>
</div>
${stamp}
</div>
<div class="bd">${input.body}</div>
<div class="foot">${ico('file-text', 12)}<b>${input.sign ?? '本页由活动按当下局面生成'}</b> · 放行前的一切都还改得动${
input.footTag === undefined ? '' : `<span class="ftag">${ico('git-branch', 12)}${input.footTag}</span>`
}</div>
</div>`;
}
const DEFAULT_ICON: Readonly<Record<Tone, string>> = {
brand: 'file-text',
ok: 'circle-check',
warn: 'alert-triangle',
danger: 'alert-triangle',
case: 'workflow',
archive: 'archive',
};
/* ==================== 件 ==================== */
/**
* 关键指标带 —— **判据摊上台面**。
* 摆的是人做判断要看的那三五个数,不是所有数;摆多了就没有「关键」可言。
*/
export function statsBand(items: ReadonlyArray<readonly [string, string]>): string {
return `<div class="stats">${items
.map(([label, path]) => `<div class="stat"><i>${label}</i><b data-slot="${path}"></b></div>`)
.join('')}</div>`;
}
/**
* 章节头 —— **小号加粗标签 + 一条细线 + 右端小签**,照 PRD 目标件。
*
* 从前这里是「彩色方块编号(一/二/三)+ 大标题」。那是我自己发明的一套:
* 编号在一份两三屏的件里没有指路作用(人不会说「翻到第四节」),
* 而方块 + 大标题把每一节都拔成一个新的开始 —— 一份件被切成了五份。
* 目标件用的是**分隔而非分段**:一行小字压住,右端一枚 mono 小签说这一段的口径。
*/
export function section(title: string, body: string, aside?: { readonly text: string; readonly tone?: 'ok' | 'bad' }): string {
const pill =
aside === undefined
? ''
: `<span class="aside${aside.tone === undefined ? '' : ` ${aside.tone}`}">${aside.text}</span>`;
return `<div class="s"><div class="sh"><b>${title}</b><span class="ln"></span>${pill}</div>${body}</div>`;
}
export type ColStyle =
| 'mono'
| 'num'
| 'pill'
| 'req'
| 'opt'
| 'req-mono'
| 'opt-mono'
| 'req-num'
| 'opt-num';
const COL_CLASS: Readonly<Record<ColStyle, string>> = {
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<readonly [string, string, ColStyle?]>,
options?: { readonly numbered?: boolean },
): string {
const numbered = options?.numbered === true;
const head =
(numbered ? '<th class="rn"></th>' : '') +
columns
.map(([label, , style]) => `<th${style !== undefined && style.startsWith('req') ? ' class="req"' : ''}>${label}</th>`)
.join('');
const cells =
(numbered ? '<td class="rn"></td>' : '') +
columns
.map(([, path, style]) => {
// 药丸要有个套得住的壳:槽位填的是文本,`td` 自己当不了药丸;颜色按值分档
if (style === 'pill') return `<td class="pill"><span data-class-of="${path}" data-slot="${path}"></span></td>`;
const cls = style === undefined ? '' : ` class="${COL_CLASS[style]}"`;
// 必填 / 选填这两档要**真的空**才标得出来,所以带 data-blank
const blank = style !== undefined && (style.startsWith('req') || style.startsWith('opt')) ? ' data-blank' : '';
return `<td${cls}${blank} data-slot="${path}"></td>`;
})
.join('');
return `<div class="tw"><table><thead><tr>${head}</tr></thead><tbody data-rows="${rowsPath}"><tr>${cells}</tr></tbody></table></div>`;
}
/** 只要那一条说明 */
export function noteOnly(html: string): string {
return `<div class="note">${ico('shield-check', 13)}<span>${html}</span></div>`;
}
/** 阶段时间线:loop 按案例模型的阶段与里程碑组织汇报(AM 07) */
export function stagesOnly(rowsPath: string): string {
return `<ul class="steps" data-rows="${rowsPath}"><li><span class="n">·</span><span><span class="t" data-slot="name"></span> <span class="d" data-slot="state"></span></span></li></ul>`;
}
/**
* **来件原文** —— 这一单是被什么叫起来的。
*
* 归一出来的每一个字段都得回溯得到出处;把原文摆在归一结果**上面**,
* 人核对时不必切页签、不必去翻沟通记录 —— 一屏之内看得见「客户原话是这么写的」。
* 正文 `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
? ''
: `<div class="atts" data-rows="${input.attachments}"><span class="att">${ico('sheet', 12)}<b data-slot="name"></b><span data-slot="note"></span></span></div>`;
/*
* 入站时刻由基座填(`case:openedAt`,起单即入站)——
* 一封信摆在这儿而不说几时到的,人核 SLA 时得另开一屏去找。
*/
const to = input.to === undefined ? '' : `<p><i>收件人</i><b data-slot="${input.to}"></b></p>`;
/*
* 归属签 —— 这一单**怎么来的、归了谁**(`case:origin` / `case:owner`,基座填)。
* 活动写不出也不该写:让被指派的人自己说自己是谁,那不叫归属解析。
*/
return `<div class="lt">
<div class="lh"><span class="dir">${ico('mail-open', 12)}${input.direction ?? '收件'}</span><span class="own"><b data-slot="case:origin" data-blank></b><i>→</i><b data-slot="case:owner" data-blank></b></span><code class="at" data-slot="case:openedAt" data-blank></code></div>
<div class="lm"><p><i>发件人</i><b data-slot="${input.from}"></b></p>${to}</div>
<h4 data-slot="${input.subject}"></h4>
<p class="lb" data-slot="${input.body}"></p>
${atts}
</div>`;
}
/**
* **分段刻度条** —— 一条数据一格,按值上色(`data-class-of`)。
*
* 原型里那条是「按条数分宽的分布条」,宽度得算,而模版不许算。
* 改成一条料一格:同类相邻自然连成一段,看着等价,而且每一格都对得上清单里的一行 ——
* 数得出来,不是一根示意的色条。
*
* `legend` 是图例:`[色档, 标签, 取值路径]`,把「A 5 条」这类计数摆在条子下面。
*/
export function meter(
rowsPath: string,
valueField: string,
legend?: ReadonlyArray<readonly [string, string, string]>,
): string {
/*
* 格子里**不填字**:只用 `data-class-of` 把值投影成颜色。
* 带上 `data-slot` 的下场实测见过 —— 九个格子里排着「0 0 0 0 0 0 0 0 1」,
* 一条刻度条成了一串数字,目标件那条是纯色块。
*/
const bar = `<ul class="meter" data-rows="${rowsPath}"><li data-class-of="${valueField}"></li></ul>`;
if (legend === undefined || legend.length === 0) return bar;
const keys = legend
.map(
([tone, label, path]) =>
`<span><i class="v-${tone}"></i>${label} <b data-slot="${path}"></b></span>`,
)
.join('');
return `${bar}<div class="mlg">${keys}</div>`;
}
/** 大号计数 —— 一屏之内最该被一眼看见的那个数(`unit` 是它的分母或量纲) */
export function bigCount(path: string, unit: string): string {
return `<span class="big"><b data-slot="${path}"></b><span>${unit}</span></span>`;
}
/**
* **按序求值的规则表** —— 三条互斥条件与各自的去向,连着「本单是几」一起摆。
*
* 命中哪一条由编排层求值,这里**不画对勾替它宣布**;但把条件、去向、本单的计数
* 摆在同一行上,人自己一眼就对得出来 —— 这比一句「三条按序求值」有用得多。
*/
export function ruleTable(
rules: ReadonlyArray<{ readonly no: string; readonly when: string; readonly node: string; readonly to: string; readonly count: string }>,
): string {
const head = '<div class="h"><span>序</span><span>条件(按序求值)</span><span>去向</span><span>本单</span></div>';
const rows = rules
.map(
(r) =>
`<div><span class="no">${r.no}</span><span>${r.when}</span>` +
`<span class="go"><code>${r.node}</code>${r.to}</span><span class="cnt" data-slot="${r.count}"></span></div>`,
)
.join('');
return `<div class="rules">${head}${rows}</div>`;
}
/** 要素卡的色调 —— 静态,不随数据变(数据判不了达标的事,别让颜色替它说) */
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 `<div class="facts">${items
.map((f) => {
const value =
f.big === undefined
? `<span class="v${f.mono === true ? ' mono' : ''}" data-slot="${f.value}"></span>`
: bigCount(f.value, f.big);
const body =
f.avatar === undefined
? value
: `<div class="row"><span class="ava${f.avatar === 'person' ? ' person' : ''}">` +
`${ico(f.avatar === 'person' ? 'user' : 'building', 17)}</span><div class="col">${value}</div></div>`;
return (
`<div class="fact${f.tone === undefined || f.tone === 'brand' ? '' : ` t-${f.tone}`}">` +
`<span class="k"><i>${ico(f.icon ?? 'file-text', 11)}</i>${f.label}</span>` +
`${body}${f.extra ?? ''}` +
`${f.note === undefined ? '' : `<span class="n">${f.note}</span>`}</div>`
);
})
.join('')}</div>`;
}
/**
* **落地动作** —— 放行那一刻系统替人做了什么,一条一行。
*
* 放行是草稿态与事实态之间的那道门。门后发生的事人看不见(合并、发事件、起下游单),
* 而他要为这些事负责 —— 所以得逐条写出来,不能只留一句「已放行」。
*/
export function actionList(items: ReadonlyArray<{ readonly title: string; readonly detail: string }>): string {
return `<ul class="acts">${items
.map((a) => `<li><span class="m">${ico('circle-check', 12)}</span><span><b>${a.title}</b>${a.detail}</span></li>`)
.join('')}</ul>`;
}
/** 结论章 —— 一屏只该有一个 */
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 `<p class="verdict${cls}">${ico(mark, 15)}${text}${detail === undefined ? '' : `<span>${detail}</span>`}</p>`;
}