feat(圆桌): 大屏多页浅色模板重写为蓝图工程图风(参考设计图)

- 浅色 palette 改玫瑰红(#E11D48)+slate 中性+白底网格,席位色序对齐设计图
- 卡片四角红色角标 + slate 网格 overlay(蓝图风);KPI 加彩色图标
- 共识竖条摘要 + 要点列表;席位卡进度条 + 风险药丸;席位专页精致化
- 新增折线图 svgLine(趋势),donut 升级镂空中心数字+图例;契约加 line 类型
- 图表保持纯 SVG(零依赖离线自包含,沙箱/新窗口/下载/分享通用,与参考源码一致)
- 深色保持深蓝 token 套同结构

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
This commit is contained in:
董宏凯 committed 2026-06-13 10:51:10 +08:00
1 parent f3202f95b6
commit e9f44c8f0f
2 files changed
+201 -122

No files matched your search

@@ -107,9 +107,10 @@ test("renderDashboardHtml:主题跟随 —— dark=深蓝规范 / light=浅红
assert.ok(dark.includes("#122347")); assert.ok(dark.includes("#122347"));
assert.ok(dark.includes("#1677FF")); assert.ok(dark.includes("#1677FF"));
const light = renderDashboardHtml(d, { theme: "light" }); const light = renderDashboardHtml(d, { theme: "light" });
// 浅红规范:页面 #FAFAFA / 容器 #FFFFFF / 主色 #F5222D(§1 色彩) // 蓝图工程图风(参考设计图):页面 #F8FAFC / 玫瑰红主色 #E11D48 / slate 文字
assert.ok(light.includes("#FAFAFA")); assert.ok(light.includes("#F8FAFC"));
assert.ok(light.includes("#F5222D")); assert.ok(light.includes("#E11D48"));
assert.ok(light.includes("#1E293B"));
assert.ok(!light.includes("#0C1836")); assert.ok(!light.includes("#0C1836"));
// 缺省 = dark(向后兼容) // 缺省 = dark(向后兼容)
const def = renderDashboardHtml(d); const def = renderDashboardHtml(d);
@@ -48,7 +48,7 @@ export interface ReportChartItem {
} }
export interface ReportChart { export interface ReportChart {
type: "bar" | "donut" | "radar"; type: "bar" | "donut" | "radar" | "line";
title: string; title: string;
items: ReportChartItem[]; items: ReportChartItem[];
} }
@@ -240,10 +240,9 @@ export function coerceReportData(raw: unknown): ReportData | null {
const co = (c ?? {}) as Record<string, unknown>; const co = (c ?? {}) as Record<string, unknown>;
const type = str(co.type); const type = str(co.type);
return { return {
type: (type === "bar" || type === "donut" || type === "radar" ? type : "bar") as type: (type === "bar" || type === "donut" || type === "radar" || type === "line"
| "bar" ? type
| "donut" : "bar") as "bar" | "donut" | "radar" | "line",
| "radar",
title: str(co.title), title: str(co.title),
items: arr(co.items) items: arr(co.items)
.map((it) => { .map((it) => {
@@ -297,7 +296,7 @@ export function buildDashboardPrompt(opts: {
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",', '{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算', ' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},', ' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
' "charts":[{"type":"bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表(风险分布/资源占比/方案评分等),3-6 项/图', ' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图',
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段', ' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",', ' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付', ' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
@@ -364,27 +363,29 @@ const PALETTES: Record<DashboardTheme, Palette> = {
risk: { high: "#F5222D", mid: "#FAAD14", low: "#52C41A" }, risk: { high: "#F5222D", mid: "#FAAD14", low: "#52C41A" },
modalMask: "rgba(6,14,36,.78)", modalMask: "rgba(6,14,36,.78)",
}, },
// 蓝图/工程图风(参考大屏设计图):白底 + slate 网格、玫瑰红(rose-600)强调、
// slate 中性文字、卡片四角红色角标、席位色序(scheme红/ux靛/ad绿/risk琥珀/summary天蓝)。
light: { light: {
bgPage: "#FAFAFA", bgPage: "#F8FAFC",
bgGradient: bgGradient:
"radial-gradient(1200px 600px at 70% -10%,rgba(245,34,45,.05),transparent),linear-gradient(160deg,#FFFFFF,#FAFAFA)", "radial-gradient(1100px 520px at 78% -12%,rgba(225,29,72,.05),transparent),linear-gradient(180deg,#FFFFFF,#F8FAFC)",
gridLine: "rgba(245,34,45,.035)", gridLine: "rgba(148,163,184,.16)",
card: "#FFFFFF", card: "#FFFFFF",
cardBorder: "#E8E8E8", cardBorder: "rgba(226,232,240,.9)",
cardGlow: "0 2px 8px rgba(0,0,0,.04)", cardGlow: "0 3px 10px rgba(15,23,42,.04)",
bracket: "#F5222D", bracket: "#E11D48",
text: "#222222", text: "#475569",
textSecondary: "#575757", textSecondary: "#64748B",
textStrong: "#222222", textStrong: "#1E293B",
heading: "#A8071A", heading: "#1E293B",
primary: "#F5222D", primary: "#E11D48",
link: "#0079FE", link: "#E11D48",
titleColor: "linear-gradient(90deg,#F5222D,#CF1322 60%,#A8071A)", titleColor: "linear-gradient(90deg,#1E293B,#334155)",
navBg: "rgba(255,255,255,.92)", navBg: "rgba(255,255,255,.95)",
trackBg: "#F0F0F0", trackBg: "#F1F5F9",
accents: ["#F5222D", "#0079FE", "#189D72", "#FF9900", "#45C3F3", "#7F7F7F"], accents: ["#E11D48", "#4F46E5", "#059669", "#D97706", "#0284C7", "#7C3AED"],
risk: { high: "#F5222D", mid: "#FF9900", low: "#189D72" }, risk: { high: "#DC2626", mid: "#D97706", low: "#059669" },
modalMask: "rgba(34,34,34,.45)", modalMask: "rgba(15,23,42,.42)",
}, },
}; };
@@ -427,24 +428,59 @@ function svgBar(chart: ReportChart): string {
function svgDonut(chart: ReportChart): string { function svgDonut(chart: ReportChart): string {
const items = chart.items.slice(0, 6); const items = chart.items.slice(0, 6);
const total = items.reduce((s, i) => s + Math.max(0, i.value), 0) || 1; const total = items.reduce((s, i) => s + Math.max(0, i.value), 0) || 1;
const R = 56; const R = 50;
const SW = 14;
const C = 2 * Math.PI * R; const C = 2 * Math.PI * R;
let offset = 0; let acc = 0;
const segs = items const segs = items
.map((it, i) => { .map((it, i) => {
const frac = Math.max(0, it.value) / total; const len = (Math.max(0, it.value) / total) * C;
const seg = `<circle cx="80" cy="80" r="${R}" fill="none" stroke="${P.accents[i % P.accents.length]}" stroke-width="20" stroke-dasharray="${(frac * C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-offset * C).toFixed(2)}" transform="rotate(-90 80 80)" opacity="0.9"/>`; const seg = `<circle cx="70" cy="70" r="${R}" fill="none" stroke="${P.accents[i % P.accents.length]}" stroke-width="${SW}" stroke-dasharray="${len.toFixed(2)} ${(C - len).toFixed(2)}" stroke-dashoffset="${(-acc).toFixed(2)}" transform="rotate(-90 70 70)"/>`;
offset += frac; acc += len;
return seg; return seg;
}) })
.join(""); .join("");
// 中心:总分类数 + 标签(对齐设计图环形图的镂空中心)。
const center = `<text x="70" y="68" text-anchor="middle" class="dc-num">${items.length}</text><text x="70" y="82" text-anchor="middle" class="dc-cap">类</text>`;
const legend = items const legend = items
.map( .map(
(it, i) => (it, i) =>
`<div class="lg"><i style="background:${P.accents[i % P.accents.length]}"></i>${esc(it.label)} <b>${esc(String(it.value))}</b></div>`, `<div class="lg"><i style="background:${P.accents[i % P.accents.length]}"></i><span>${esc(it.label)}</span><b>${esc(String(it.value))}</b></div>`,
) )
.join(""); .join("");
return `<div class="donut"><svg viewBox="0 0 160 160" role="img">${segs}</svg><div class="lgs">${legend}</div></div>`; return `<div class="donut"><svg viewBox="0 0 140 140" role="img"><circle cx="70" cy="70" r="${R}" fill="none" stroke="var(--track)" stroke-width="${SW}"/>${segs}${center}</svg><div class="lgs">${legend}</div></div>`;
}
// 折线图(趋势):多取前 8 点;自动归一化到画布。设计图的销量趋势用折线。
function svgLine(chart: ReportChart): string {
const items = chart.items.slice(0, 8);
if (items.length < 2) return svgBar(chart);
const W = 300;
const H = 150;
const padL = 30;
const padR = 12;
const padT = 12;
const padB = 24;
const max = Math.max(...items.map((i) => i.value), 1);
const min = Math.min(...items.map((i) => i.value), 0);
const span = max - min || 1;
const x = (i: number) => padL + (i / (items.length - 1)) * (W - padL - padR);
const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
const pts = items.map((it, i) => `${x(i).toFixed(1)},${y(it.value).toFixed(1)}`).join(" ");
const grid = [0, 0.5, 1]
.map((k) => {
const gy = (padT + k * (H - padT - padB)).toFixed(1);
return `<line x1="${padL}" y1="${gy}" x2="${W - padR}" y2="${gy}" class="gr"/>`;
})
.join("");
const area = `<polygon points="${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}" fill="${P.primary}14"/>`;
const dots = items
.map((it, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(it.value).toFixed(1)}" r="2.6" fill="${P.primary}"/>`)
.join("");
const labels = items
.map((it, i) => `<text x="${x(i).toFixed(1)}" y="${H - 8}" text-anchor="middle" class="cl">${esc(it.label)}</text>`)
.join("");
return `<svg viewBox="0 0 ${W} ${H}" role="img">${grid}${area}<polyline points="${pts}" fill="none" stroke="${P.primary}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>${dots}${labels}</svg>`;
} }
function svgRadar(chart: ReportChart): string { function svgRadar(chart: ReportChart): string {
@@ -480,7 +516,13 @@ function svgRadar(chart: ReportChart): string {
function chartHtml(chart: ReportChart): string { function chartHtml(chart: ReportChart): string {
const body = const body =
chart.type === "donut" ? svgDonut(chart) : chart.type === "radar" ? svgRadar(chart) : svgBar(chart); chart.type === "donut"
? svgDonut(chart)
: chart.type === "radar"
? svgRadar(chart)
: chart.type === "line"
? svgLine(chart)
: svgBar(chart);
return `<div class="card chart"><h3>${esc(chart.title || "图表")}</h3>${body}</div>`; return `<div class="card chart"><h3>${esc(chart.title || "图表")}</h3>${body}</div>`;
} }
@@ -501,7 +543,7 @@ function metricBars(metrics: ReportSeatMetric[]): string {
return `<div class="metrics">${metrics return `<div class="metrics">${metrics
.map( .map(
(m, i) => (m, i) =>
`<div class="metric"><span>${esc(m.label)}</span><div class="track"><div class="fill" style="width:${m.value}%;background:${P.accents[i % P.accents.length]}"></div></div><b>${m.value}</b></div>`, `<div class="metric"><div class="mrow"><span>${esc(m.label)}</span><b>${m.value}</b></div><div class="track"><div class="fill" style="width:${m.value}%;background:${P.accents[i % P.accents.length]}"></div></div></div>`,
) )
.join("")}</div>`; .join("")}</div>`;
} }
@@ -533,20 +575,23 @@ export function renderDashboardHtml(
`<a class="tab" href="#overview">总览</a>` + `<a class="tab" href="#overview">总览</a>` +
seats.map((s) => `<a class="tab" href="#${esc(s.id)}">${esc(s.name)}</a>`).join(""); seats.map((s) => `<a class="tab" href="#${esc(s.id)}">${esc(s.name)}</a>`).join("");
const KPI_ICONS = ["📊", "📈", "⚠️", "🧩", "🏆", "📅"];
const kpis = data.kpis const kpis = data.kpis
.map( .map((k, i) => {
(k, i) => const ac = P.accents[i % P.accents.length];
`<div class="card kpi"><span class="klabel">${esc(k.label)}</span><div class="kval" style="color:${P.accents[i % P.accents.length]}">${esc(k.value)}<small>${esc(k.unit ?? "")}</small>${ return `<div class="card kpi">
<div class="khead"><span class="klabel">${esc(k.label)}</span><span class="kico" style="background:${ac}1a;color:${ac}">${KPI_ICONS[i % KPI_ICONS.length]}</span></div>
<div class="kval">${esc(k.value)}<small>${esc(k.unit ?? "")}</small>${
k.trend === "up" ? '<i class="tr up">▲</i>' : k.trend === "down" ? '<i class="tr dn">▼</i>' : "" k.trend === "up" ? '<i class="tr up">▲</i>' : k.trend === "down" ? '<i class="tr dn">▼</i>' : ""
}</div></div>`, }</div></div>`;
) })
.join(""); .join("");
const seatCards = seats const seatCards = seats
.map((s, i) => { .map((s, i) => {
const color = P.accents[i % P.accents.length]; const color = P.accents[i % P.accents.length];
return `<a class="card seat" href="#${esc(s.id)}" style="--ac:${color}"> return `<a class="card seat" href="#${esc(s.id)}" style="--ac:${color}">
<div class="shead"><i></i><h3>${esc(s.name)}</h3><span class="go">进入席位页 →</span></div> <div class="shead"><i></i><h3>${esc(s.name)}</h3><span class="go">席位页 →</span></div>
<p>${esc(s.summary || "(无摘要)")}</p> <p>${esc(s.summary || "(无摘要)")}</p>
${metricBars(s.metrics.slice(0, 2))} ${metricBars(s.metrics.slice(0, 2))}
${riskChips(s.risks.slice(0, 2))} ${riskChips(s.risks.slice(0, 2))}
@@ -562,17 +607,20 @@ export function renderDashboardHtml(
const consensusPoints = data.consensus.points.map((p) => `<li>${esc(p)}</li>`).join(""); const consensusPoints = data.consensus.points.map((p) => `<li>${esc(p)}</li>`).join("");
const chartsBlock = data.charts.length
? `<div class="charts">${data.charts.map(chartHtml).join("")}</div>`
: "";
const overview = `<section class="page" id="overview"> const overview = `<section class="page" id="overview">
<div class="kpis">${kpis}</div> <div class="kpis">${kpis}</div>
<div class="grid2"> <div class="ovgrid">
<div class="card consensus"><h3>总控最终共识</h3> <div class="card consensus"><h3>总控最终共识</h3>
<p class="headline">${esc(data.consensus.headline || "(未提供)")}</p> <p class="headline">${esc(data.consensus.headline || "(未提供)")}</p>
<ul>${consensusPoints}</ul> <ul>${consensusPoints}</ul>
<a class="more" href="#consensus-modal">查看详情</a> <a class="more" href="#consensus-modal">查看研讨详情 →</a>
</div> </div>
<div class="charts">${data.charts.map(chartHtml).join("")}</div> ${chartsBlock}
</div> </div>
<h2 class="sec">席位交付总览 <small>点击卡片进入席位专页</small></h2> <h2 class="sec">席位交付总览 <small>点击卡片进入对应席位专页</small></h2>
<div class="seats">${seatCards}</div> <div class="seats">${seatCards}</div>
${timeline} ${timeline}
</section>`; </section>`;
@@ -620,95 +668,125 @@ export function renderDashboardHtml(
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(data.title)}</title> <title>${esc(data.title)}</title>
<style> <style>
/* 主题色板(light=浅红色 UI 规范 / dark=深蓝色 UI 规范,渲染时注入) */ /* 蓝图工程图风模板(light=参考大屏设计图 / dark=深蓝 token,渲染时注入 palette) */
:root{--line:${P.cardBorder};--txt:${P.text};--dim:${P.textSecondary};--strong:${P.textStrong}; :root{--line:${P.cardBorder};--txt:${P.text};--dim:${P.textSecondary};--strong:${P.textStrong};
--primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg}} --primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg};--bracket:${P.bracket}}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:"Noto Sans S Chinese","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh; body{font-family:"Inter","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh;
background:${P.bgGradient};background-color:${P.bgPage};background-attachment:fixed} font-size:13px;background:${P.bgGradient};background-color:${P.bgPage};background-attachment:fixed}
body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.6; body::before{content:"";position:fixed;inset:0;pointer-events:none;
background:linear-gradient(${P.gridLine} 1px,transparent 1px),linear-gradient(90deg,${P.gridLine} 1px,transparent 1px); background:linear-gradient(${P.gridLine} 1px,transparent 1px),linear-gradient(90deg,${P.gridLine} 1px,transparent 1px);
background-size:42px 42px} background-size:22px 22px}
.top{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;align-items:center;gap:12px; .top{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;align-items:center;gap:12px;
padding:14px 26px;background:${P.navBg};backdrop-filter:blur(6px);border-bottom:1px solid var(--line)} padding:13px 26px;background:${P.navBg};backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.top h1{font-size:21px;letter-spacing:2px;background:${P.titleColor}; .top h1{font-size:18px;font-weight:800;letter-spacing:.5px;background:${P.titleColor};
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap} -webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
.top .sub{font-size:12px;color:var(--dim)} .top .sub{font-size:11.5px;color:var(--dim);padding:2px 8px;border:1px solid var(--line);border-radius:4px}
.top .meta{margin-left:auto;font-size:11px;color:var(--dim)} .top .meta{margin-left:auto;font-size:10.5px;color:var(--dim);font-family:"JetBrains Mono",Consolas,monospace;letter-spacing:.5px}
nav{display:flex;flex-wrap:wrap;gap:8px;padding:10px 26px;border-bottom:1px solid var(--line)} nav{position:sticky;top:51px;z-index:4;display:flex;flex-wrap:wrap;gap:7px;padding:9px 26px;
.tab{font-size:12px;padding:5px 14px;border:1px solid var(--line);border-radius:2px;color:var(--dim);text-decoration:none;transition:.15s} background:${P.navBg};backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.tab:hover{color:${P.primary};border-color:${P.primary};box-shadow:0 0 10px ${P.primary}40} .tab{font-size:12px;font-weight:600;padding:5px 13px;border:1px solid var(--line);border-radius:4px;color:var(--dim);text-decoration:none;transition:.15s;background:var(--track)}
main{padding:20px 26px 40px;max-width:1480px;margin:0 auto} .tab:hover{color:#fff;border-color:var(--primary);background:var(--primary)}
main{padding:22px 26px 48px;max-width:1500px;margin:0 auto}
.page{display:none;animation:fi .25s ease} .page{display:none;animation:fi .25s ease}
#overview{display:block} #overview{display:block}
.page:target{display:block} .page:target{display:block}
body:has(.page:target) #overview:not(:target){display:none} body:has(.page:target) #overview:not(:target){display:none}
@keyframes fi{from{opacity:0;transform:translateY(6px)}to{opacity:1}} @keyframes fi{from{opacity:0;transform:translateY(8px)}to{opacity:1}}
/* 卡片:规范圆角 4px;浅色实底白卡 + 轻阴影,深色深蓝渐变 + 内发光 */ /* 蓝图卡片:白底 + 四角玫瑰红角标 + 极淡网格 overlay */
.card{position:relative;border:1px solid var(--line);border-radius:4px;padding:16px 18px; .card{position:relative;border:1px solid var(--line);border-radius:6px;padding:17px 19px;
background:${P.card};box-shadow:${P.cardGlow}} background:${P.card};box-shadow:${P.cardGlow};transition:box-shadow .3s,transform .3s,border-color .3s}
.card::before,.card::after{content:"";position:absolute;width:14px;height:14px;border-color:${P.bracket};border-style:solid;opacity:.55} .card::before,.card::after{content:"";position:absolute;width:12px;height:12px;border-color:var(--bracket);border-style:solid;z-index:2}
.card::before{top:-1px;left:-1px;border-width:2px 0 0 2px;border-radius:4px 0 0 0} .card::before{top:-1px;left:-1px;border-width:2px 0 0 2px}
.card::after{bottom:-1px;right:-1px;border-width:0 2px 2px 0;border-radius:0 0 4px 0} .card::after{bottom:-1px;right:-1px;border-width:0 2px 2px 0}
.card h3{font-size:13px;letter-spacing:1.5px;color:var(--heading);margin-bottom:10px} .card>*{position:relative;z-index:1}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px} .card h3{font-size:13.5px;font-weight:700;letter-spacing:.3px;color:var(--strong);margin-bottom:12px;
.kpi .klabel{font-size:11px;color:var(--dim);letter-spacing:1px} display:flex;align-items:center;gap:7px;padding-bottom:11px;border-bottom:1px solid var(--line)}
.kpi .kval{font-size:30px;font-weight:800;margin-top:4px;font-family:"D-DIN",Consolas,monospace} .card h3::before{content:"";width:9px;height:9px;border-radius:2px;background:var(--primary);flex:none}
.kpi small{font-size:12px;margin-left:4px;color:var(--dim)} /* KPI */
.kpi .tr{font-style:normal;font-size:12px;margin-left:6px}.tr.up{color:${P.risk.low}}.tr.dn{color:${P.risk.high}} .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:13px;margin-bottom:18px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px} .kpi{padding:15px 17px;min-height:104px;display:flex;flex-direction:column;justify-content:space-between}
@media(max-width:980px){.grid2{grid-template-columns:1fr}} .kpi .khead{display:flex;justify-content:space-between;align-items:flex-start;gap:6px}
.consensus .headline{font-size:15px;font-weight:700;color:var(--strong);margin-bottom:8px} .kpi .klabel{font-size:11.5px;color:var(--dim);font-weight:500;letter-spacing:.3px}
.consensus ul{padding-left:18px;display:grid;gap:5px;font-size:12.5px;color:var(--txt)} .kpi .kico{width:30px;height:30px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;flex:none}
.consensus .more{display:inline-block;margin-top:10px;font-size:11px;color:${P.link};text-decoration:none;border-bottom:1px dashed ${P.link}} .kpi .kval{font-size:30px;font-weight:800;letter-spacing:-.5px;color:var(--strong);font-family:"JetBrains Mono",Consolas,monospace;line-height:1;display:flex;align-items:baseline;gap:3px;margin-top:8px}
.charts{display:grid;gap:14px} .kpi small{font-size:12px;font-weight:500;color:var(--dim)}
.chart svg{width:100%;height:auto} .kpi .tr{font-size:11px;font-weight:700;padding:1px 5px;border-radius:3px;margin-left:4px}
.cl{fill:var(--dim);font-size:11px}.cv{fill:var(--txt);font-size:11px;font-weight:700} .tr.up{color:${P.risk.low};background:${P.risk.low}1a}.tr.dn{color:${P.risk.high};background:${P.risk.high}1a}
.ct{fill:var(--track)}.gr{fill:none;stroke:${P.primary}2e;stroke-width:1} .ovgrid{display:grid;grid-template-columns:1.25fr 1fr;gap:13px;margin-bottom:20px;align-items:start}
.donut{display:flex;align-items:center;gap:14px}.donut svg{width:130px;flex:none} @media(max-width:980px){.ovgrid{grid-template-columns:1fr}}
.lgs{display:grid;gap:5px}.lg{font-size:11.5px;color:var(--dim)}.lg b{color:var(--txt);margin-left:4px} .grid2{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin-bottom:14px}
.lg i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px} @media(max-width:760px){.grid2{grid-template-columns:1fr}}
.sec{font-size:14px;letter-spacing:2px;color:var(--heading);margin:6px 0 12px} /* 共识:玫瑰红竖条摘要 + 要点列表 */
.sec small{font-size:11px;color:var(--dim);font-weight:400;margin-left:8px} .consensus .headline{font-size:13.5px;font-weight:600;color:var(--strong);line-height:1.7;margin-bottom:13px;
.seats{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:18px} background:${P.primary}0d;padding:12px 14px;border-radius:4px;border-left:3px solid var(--primary)}
.seat{display:block;text-decoration:none;color:inherit;transition:.15s} .consensus ul{list-style:none;display:grid;gap:11px}
.seat:hover{border-color:var(--ac);box-shadow:0 0 16px ${P.primary}30;transform:translateY(-2px)} .consensus li{display:flex;gap:9px;font-size:12px;line-height:1.65;color:var(--txt)}
.shead{display:flex;align-items:center;gap:8px;margin-bottom:8px} .consensus li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--primary);margin-top:6px;flex:none}
.shead i{width:8px;height:8px;border-radius:50%;background:var(--ac);box-shadow:0 0 8px var(--ac)} .consensus .more{display:inline-block;margin-top:13px;font-size:11.5px;font-weight:600;color:var(--primary);text-decoration:none}
.shead h3{margin:0;flex:1} .consensus .more:hover{text-decoration:underline}
.go{font-size:10.5px;color:var(--dim)} .charts{display:grid;gap:13px}
.chart svg{width:100%;height:auto;display:block}
.cl{fill:var(--dim);font-size:9.5px}.cv{fill:var(--strong);font-size:11px;font-weight:700;font-family:monospace}
.ct{fill:var(--track)}.gr{fill:none;stroke:${P.gridLine.replace(/[\d.]+\)$/, "0.6)")};stroke:var(--line);stroke-width:1}
.dc-num{fill:var(--strong);font-size:26px;font-weight:800;font-family:monospace}
.dc-cap{fill:var(--dim);font-size:9px}
.donut{display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:center}.donut svg{width:128px;flex:none}
.lgs{display:grid;gap:7px;min-width:130px}.lg{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--txt)}
.lg span{flex:1}.lg b{color:var(--strong);font-family:monospace;font-weight:700}
.lg i{display:inline-block;width:9px;height:9px;border-radius:2px;flex:none}
.sec{font-size:15px;font-weight:700;color:var(--strong);margin:8px 0 13px;display:flex;align-items:center;gap:8px}
.sec small{font-size:11.5px;color:var(--dim);font-weight:400}
.seats{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:13px;margin-bottom:20px}
.seat{display:flex;flex-direction:column;text-decoration:none;color:inherit}
.seat:hover{border-color:var(--ac);box-shadow:0 8px 22px rgba(15,23,42,.09);transform:translateY(-2px)}
.shead{display:flex;align-items:center;gap:7px;padding-bottom:10px;border-bottom:1px solid var(--line);margin-bottom:0}
.shead::before{display:none}
.shead i{width:9px;height:9px;border-radius:50%;background:var(--ac);flex:none}
.shead h3{margin:0;padding:0;border:0;font-size:12.5px;flex:1}
.shead h3::before{display:none}
.go{font-size:10px;color:var(--dim);display:flex;align-items:center;gap:2px}
.seat:hover .go{color:var(--ac)} .seat:hover .go{color:var(--ac)}
.seat p{font-size:12px;color:var(--dim);line-height:1.6;margin-bottom:8px} .seat .anly{font-size:10px;color:var(--dim);font-family:monospace;text-transform:uppercase;letter-spacing:.5px;margin:11px 0 6px}
.metrics{display:grid;gap:7px} .seat p{font-size:11.5px;color:var(--dim);line-height:1.65;margin-bottom:12px;flex:1;
.metric{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)} display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.metric span{width:72px;flex:none}.metric b{width:26px;text-align:right;color:var(--txt)} .metrics{display:grid;gap:9px}
.track{flex:1;height:7px;border-radius:4px;background:var(--track);overflow:hidden} .metric span{font-size:10.5px;color:var(--dim);font-weight:500}
.fill{height:100%;border-radius:4px} .metric .mrow{display:flex;justify-content:space-between;margin-bottom:4px}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} .metric b{font-size:11px;color:var(--strong);font-family:monospace;font-weight:700}
.chip{font-size:10.5px;padding:2px 9px;border:1px solid;border-radius:2px} .track{height:6px;border-radius:99px;background:var(--track);overflow:hidden}
.tl ol{list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;counter-reset:tl} .fill{height:100%;border-radius:99px}
.tl li{position:relative;padding:10px 12px 10px 14px;border-left:2px solid ${P.primary};background:${P.primary}10;border-radius:0 4px 4px 0} .chips{display:flex;flex-direction:column;gap:6px;margin-top:12px;padding-top:11px;border-top:1px solid var(--line)}
.tl b{display:block;font-size:12.5px;color:var(--heading);margin-bottom:3px} .chip{font-size:10px;padding:3px 8px;border-radius:4px;border-left:2px solid;display:flex;align-items:center;gap:5px}
.tl span{font-size:11px;color:var(--dim);line-height:1.5} .chip::before{content:"⚠";font-size:9px}
.pbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;font-size:12px} .tl{margin-top:2px}
.pbar a{color:${P.link};text-decoration:none}.pnav{display:flex;gap:14px}.pnav a{color:var(--dim)}.pnav a:hover{color:${P.link}} .tl ol{list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:11px}
.phead{display:flex;align-items:center;gap:12px;margin-bottom:12px} .tl li{position:relative;padding:12px 14px;border:1px solid var(--line);border-left:3px solid var(--primary);background:var(--track);border-radius:0 5px 5px 0}
.phead i{width:10px;height:34px;border-radius:3px;background:var(--ac);box-shadow:0 0 10px var(--ac)} .tl b{display:block;font-size:12.5px;font-weight:700;color:var(--strong);margin-bottom:5px}
.phead h2{font-size:19px;letter-spacing:2px;color:var(--strong)} .tl span{font-size:11px;color:var(--dim);line-height:1.6}
.phead p{font-size:12px;color:var(--dim);margin-top:3px} /* 席位专页 */
.deps{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px} .pbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;font-size:12px}
.dep{font-size:11px;padding:3px 11px;border:1px dashed ${P.link};border-radius:2px;color:${P.link};text-decoration:none} .pbar a{color:var(--primary);text-decoration:none;font-weight:600}.pnav{display:flex;gap:16px}.pnav a{color:var(--dim);font-weight:500}.pnav a:hover{color:var(--primary)}
.dep.sup{border-color:${P.risk.low};color:${P.risk.low}} .phead{display:flex;align-items:center;gap:13px;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.dep:hover{background:${P.link}1a} .phead i{width:6px;height:38px;border-radius:3px;background:var(--ac);flex:none}
.psections{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;margin-bottom:14px} .phead h2{font-size:20px;font-weight:800;color:var(--strong)}
.sect ul{padding-left:16px;display:grid;gap:6px;font-size:12.5px;line-height:1.6} .phead p{font-size:12px;color:var(--dim);margin-top:4px;line-height:1.6}
.deps{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px}
.dep{font-size:11px;font-weight:600;padding:4px 11px;border:1px dashed var(--primary);border-radius:4px;color:var(--primary);text-decoration:none}
.dep.sup{border-color:${P.accents[4]};color:${P.accents[4]}}
.dep:hover{background:var(--primary)12}
.psections{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:13px;margin-bottom:14px}
.sect ul{list-style:none;display:grid;gap:8px;font-size:12px;line-height:1.65}
.sect li{display:flex;gap:8px;color:var(--txt)}
.sect li::before{content:"";width:5px;height:5px;border-radius:50%;background:var(--primary);margin-top:7px;flex:none}
.sect .empty{font-size:12px;color:var(--dim)} .sect .empty{font-size:12px;color:var(--dim)}
.modal{display:none;position:fixed;inset:0;z-index:20;background:${P.modalMask};padding:6vh 16px} .modal{display:none;position:fixed;inset:0;z-index:20;background:${P.modalMask};padding:6vh 16px}
.modal:target{display:flex;justify-content:center;align-items:flex-start} .modal:target{display:flex;justify-content:center;align-items:flex-start}
.mbody{max-width:680px;width:100%;max-height:84vh;overflow:auto} .mbody{max-width:700px;width:100%;max-height:84vh;overflow:auto}
.mbody ul{padding-left:18px;display:grid;gap:6px;font-size:13px} .mbody ul{list-style:none;display:grid;gap:9px;font-size:13px;margin-top:6px}
.mclose{position:absolute;top:8px;right:14px;font-size:20px;color:var(--dim);text-decoration:none} .mbody li{display:flex;gap:9px;line-height:1.65}.mbody li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--primary);margin-top:7px;flex:none}
.mclose:hover{color:${P.risk.high}} .mclose{position:absolute;top:10px;right:16px;font-size:22px;color:var(--dim);text-decoration:none;line-height:1}
.mclose:hover{color:var(--primary)}
</style></head> </style></head>
<body> <body>
<header class="top"><h1>${esc(data.title)}</h1><span class="sub">${esc(data.subtitle)}</span><span class="meta">${generated}</span></header> <header class="top"><h1>${esc(data.title)}</h1><span class="sub">${esc(data.subtitle)}</span><span class="meta">${generated}</span></header>