Archived
手改是**兜 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
708 lines
40 KiB
TypeScript
708 lines
40 KiB
TypeScript
/**
|
||
* 视图模版工具箱 —— 活动开发者写「呈现」时看到的世界(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>`;
|
||
}
|