feat(backend): 新增页面报告设计器 Seed 与模板工具

功能说明:\n- 新增页面报告设计器的路由级 seed 接口与初始化模板资源加载能力\n- 新增内置页面报告模板工具,实现报告生成流程中的模板路径与参数接入\n- 补充页面报告模板与相关资产目录,供运行时/离线环境直接读取\n- 将页面报告配置与工具链纳入后端启动路径,支持离线部署下可复用的默认模板
This commit is contained in:
董宏凯 committed 2026-09-02 12:52:40 +08:00
1 parent 6d326a21cd
commit 1c891df8fb
14 files changed
+9256

No files matched your search

@@ -0,0 +1,83 @@
"""Seed the experimental ``页面研报设计师`` agent and reusable public skills.
The runtime state directory (``.deer-flow``) and its ``skills/`` directory are
not source controlled. The verified agent/skill assets therefore live next to
this module and are copied only when a target is missing. Existing operations
or admin changes are always preserved.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer"
PAGE_REPORT_SKILL_NAMES: tuple[str, ...] = (
"page-report-generation",
"page-template-library",
"page-quality-review",
)
_ASSETS_DIR = Path(__file__).parent / "_page_report_designer_seed_assets"
_AGENT_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def _ensure_skills() -> None:
"""Seed every public page-report skill that is absent from this deployment."""
try:
from deerflow.skills.storage import get_or_new_skill_storage
public_dir = Path(get_or_new_skill_storage().get_skills_root_path()) / "public"
public_dir.mkdir(parents=True, exist_ok=True)
for skill_name in PAGE_REPORT_SKILL_NAMES:
source = _ASSETS_DIR / "skills" / skill_name
target = public_dir / skill_name
if target.exists():
continue
if not (source / "SKILL.md").is_file():
logger.error("Missing seed asset for page-report skill '%s': %s", skill_name, source)
continue
shutil.copytree(source, target)
logger.info("Seeded public page-report skill '%s'", skill_name)
except Exception:
logger.exception("Failed to seed page-report public skills")
def _ensure_agent() -> bool:
"""Create the built-in agent directory once, without overwriting edits."""
try:
target = get_paths().agent_dir(PAGE_REPORT_DESIGNER_AGENT_ID)
if target.exists():
return False
source = _ASSETS_DIR / PAGE_REPORT_DESIGNER_AGENT_ID
if not source.is_dir():
logger.error("Missing seed assets for page-report agent: %s", source)
return False
target.mkdir(parents=True, exist_ok=True)
for filename in _AGENT_FILES:
asset = source / filename
if asset.is_file():
(target / filename).write_bytes(asset.read_bytes())
logger.info("Seeded built-in agent '%s'", PAGE_REPORT_DESIGNER_AGENT_ID)
return True
except Exception:
logger.exception("Failed to seed built-in page-report agent")
return False
def ensure_page_report_designer_agent() -> bool:
"""Seed the experimental agent and all of its public reusable skills."""
_ensure_skills()
return _ensure_agent()
__all__ = [
"PAGE_REPORT_DESIGNER_AGENT_ID",
"PAGE_REPORT_SKILL_NAMES",
"ensure_page_report_designer_agent",
]
@@ -0,0 +1,37 @@
# 页面研报设计师
你是「页面研报设计师」:把用户提供的数据、附件、当前会话产物和已生成的 Markdown 报告,组织成可交付的 HTML 汇报页面。你擅长深蓝和浅色大屏、文字型研判面板、表格、卡片、图表、甘特图与克制的动效。
你只编辑当前会话的工作副本和 `outputs/` 产物,绝不修改模板源文件。
## 先判断要不要画页面
1. 用户只是提问、解释、讨论、审阅时,正常用文字回答,不生成页面。
2. 用户明确要求 Markdown / 报告 / 方案文档,且没有要求页面或 HTML 时,正常产出 Markdown;不要强制画页面。
3. 用户明确说“页面、HTML、大屏、看板、可视化、图表展示、把材料做成页面”时,生成页面。
4. 对于有明确“创作/产出”意图但未指定格式的请求,默认生成页面;若语气更像普通问答,仍按问答处理。
## 材料与检索边界
- 先读取本轮用户材料、上传文件、会话中的 Markdown / JSON 产物;已有信息足够时直接制作,**不得自动检索**。
- 信息不足以支撑结论、用户没有提供数据且未授权检索时,先简洁说明缺口,并询问:“是否允许我检索公开资料补充?”不要先调用 `web_search`。
- 用户明确强调“即使没有数据也要图表”时,仍应正常绘制图表:使用用户已给的定性信息或合理示意数据,但必须在图例、口径或脚注标注“示意数据 / 待补充数据”。不得把示意数值说成真实事实。
- 用户确认可以检索后,才使用 `web_search`;把来源、日期和不确定性写进相应文字面板或页脚。
## 页面生成流程
1. 将材料归为:事实数据、可推导结论、待补充项、图表数据、长文本、表格行、行动/风险项。优先保留用户原文的关键判断和数据口径。
2. 读取 `page-template-library` 技能;调用 `page_report_template(action="prepare")`,传入当前界面提供的 `presentation_theme`(`dark` 或 `light`)。无该上下文时,按用户明示主题,否则默认浅色。
3. 页面文字通常要多于图表:至少包含结论摘要、关键发现、事项/风险说明、数据口径或来源、行动建议中的多个展示样式。材料丰富时优先生成约 3 页;材料较少时删减模板页和无数据卡片,不留空面板。
4. 复制后的工作副本是骨架。保留已有图表渲染、动效和导航逻辑;用真实数据替换 `reportData` 和相关文本。若模板存在 5 页,可保留其中最贴合的 3 页而不是机械展示全部。
5. 所有可见正文、表头、标签、说明文字最小字号均为 16px;长文本面板要增高、提高行高,不能把段落挤成一团。
6. 最终 HTML 写入 `/mnt/user-data/outputs/`。调用 `page_report_template(action="validate")`;若返回错误,先修复再交付。通过后调用 `present_files` 展示 HTML 和必要的质量报告。
## 设计要求
- 跟随 `presentation_theme`:`dark` 选深蓝骨架,`light` 选浅色骨架;不要把产品现有聊天页面风格照搬进汇报页面。
- 深色使用深蓝科技感,选中状态可有细腻的扫描光、描边或流光;浅色使用清晰留白与高对比文字。
- 动效服务于阅读:数字滚动、图表入场、进度条、轻量背景粒子即可;支持 `prefers-reduced-motion`,不使用刺眼或持续大幅运动。
- 图表必须与材料一致。数据不够时宁可删掉图表或明确标为示意,不伪造事实;避免为了“丰富”而堆满图。
- 文字展示不能只是一种卡片。灵活组合:摘要带、洞察卡、左右对照、时间线、风险清单、引用/口径块、行动闭环、说明段、表格、问答式要点、负责人/截止日清单。
- 每页都要有明确标题、数据时间/口径、可读的结论。HTML 必须自包含,不依赖第三方 CDN。
@@ -0,0 +1,18 @@
id: page-report-designer
name: 页面研报设计师
description: 实验性页面研报智能体:根据用户材料、上下文产物和主题色,选择可复用 HTML 骨架,生成文字与图表详略得当的高质量汇报页面
tool_groups:
- web
- file:read
- file:write
- bash
- page:report
skills:
- page-report-generation
- page-template-library
- page-quality-review
common_questions:
- title: 根据材料生成汇报页面
prompt: 请根据我提供的材料,生成一套文字为主、图表详略得当的 HTML 汇报页面;沿用当前界面主题色。
- title: 将报告转为多页面大屏
prompt: 请读取当前会话中的 Markdown 报告和附件,生成 3 页左右的 HTML 大屏汇报,并保留关键结论、表格和图表。
@@ -0,0 +1,19 @@
"""Reusable report-page template selection and quality helpers.
The package deliberately lives in the harness layer: it knows only about
template files and the current thread sandbox, never Gateway/application code.
"""
from .templates import (
PageTemplateProfile,
choose_template,
list_template_profiles,
resolve_template_file,
)
__all__ = [
"PageTemplateProfile",
"choose_template",
"list_template_profiles",
"resolve_template_file",
]
@@ -0,0 +1,824 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>深蓝科技风 · 大屏汇报模版(炫光精简版)</title>
<!--
============================================================
大屏汇报模版 A · 深蓝科技炫光风(动画精简版)
============================================================
· 1920×1080 固定画布,JS 自动等比缩放,任意分辨率大屏/投影直接全屏打开即可
· 零依赖单文件:不需要网络、不需要构建,双击即可运行
· 动画策略(低负载,弱机/集显也可流畅播放):
1) 图表最终状态由脚本同步直出(柱高/进度/弧长直接写内联样式),
动画只是从隐藏到最终态的一次性特效,不依赖 JS 定时链
2) 持续循环动画只保留 9 个,且全部只动 transform / opacity(GPU 合成):
KPI 图标虚线环旋转×4 / LIVE 呼吸灯 / 仪表盘内环旋转 /
雷达扫掠+虚线环 / 趋势线流光光晕
3) JS 循环仅 4 个且限帧:粒子星网(44点/30fps) / 趋势流光点(30fps) /
预警纵向滚动(60fps) / 底部跑马灯(60fps)
4) 已删除:全屏扫描光束、标题流光、面板跑灯与角标呼吸、进度条流光、
柱体闪烁、翼线扫光、LOGO 呼吸、信号点漂移与闪烁等重绘型动画
· 智能体填充方式(弱模型友好):
1) 标题、面板名称等文案:直接全局替换 HTML 里的中文文本
2) 所有数字、图表、列表:只改页面底部 <script> 里的 reportData 对象
3) 不要改 CSS 类名与页面结构,所有动画会自动生效
-->
<style>
:root {
color-scheme: dark;
--bg: #02060f;
--panel: rgba(7, 19, 45, 0.74);
--panel-line: rgba(38, 100, 200, 0.4);
--cyan: #3fd6ff;
--blue: #2f7bff;
--violet: #8b7bff;
--amber: #ffb03a;
--red: #ff4d6b;
--green: #35e08c;
--text: #d8ecff;
--dim: #7fa8d9;
}
* { box-sizing: border-box; margin: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
color: var(--text);
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
background:
radial-gradient(1200px 600px at 50% -10%, rgba(30, 90, 200, 0.28), transparent 60%),
radial-gradient(900px 500px at 100% 100%, rgba(20, 160, 180, 0.14), transparent 55%),
radial-gradient(800px 500px at 0% 90%, rgba(60, 70, 220, 0.12), transparent 55%),
var(--bg);
}
/* ===== 背景层:粒子画布 / 静态网格 / 暗角(全部无动画) ===== */
#bgNet { position: fixed; inset: 0; z-index: 0; }
.fx-grid {
position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .5;
background-image:
linear-gradient(rgba(60, 140, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(60, 140, 255, 0.05) 1px, transparent 1px);
background-size: 56px 56px;
}
.fx-vignette { position: fixed; inset: 0; z-index: 4; pointer-events: none;
background: radial-gradient(120% 90% at 50% 45%, transparent 62%, rgba(1, 4, 12, 0.6) 100%); }
.fx-crt { position: fixed; inset: 0; z-index: 4; pointer-events: none; opacity: .5;
background: repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.014) 0 1px, transparent 1px 3px); }
/* ===== 固定舞台 1920×1080,自动缩放 ===== */
.stage {
position: absolute; z-index: 2; left: 50%; top: 50%;
width: 1920px; height: 1080px;
transform: translate(-50%, -50%);
transform-origin: center;
display: flex; flex-direction: column; gap: 14px;
padding: 16px 26px 14px;
animation: stageIn 1.1s ease both;
}
@keyframes stageIn { from { opacity: 0; } to { opacity: 1; } }
/* ===== 顶部标题栏 ===== */
.hud { height: 96px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; }
.hud-side { display: flex; align-items: center; gap: 14px; }
.hud-side.r { justify-content: flex-end; gap: 18px; }
.logo { position: relative; width: 44px; height: 44px; flex: none;
clip-path: polygon(25% 3%, 75% 3%, 98% 50%, 75% 97%, 25% 97%, 2% 50%);
background: linear-gradient(160deg, rgba(63, 214, 255, .8), rgba(47, 123, 255, .35)); }
.logo-core { position: absolute; left: 50%; top: 50%; width: 12px; height: 12px; border-radius: 50%;
transform: translate(-50%, -50%); background: #9df1ff; box-shadow: 0 0 16px var(--cyan); }
.hud-org b { display: block; font-size: 16px; letter-spacing: 3px; color: #cfe8ff; }
.hud-org span { display: block; margin-top: 3px; font-size: 10px; letter-spacing: 3px; color: #5580b8; }
.hud-title-wrap { display: flex; align-items: center; gap: 26px; width: 980px; }
.wing { position: relative; flex: 1; height: 2px; }
.wing.l { background: linear-gradient(90deg, transparent, rgba(63, 214, 255, .85)); transform-origin: left; animation: wingIn 1s .15s both; }
.wing.r { background: linear-gradient(270deg, transparent, rgba(63, 214, 255, .85)); transform-origin: right; animation: wingIn 1s .15s both; }
@keyframes wingIn { from { transform: scaleX(0); } }
.wing::before { content: ""; position: absolute; top: -4px; width: 9px; height: 9px; transform: rotate(45deg);
background: #071633; border: 1px solid var(--cyan); box-shadow: 0 0 12px rgba(63, 214, 255, .9); }
.wing.l::before { right: 0; } .wing.r::before { left: 0; }
.hud-title { text-align: center; animation: titleIn .9s both; }
@keyframes titleIn { from { opacity: 0; transform: translateY(-8px); } }
.hud-title h1 {
font-size: 40px; font-weight: 700; letter-spacing: 10px; white-space: nowrap;
background: linear-gradient(90deg, #7ee8ff 10%, #2f7bff 40%, #8b7bff 65%, #7ee8ff 90%);
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 0 16px rgba(63, 214, 255, .32));
}
.hud-title p { margin-top: 7px; font-size: 12px; letter-spacing: 7px; color: #5d86c2; }
.live { display: flex; align-items: center; gap: 7px; padding: 5px 12px; border: 1px solid rgba(255, 77, 107, .45); border-radius: 4px;
color: #ff8ba0; font-size: 12px; letter-spacing: 2px; background: rgba(255, 77, 107, .08); }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); box-shadow: 0 0 10px var(--red); animation: dotPulse 1.4s infinite; }
@keyframes dotPulse { 50% { opacity: .35; } }
.clockbox { text-align: right; }
.clock { font-size: 26px; font-weight: 700; letter-spacing: 3px; color: #eaf6ff;
font-variant-numeric: tabular-nums; text-shadow: 0 0 14px rgba(63, 214, 255, .5); }
.clock-sub { display: flex; gap: 10px; justify-content: flex-end; margin-top: 2px; font-size: 12px; color: #6f9bd0; letter-spacing: 2px; }
/* ===== 主区三栏 ===== */
.cols { flex: 1; min-height: 0; display: grid; grid-template-columns: 448px 1fr 448px; gap: 14px; }
.col { display: grid; gap: 14px; min-height: 0; }
.col.left, .col.right { grid-template-rows: 0.95fr 1.12fr 1fr; }
.col.mid { grid-template-rows: 1.42fr 1fr; }
.mid-bottom { display: grid; grid-template-columns: 1.18fr 1fr; gap: 14px; min-height: 0; }
/* ===== 面板:静态角标(无跑灯、无呼吸) ===== */
.panel { position: relative; min-height: 0; display: flex; flex-direction: column;
padding: 12px 16px 14px; border: 1px solid var(--panel-line); background: var(--panel);
box-shadow: inset 0 0 32px rgba(20, 70, 160, 0.14), 0 6px 24px rgba(0, 4, 14, 0.5);
animation: panelIn .7s cubic-bezier(.2, .7, .2, 1) both; }
@keyframes panelIn { from { opacity: 0; transform: translateY(16px); } }
.d1 { animation-delay: .08s; } .d2 { animation-delay: .2s; } .d3 { animation-delay: .32s; }
.d4 { animation-delay: .44s; } .d5 { animation-delay: .56s; } .d6 { animation-delay: .68s; }
.panel::after { content: ""; position: absolute; inset: -1px; pointer-events: none; opacity: .85;
--cc: rgba(63, 214, 255, .9);
background:
linear-gradient(var(--cc), var(--cc)) left top / 16px 2px no-repeat,
linear-gradient(var(--cc), var(--cc)) left top / 2px 16px no-repeat,
linear-gradient(var(--cc), var(--cc)) right top / 16px 2px no-repeat,
linear-gradient(var(--cc), var(--cc)) right top / 2px 16px no-repeat,
linear-gradient(var(--cc), var(--cc)) left bottom / 16px 2px no-repeat,
linear-gradient(var(--cc), var(--cc)) left bottom / 2px 16px no-repeat,
linear-gradient(var(--cc), var(--cc)) right bottom / 16px 2px no-repeat,
linear-gradient(var(--cc), var(--cc)) right bottom / 2px 16px no-repeat; }
.p-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex: none; }
.p-head h2 { display: flex; align-items: center; gap: 9px; font-size: 16px; font-weight: 600; letter-spacing: 3px; color: #eaf6ff; }
.p-head h2::before { content: ""; width: 4px; height: 15px; flex: none;
background: linear-gradient(180deg, var(--cyan), var(--blue)); box-shadow: 0 0 9px rgba(63, 214, 255, .9); }
.p-tag { margin-left: auto; padding: 3px 9px; border: 1px solid rgba(63, 214, 255, .25); border-radius: 3px;
color: #6f9bd0; font-size: 11px; letter-spacing: 1px; background: rgba(63, 214, 255, .06); }
.p-body { flex: 1; min-height: 0; position: relative; }
/* ===== 左上:KPI(图标虚线环旋转为仅存的持续动画之一) ===== */
.kpi-grid { height: 100%; display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 10px; }
.kpi { position: relative; display: grid; grid-template-columns: 40px 1fr; align-items: center; gap: 10px;
padding: 8px 10px; border: 1px solid rgba(38, 100, 200, .3); background: linear-gradient(150deg, rgba(22, 55, 110, .4), rgba(8, 22, 50, .5));
animation: kpiIn .6s both; }
.kpi:nth-child(2) { animation-delay: .1s; } .kpi:nth-child(3) { animation-delay: .2s; } .kpi:nth-child(4) { animation-delay: .3s; }
@keyframes kpiIn { from { opacity: 0; transform: translateX(-12px); } }
.kicon { position: relative; width: 38px; height: 38px; border-radius: 50%; }
.kicon::before { content: ""; position: absolute; inset: 0; border-radius: 50%;
border: 1px dashed var(--ic, var(--cyan)); opacity: .8; animation: spin 9s linear infinite; }
.kicon::after { content: ""; position: absolute; left: 50%; top: 50%; width: 10px; height: 10px; border-radius: 50%;
transform: translate(-50%, -50%); background: var(--ic, var(--cyan)); box-shadow: 0 0 14px var(--ic, var(--cyan)); }
@keyframes spin { to { transform: rotate(360deg); } }
.kpi-label { font-size: 12px; color: #8fb4e2; letter-spacing: 1px; }
.kpi-v { margin-top: 4px; font-size: 25px; font-weight: 700; color: #fff; letter-spacing: 1px;
font-variant-numeric: tabular-nums; text-shadow: 0 0 16px rgba(63, 214, 255, .45); }
.kpi-v small { font-size: 13px; font-weight: 400; color: #9cc4ec; }
.kpi-delta { display: inline-block; margin-top: 5px; padding: 1px 7px; border-radius: 3px; font-size: 11px; }
.kpi-delta.up { color: #7dffc4; background: rgba(53, 224, 140, .12); }
.kpi-delta.down { color: #ffd07d; background: rgba(255, 176, 58, .12); }
/* ===== 左中:区域排行(进度条一次性行入) ===== */
.region-list { height: 100%; display: flex; flex-direction: column; justify-content: space-around; gap: 8px; }
.region-row { display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 10px; }
.rk { display: grid; place-items: center; width: 24px; height: 24px; font-size: 12px; font-weight: 700;
color: #7fdcff; clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
background: rgba(63, 214, 255, .12); }
.region-row:nth-child(1) .rk { color: #071633; background: linear-gradient(160deg, #6fe9ff, #2f7bff); }
.region-row:nth-child(2) .rk { background: rgba(63, 214, 255, .28); }
.region-name { font-size: 13px; color: #cfe6ff; margin-bottom: 6px; display: flex; justify-content: space-between; }
.region-name em { font-style: normal; color: #8fb4e2; font-variant-numeric: tabular-nums; }
.meter { position: relative; height: 8px; border-radius: 4px; overflow: hidden; background: rgba(80, 130, 210, .16); }
.meter b { display: block; height: 100%; border-radius: 4px; transform-origin: left;
background: linear-gradient(90deg, #1f6ae0, #3fd6ff); box-shadow: 0 0 12px rgba(63, 214, 255, .55);
animation: growX 1.2s cubic-bezier(.2, .7, .2, 1) both; }
@keyframes growX { from { transform: scaleX(0); } }
/* ===== 左下:健康度仪表 ===== */
.health-body { display: grid; grid-template-columns: 168px 1fr; align-items: center; gap: 14px; height: 100%; }
.gauge-wrap { position: relative; width: 168px; height: 168px; }
.gauge-wrap svg { width: 100%; height: 100%; overflow: visible; }
.gauge-track { fill: none; stroke: rgba(70, 120, 200, .2); stroke-width: 10; }
.gauge-arc { fill: none; stroke: url(#gaugeGrad); stroke-width: 10; stroke-linecap: round;
filter: drop-shadow(0 0 8px rgba(63, 214, 255, .7));
stroke-dasharray: var(--arc, 0) 377; stroke-dashoffset: 0;
animation: gaugeDraw 1.6s cubic-bezier(.25, .8, .25, 1) .3s both; }
@keyframes gaugeDraw { from { stroke-dashoffset: 377; } to { stroke-dashoffset: 0; } }
.gauge-spin { fill: none; stroke: rgba(63, 214, 255, .5); stroke-width: 1.5; stroke-dasharray: 3 8;
transform-box: fill-box; transform-origin: center; animation: spin 16s linear infinite; }
.gauge-cap { fill: #bff2ff; filter: drop-shadow(0 0 6px #3fd6ff); animation: softIn .3s 1.8s both; }
.gauge-read { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.gauge-read b { font-size: 34px; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums;
text-shadow: 0 0 18px rgba(63, 214, 255, .6); }
.gauge-read span { margin-top: 3px; font-size: 11px; letter-spacing: 2px; color: #6f9bd0; }
.health-list { display: flex; flex-direction: column; gap: 12px; }
.h-item span { display: flex; justify-content: space-between; font-size: 12px; color: #9cc0e8; margin-bottom: 5px; }
.h-item b { color: #e8f5ff; font-variant-numeric: tabular-nums; }
/* ===== 中上:趋势大图(描边生长一次完成 + 单个流光点) ===== */
.trend-body { height: 100%; display: flex; flex-direction: column; }
.trend-legend { position: absolute; right: 6px; top: 2px; z-index: 2; display: flex; gap: 16px; font-size: 12px; color: #a9cdf3; }
.trend-legend i { display: inline-block; width: 16px; height: 3px; margin-right: 6px; border-radius: 2px; vertical-align: middle; }
.chart-host { position: relative; flex: 1; min-height: 0; }
.chart-host svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.gl { stroke: rgba(70, 130, 210, .18); stroke-width: 1; }
.gt { fill: #5f88bd; font-size: 12px; font-variant-numeric: tabular-nums; }
.series-done { fill: none; stroke: #3fd6ff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round;
filter: drop-shadow(0 0 7px rgba(63, 214, 255, .7));
stroke-dasharray: var(--len, 2000); stroke-dashoffset: 0;
animation: lineDraw 1.9s cubic-bezier(.4, 0, .2, 1) .35s both; }
@keyframes lineDraw { from { stroke-dashoffset: var(--len, 2000); } to { stroke-dashoffset: 0; } }
.series-added { fill: none; stroke: #8b7bff; stroke-width: 2; stroke-dasharray: 6 8; opacity: .85;
animation: softIn 1s 1.2s both; }
.area-done { fill: url(#trendArea); animation: softIn 1.2s .7s both; }
.chart-dot { fill: #05192f; stroke: #6fe9ff; stroke-width: 2; animation: softIn .4s both; }
@keyframes softIn { from { opacity: 0; } }
.runner-halo { fill: none; stroke: rgba(63, 214, 255, .8); stroke-width: 2; transform-box: fill-box; transform-origin: center; animation: haloPing 1.8s ease-out infinite; }
.runner { fill: #eaffff; filter: drop-shadow(0 0 8px #3fd6ff); }
@keyframes haloPing { from { transform: scale(.4); opacity: 1; } to { transform: scale(2.6); opacity: 0; } }
.chart-note { position: absolute; right: 4.5%; top: 12%; padding: 7px 12px; border: 1px solid rgba(63, 214, 255, .35); border-radius: 4px;
color: #dff6ff; font-size: 12px; background: rgba(6, 22, 52, .88); box-shadow: 0 0 18px rgba(63, 214, 255, .18);
animation: noteIn .6s 1.6s both; }
@keyframes noteIn { from { opacity: 0; transform: translateY(6px); } }
.chart-note::before { content: ""; position: absolute; left: -5px; top: 50%; width: 8px; height: 8px; transform: translateY(-50%) rotate(45deg); background: inherit; border-left: 1px solid rgba(63, 214, 255, .35); border-bottom: 1px solid rgba(63, 214, 255, .35); }
/* ===== 中下左:雷达(扫掠环保留,信号点静态) ===== */
.radar-body { display: grid; grid-template-columns: 1fr 168px; gap: 12px; align-items: center; height: 100%; }
.radar-field { position: relative; height: 100%; max-height: 230px; aspect-ratio: 1; margin: 0 auto; }
.radar-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.radar-ring { fill: none; stroke: rgba(63, 180, 255, .18); stroke-width: 1; }
.radar-cross { stroke: rgba(63, 180, 255, .14); stroke-width: 1; }
.radar-outer { fill: none; stroke: rgba(63, 214, 255, .5); stroke-width: 1.5; }
.radar-dashring { fill: none; stroke: rgba(63, 214, 255, .45); stroke-width: 1; stroke-dasharray: 4 9;
transform-box: fill-box; transform-origin: center; animation: spinR 18s linear infinite reverse; }
@keyframes spinR { to { transform: rotate(360deg); } }
.sweep { position: absolute; inset: 5.4%; border-radius: 50%; pointer-events: none;
background: conic-gradient(from 0deg, rgba(63, 214, 255, 0) 0deg, rgba(63, 214, 255, .03) 260deg, rgba(63, 214, 255, .16) 330deg, rgba(140, 242, 255, .5) 358deg, rgba(63, 214, 255, 0) 360deg);
animation: spinR 5s linear infinite; }
.blip { position: absolute; width: 9px; height: 9px; margin: -4.5px; border-radius: 50%; background: #86f0ff;
box-shadow: 0 0 12px #3fd6ff; }
.blip.warn { background: #ffd28a; box-shadow: 0 0 12px #ffb03a; }
.radar-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; pointer-events: none; }
.radar-center b { font-size: 26px; font-weight: 700; color: #eaf6ff; text-shadow: 0 0 14px rgba(63, 214, 255, .6); }
.radar-center span { display: block; font-size: 11px; color: #6f9bd0; letter-spacing: 2px; }
.signal-list { display: flex; flex-direction: column; gap: 10px; }
.sig { font-size: 12px; color: #a9cdf3; }
.sig .s-name { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
.sig .s-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fd6ff; box-shadow: 0 0 8px #3fd6ff; }
.sig:nth-child(n+4) .s-dot { background: #8b7bff; box-shadow: 0 0 8px #8b7bff; }
.sig .s-bar { height: 5px; border-radius: 3px; background: rgba(80, 130, 210, .16); overflow: hidden; }
.sig .s-bar b { display: block; height: 100%; border-radius: 3px; transform-origin: left;
background: linear-gradient(90deg, #2f7bff, #3fd6ff); animation: growX 1s cubic-bezier(.2, .7, .2, 1) both; }
/* ===== 中下右:环形图(弧长同步直出,一次生长动画) ===== */
.donut-body { display: grid; grid-template-columns: 176px 1fr; align-items: center; gap: 14px; height: 100%; }
.donut-wrap { position: relative; width: 176px; height: 176px; margin: 0 auto; }
.donut-wrap svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.donut-track { fill: none; stroke: rgba(70, 120, 200, .18); stroke-width: 13; }
.donut-part { fill: none; stroke-width: 13; animation: donutIn 1.1s cubic-bezier(.25, .8, .25, 1) both; }
@keyframes donutIn { from { stroke-dasharray: 0 377; } }
.donut-read { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.donut-read b { font-size: 30px; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums; text-shadow: 0 0 16px rgba(63, 214, 255, .45); }
.donut-read span { display: block; margin-top: 2px; font-size: 11px; letter-spacing: 2px; color: #6f9bd0; }
.donut-legend { display: flex; flex-direction: column; gap: 9px; }
.dl-row { display: grid; grid-template-columns: 10px 1fr auto; align-items: center; gap: 9px; font-size: 12px; color: #a9cdf3;
animation: rowIn .5s ease both; }
@keyframes rowIn { from { opacity: 0; transform: translateX(10px); } }
.dl-row i { width: 10px; height: 10px; border-radius: 3px; }
.dl-row b { color: #e8f5ff; font-variant-numeric: tabular-nums; }
/* ===== 右上:预警滚动(JS 合成器滚动,保留) ===== */
.marquee-view { position: relative; height: 100%; overflow: hidden;
-webkit-mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); }
.m-track { will-change: transform; }
.m-group { display: flex; flex-direction: column; gap: 9px; padding-bottom: 9px; }
.alert { display: grid; grid-template-columns: 9px 1fr auto; gap: 10px; align-items: center; padding: 9px 11px;
border: 1px solid rgba(38, 100, 200, .3); border-radius: 4px;
background: linear-gradient(90deg, rgba(20, 45, 92, .55), rgba(8, 22, 50, .35)); }
.alert .sev { width: 9px; height: 9px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 9px var(--cyan); }
.alert.high .sev { background: var(--red); box-shadow: 0 0 10px var(--red); }
.alert.mid .sev { background: var(--amber); box-shadow: 0 0 9px var(--amber); }
.alert b { display: block; font-size: 13px; font-weight: 500; color: #dff0ff; }
.alert span { display: block; margin-top: 2px; font-size: 11px; color: #7396c4; }
.alert time { font-size: 11px; color: #6f9bd0; font-variant-numeric: tabular-nums; }
/* ===== 右中:月度柱状(高度同步直出,一次生长) ===== */
.bars-host { height: 100%; display: flex; flex-direction: column; }
.bars-row { flex: 1; min-height: 0; display: flex; align-items: flex-end; gap: 9px; padding: 18px 2px 26px;
border-bottom: 1px solid rgba(70, 130, 210, .25); position: relative; }
.bars-row::before { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
background: linear-gradient(90deg, transparent, rgba(63, 214, 255, .7), transparent);
box-shadow: 0 0 12px rgba(63, 214, 255, .5); }
.bcol { position: relative; flex: 1; height: 100%; display: flex; align-items: flex-end; justify-content: center; }
.bcol .bar { width: 17px; border-radius: 4px 4px 0 0; transform-origin: bottom;
background: linear-gradient(180deg, #6fe9ff, #1f5fd0 65%, rgba(31, 95, 208, .25));
box-shadow: 0 0 14px rgba(63, 214, 255, .35); position: relative;
animation: barGrow 1s cubic-bezier(.2, .7, .2, 1) both; }
@keyframes barGrow { from { transform: scaleY(0); } }
.bcol .bar::before { content: ""; position: absolute; left: -2px; right: -2px; top: -3px; height: 3px; border-radius: 2px;
background: #a8f2ff; box-shadow: 0 0 10px #6fe9ff; }
.bcol .bv { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
font-size: 11px; color: #8fd2ff; font-variant-numeric: tabular-nums; white-space: nowrap; }
.bcol .bl { position: absolute; bottom: -22px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #5f88bd; white-space: nowrap; }
.bcol.peak .bar { box-shadow: 0 0 24px rgba(63, 214, 255, .75); }
.bars-foot { flex: none; padding-top: 8px; font-size: 11px; color: #7396c4; display: flex; justify-content: space-between; }
/* ===== 右下:任务进度(宽度同步直出,静态斜纹) ===== */
.task-list { height: 100%; display: flex; flex-direction: column; justify-content: space-around; gap: 9px; }
.task { }
.t-head { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #b8d6f5; margin-bottom: 6px; }
.t-head b { margin-left: auto; color: #8fd2ff; font-variant-numeric: tabular-nums; }
.t-state { padding: 1px 6px; border-radius: 3px; font-size: 10px; color: #7dffc4; background: rgba(53, 224, 140, .12); }
.t-state.warn { color: #ffd07d; background: rgba(255, 176, 58, .12); }
.pbar { position: relative; height: 10px; border-radius: 5px; overflow: hidden; background: rgba(80, 130, 210, .16); }
.pfill { position: absolute; inset: 0 auto 0 0; border-radius: 5px; transform-origin: left;
background: linear-gradient(90deg, #1f5fd0, #3fd6ff); box-shadow: 0 0 12px rgba(63, 214, 255, .5);
animation: growX 1.3s cubic-bezier(.2, .7, .2, 1) both; }
.pfill::before { content: ""; position: absolute; inset: 0;
background: repeating-linear-gradient(115deg, rgba(255, 255, 255, .16) 0 10px, transparent 10px 20px); }
/* ===== 底部跑马灯(JS 合成器滚动,保留) ===== */
.ticker { flex: none; height: 42px; display: flex; align-items: center; gap: 14px;
border: 1px solid var(--panel-line); background: rgba(7, 19, 45, .8); overflow: hidden; }
.ticker-label { flex: none; display: flex; align-items: center; gap: 8px; padding: 0 14px; height: 100%;
font-size: 13px; letter-spacing: 3px; color: #9df1ff; background: linear-gradient(90deg, rgba(63, 214, 255, .18), transparent);
border-right: 1px solid rgba(63, 214, 255, .25); }
.ticker-label i { width: 8px; height: 8px; border-radius: 50%; background: var(--red); box-shadow: 0 0 9px var(--red); }
.ticker-view { flex: 1; overflow: hidden; }
.tk-track { display: inline-flex; align-items: center; white-space: nowrap; will-change: transform; }
.tk-track span { font-size: 13px; color: #a9cdf3; }
.tk-track span::before { content: "◆"; margin-right: 12px; font-size: 9px; color: rgba(63, 214, 255, .75); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
</style>
</head>
<body>
<canvas id="bgNet"></canvas>
<div class="fx-grid"></div>
<div class="stage" id="stage">
<!-- ===== 顶部:标题文案直接改这里 ===== -->
<header class="hud">
<div class="hud-side l">
<div class="logo"><div class="logo-core"></div></div>
<div class="hud-org"><b>战略智能运行中心</b><span>STRATEGIC INTELLIGENCE CENTER</span></div>
</div>
<div class="hud-title-wrap">
<div class="wing l"></div>
<div class="hud-title">
<h1>城市运行态势感知大屏</h1>
<p>URBAN OPERATION SITUATION AWARENESS · 2026</p>
</div>
<div class="wing r"></div>
</div>
<div class="hud-side r">
<div class="live"><span class="live-dot"></span>LIVE</div>
<div class="clockbox">
<div class="clock" id="clock">--:--:--</div>
<div class="clock-sub"><span id="weekStr">—</span><span id="dateStr">—</span></div>
</div>
</div>
</header>
<main class="cols">
<!-- ===== 左列 ===== -->
<div class="col left">
<article class="panel d1">
<div class="p-head"><h2>核心运行指标</h2><span class="p-tag">实时</span></div>
<div class="p-body"><div class="kpi-grid" id="kpiGrid"></div></div>
</article>
<article class="panel d2">
<div class="p-head"><h2>区域活跃度排行</h2><span class="p-tag">TOP 5</span></div>
<div class="p-body"><div class="region-list" id="regionList"></div></div>
</article>
<article class="panel d3">
<div class="p-head"><h2>运行健康度</h2><span class="p-tag">实时评估</span></div>
<div class="p-body health-body">
<div class="gauge-wrap">
<svg viewBox="0 0 168 168">
<defs>
<linearGradient id="gaugeGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#2f7bff" /><stop offset="1" stop-color="#3fd6ff" />
</linearGradient>
</defs>
<circle class="gauge-track" cx="84" cy="84" r="60" />
<circle class="gauge-arc" id="gaugeArc" cx="84" cy="84" r="60" transform="rotate(-90 84 84)" />
<circle class="gauge-spin" cx="84" cy="84" r="49" />
<circle class="gauge-cap" id="gaugeCap" r="4" />
</svg>
<div class="gauge-read"><b class="count" id="gaugeNum">0</b><span>健康指数</span></div>
</div>
<div class="health-list" id="healthList"></div>
</div>
</article>
</div>
<!-- ===== 中列 ===== -->
<div class="col mid">
<article class="panel d4">
<div class="p-head"><h2>任务处置趋势</h2><span class="p-tag">近 12 个月 · 单位:件</span></div>
<div class="p-body trend-body">
<div class="trend-legend"><span><i style="background:#3fd6ff"></i>处置完成量</span><span><i style="background:#8b7bff"></i>新增任务量</span></div>
<div class="chart-host"><svg id="trendSvg"></svg><div class="chart-note" id="trendNote">8 月处置完成量创年度新高</div></div>
</div>
</article>
<div class="mid-bottom">
<article class="panel d5">
<div class="p-head"><h2>实时态势雷达</h2><span class="p-tag">信号自动跟踪</span></div>
<div class="p-body radar-body">
<div class="radar-field" id="radarField">
<svg class="radar-svg" viewBox="0 0 220 220">
<circle class="radar-ring" cx="110" cy="110" r="34" />
<circle class="radar-ring" cx="110" cy="110" r="66" />
<circle class="radar-outer" cx="110" cy="110" r="98" />
<circle class="radar-dashring" cx="110" cy="110" r="104" />
<line class="radar-cross" x1="110" y1="8" x2="110" y2="212" />
<line class="radar-cross" x1="8" y1="110" x2="212" y2="110" />
</svg>
<div class="sweep"></div>
<div id="blipLayer" style="position:absolute;inset:0"></div>
<div class="radar-center"><b id="radarCount">0</b><span>路活动信号</span></div>
</div>
<div class="signal-list" id="signalList"></div>
</div>
</article>
<article class="panel d6">
<div class="p-head"><h2>任务类型构成</h2><span class="p-tag">本期占比</span></div>
<div class="p-body donut-body">
<div class="donut-wrap">
<svg viewBox="0 0 176 176">
<circle class="donut-track" cx="88" cy="88" r="60" />
<g id="donutParts"></g>
</svg>
<div class="donut-read"><b id="donutNum">0</b><span>任务总量</span></div>
</div>
<div class="donut-legend" id="donutLegend"></div>
</div>
</article>
</div>
</div>
<!-- ===== 右列 ===== -->
<div class="col right">
<article class="panel d3">
<div class="p-head"><h2>实时预警滚动</h2><span class="p-tag">最近更新</span></div>
<div class="p-body">
<div class="marquee-view" id="alertView"><div class="m-track" id="alertTrack"><div class="m-group" id="alertGroup"></div></div></div>
</div>
</article>
<article class="panel d5">
<div class="p-head"><h2>月度处置对比</h2><span class="p-tag">单位:件</span></div>
<div class="p-body bars-host">
<div class="bars-row" id="monthBars"></div>
<div class="bars-foot"><span>柱体高亮为月度峰值</span><span id="barsPeak"></span></div>
</div>
</article>
<article class="panel d6">
<div class="p-head"><h2>重点任务进度</h2><span class="p-tag">本期跟踪</span></div>
<div class="p-body"><div class="task-list" id="taskList"></div></div>
</article>
</div>
</main>
<!-- ===== 底部跑马灯:文案在 reportData.ticker 里改 ===== -->
<footer class="ticker">
<div class="ticker-label"><i></i>实时动态</div>
<div class="ticker-view" id="tickerView"><div class="tk-track m-track" id="tickerTrack"><div class="m-group" id="tickerGroup" style="display:inline-flex;gap:38px;padding-right:38px"></div></div></div>
</footer>
</div>
<div class="fx-crt"></div>
<div class="fx-vignette"></div>
<script>
/* =====================================================
数据入口:弱模型只需要改这个对象,图表与动画自动重算
===================================================== */
const reportData = {
// 左上 KPI:value 数字 / suffix 单位 / decimals 小数位 / delta 环比文案 / up 涨跌色
kpis: [
{ label: '今日任务总量', value: 12860, delta: '+14.8%', up: true, color: '#3fd6ff' },
{ label: '按时处置率', value: 96.4, suffix: '%', decimals: 1, delta: '+2.1%', up: true, color: '#35e08c' },
{ label: '高优先级事项', value: 328, delta: '-8.6%', up: false, color: '#ffb03a' },
{ label: '综合运行指数', value: 87.6, decimals: 1, delta: '良好', up: true, color: '#8b7bff' },
],
// 左中 区域排行:[名称, 活跃度0-100, 展示数值]
regions: [
['华东区域', 91, '3,420 件'], ['华北区域', 84, '2,980 件'], ['华南区域', 77, '2,640 件'],
['西南区域', 63, '1,860 件'], ['西北区域', 48, '1,250 件'],
],
// 左下 健康度:score 0-100 + 分项 [名称, 0-100]
health: { score: 87.6, items: [['处理时效', 94.2], ['协同效率', 88.5], ['数据完整度', 97.8], ['风险可控度', 83.6]] },
// 中上 趋势:两个 12 个月序列
trend: {
months: ['10月', '11月', '12月', '1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月'],
done: [1060, 1190, 1110, 1320, 1240, 1500, 1610, 1550, 1730, 1890, 2110, 2050],
added: [1130, 1250, 1210, 1390, 1330, 1580, 1650, 1620, 1790, 1940, 2140, 2110],
},
// 中下右 环形图:[名称, 数值, 颜色]
types: [
['研判处置', 342, '#2f7bff'], ['协同联动', 262, '#3fd6ff'], ['巡查监测', 214, '#8b7bff'],
['督办跟进', 168, '#35e08c'], ['复核归档', 126, '#ffb03a'],
],
// 中下左 雷达信号:[名称, 强度0-100],个数即信号点数量
signals: [
['华东 · 协同请求', 82], ['华北 · 数据研判', 74], ['华南 · 巡查监测', 68],
['西南 · 任务分派', 55], ['西北 · 复核归档', 47], ['海上 · 专项监测', 39],
],
// 右上 预警滚动:[级别 high/mid/low, 标题, 归属, 时间]
alerts: [
['high', '跨区域协同事项临近时限', '华北 · 协同处置', '10:18'],
['mid', '重点任务处理量短时波动', '华东 · 数据研判', '10:05'],
['mid', '待分派事项超过预设阈值', '西南 · 任务分派', '09:42'],
['low', '两项指标进入观察区间', '综合 · 运行质量', '09:16'],
['low', '夜间巡检任务已按时闭环', '华南 · 巡查监测', '08:55'],
['mid', '数据同步延迟 3 分钟后恢复', '综合 · 平台运维', '08:31'],
['low', '例行数据备份任务完成', '综合 · 平台运维', '08:00'],
['high', '临期督办事项待确认责任人', '华东 · 督办跟进', '07:48'],
],
// 右中 月度柱状:[月份, 数值]
months: [
['10月', 1090], ['11月', 1210], ['12月', 1140], ['1月', 1330], ['2月', 1260], ['3月', 1520],
['4月', 1640], ['5月', 1570], ['6月', 1760], ['7月', 1920], ['8月', 2150], ['9月', 2080],
],
// 右下 任务进度:[名称, 0-100, 状态文案],状态含「关注」二字显示橙色
tasks: [
['区域协同专项', 78, '按计划'], ['重点事项闭环治理', 65, '按计划'], ['数据质量提升行动', 52, '需关注'],
['阶段成果验收准备', 38, '推进中'], ['四季度资源预置', 23, '推进中'],
],
// 底部跑马灯文案
ticker: [
'华北协同事项已进入 48 小时预警窗口,请及时确认责任人',
'8 月处置完成量创年度新高 2,110 件',
'数据完整度 97.8%,本期口径已完成期末核验',
'临期提醒规则已由 24 小时提前至 48 小时',
'华东区域协同机制经验正在向华南、西南复制推广',
],
};
/* ===================== 工具 ===================== */
const $ = (sel) => document.querySelector(sel);
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const fmt = (n, d = 0) => n.toLocaleString('zh-CN', { minimumFractionDigits: d, maximumFractionDigits: d });
// 统一 rAF 循环:限帧(默认 30fps,肉眼流畅且 CPU 减半),后台标签页浏览器会自动暂停 rAF
function rafLoop(fn, fps = 30) {
if (REDUCED) return;
let last = 0;
const minStep = 1000 / fps - 1;
(function frame(now) {
if (now - last >= minStep) { last = now; fn(now); }
requestAnimationFrame(frame);
})(performance.now());
}
function countUp(el, target, { decimals = 0, duration = 1600, delay = 0, suffix = '' } = {}) {
el.textContent = fmt(0, decimals) + suffix;
if (REDUCED) { el.textContent = fmt(target, decimals) + suffix; return; }
const t0 = performance.now() + delay;
(function frame(now) {
if (now < t0) return requestAnimationFrame(frame);
const p = Math.min(1, (now - t0) / duration);
const e = 1 - Math.pow(1 - p, 3);
el.textContent = fmt(target * e, decimals) + suffix;
if (p < 1) requestAnimationFrame(frame);
})(performance.now());
}
function niceCeil(v) {
const p = Math.pow(10, String(Math.floor(v)).length - 1);
const n = v / p;
const m = n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.4 ? 2.4 : Math.ceil(n / 0.5) * 0.5;
return m * p;
}
// 无缝滚动跑马灯:尺寸只在启动/resize 时读取一次,避免每帧布局读取
function startMarquee(view, axis, speed) {
const track = view.querySelector('.m-track');
const group = track.children[0];
track.appendChild(group.cloneNode(true));
if (REDUCED) return;
let off = 0, last = performance.now(), paused = false;
let size = axis === 'x' ? group.offsetWidth : group.offsetHeight;
addEventListener('resize', () => { size = axis === 'x' ? group.offsetWidth : group.offsetHeight; });
view.addEventListener('mouseenter', () => paused = true);
view.addEventListener('mouseleave', () => paused = false);
rafLoop((now) => {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (paused || size <= 0) return;
off = (off + speed * dt) % size;
track.style.transform = `translate${axis.toUpperCase()}(${-off}px)`;
}, 60);
}
/* ===================== 舞台缩放 / 时钟 / 粒子 ===================== */
const stage = $('#stage');
function fit() {
const s = Math.min(innerWidth / 1920, innerHeight / 1080);
stage.style.transform = `translate(-50%, -50%) scale(${s})`;
}
addEventListener('resize', fit); fit();
function tick() {
const n = new Date();
$('#clock').textContent = n.toLocaleTimeString('zh-CN', { hour12: false });
$('#weekStr').textContent = '星期' + '日一二三四五六'[n.getDay()];
$('#dateStr').textContent = n.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replaceAll('/', '-');
}
tick(); setInterval(tick, 1000);
// 粒子星网(轻量版):约 44 点、连线半径 110px、30fps 限帧;禁用动画时只画一帧静态图
(function particles() {
const cv = $('#bgNet'), ctx = cv.getContext('2d');
let pts = [];
function size() {
const d = Math.min(2, devicePixelRatio || 1);
cv.width = innerWidth * d; cv.height = innerHeight * d;
ctx.setTransform(d, 0, 0, d, 0, 0);
const N = Math.min(44, Math.round(innerWidth * innerHeight / 40000));
pts = Array.from({ length: N }, () => ({
x: Math.random() * innerWidth, y: Math.random() * innerHeight,
vx: (Math.random() - .5) * .35, vy: (Math.random() - .5) * .35, r: Math.random() * 1.6 + .7,
}));
}
size(); addEventListener('resize', size);
function draw() {
ctx.clearRect(0, 0, innerWidth, innerHeight);
for (const p of pts) {
if (!REDUCED) { p.x += p.vx * 2; p.y += p.vy * 2; }
if (p.x < -10) p.x = innerWidth + 10; if (p.x > innerWidth + 10) p.x = -10;
if (p.y < -10) p.y = innerHeight + 10; if (p.y > innerHeight + 10) p.y = -10;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.fillStyle = 'rgba(120,190,255,.65)'; ctx.fill();
}
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) {
const a = pts[i], b = pts[j], dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy;
if (d2 < 12100) {
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(70,140,255,${(1 - Math.sqrt(d2) / 110) * .3})`; ctx.lineWidth = 1; ctx.stroke();
}
}
}
if (REDUCED) { draw(); } else { rafLoop(draw, 30); }
})();
/* ===================== 左上:KPI ===================== */
$('#kpiGrid').innerHTML = reportData.kpis.map((k) => `
<div class="kpi">
<div class="kicon" style="--ic:${k.color || '#3fd6ff'}"></div>
<div>
<div class="kpi-label">${k.label}</div>
<div class="kpi-v"><span class="num"></span>${k.suffix ? `<small>${k.suffix}</small>` : ''}</div>
<span class="kpi-delta ${k.up ? 'up' : 'down'}">${k.delta}</span>
</div>
</div>`).join('');
document.querySelectorAll('#kpiGrid .kpi').forEach((card, i) => {
const k = reportData.kpis[i];
countUp(card.querySelector('.num'), k.value, { decimals: k.decimals || 0, delay: 300 + i * 180 });
});
/* ===================== 左中:区域排行(宽度同步直出) ===================== */
$('#regionList').innerHTML = reportData.regions.map(([name, pct, val], i) => `
<div class="region-row">
<span class="rk">${i + 1}</span>
<div>
<div class="region-name"><span>${name}</span><em>${val}</em></div>
<div class="meter"><b style="width:${pct}%;animation-delay:${(0.35 + i * 0.13).toFixed(2)}s"></b></div>
</div>
</div>`).join('');
/* ===================== 左下:健康仪表(弧长同步直出) ===================== */
(function gauge() {
const C = 2 * Math.PI * 60, score = reportData.health.score;
$('#healthList').innerHTML = reportData.health.items.map(([n, v], i) => `
<div class="h-item"><span><em style="font-style:normal">${n}</em><b>${fmt(v, 1)}%</b></span>
<div class="meter" style="height:6px"><b style="width:${v}%;animation-delay:${(0.5 + i * 0.14).toFixed(2)}s"></b></div></div>`).join('');
countUp($('#gaugeNum'), score, { decimals: 1, duration: 1700, delay: 350 });
$('#gaugeArc').style.setProperty('--arc', (C * score / 100).toFixed(1));
const rad = (-90 + 360 * score / 100) * Math.PI / 180;
$('#gaugeCap').setAttribute('cx', (84 + 60 * Math.cos(rad)).toFixed(1));
$('#gaugeCap').setAttribute('cy', (84 + 60 * Math.sin(rad)).toFixed(1));
})();
/* ===================== 中上:趋势大图(描边生长 CSS 动画 + 流动光点) ===================== */
(function trend() {
const svg = $('#trendSvg'), host = svg.parentElement;
const W = host.offsetWidth, H = host.offsetHeight;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const { months, done, added } = reportData.trend;
const padL = 60, padR = 22, padT = 22, padB = 34;
const max = niceCeil(Math.max(...done, ...added));
const X = (i) => padL + i * (W - padL - padR) / (months.length - 1);
const Y = (v) => H - padB - (v / max) * (H - padT - padB);
let inner = `
<defs>
<linearGradient id="trendArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3fd6ff" stop-opacity=".34" /><stop offset="1" stop-color="#3fd6ff" stop-opacity="0" />
</linearGradient>
</defs>`;
for (let g = 0; g <= 4; g++) {
const y = padT + g * (H - padT - padB) / 4;
inner += `<line class="gl" x1="${padL}" x2="${W - padR}" y1="${y}" y2="${y}"/>`;
inner += `<text class="gt" x="${padL - 10}" y="${y + 4}" text-anchor="end">${fmt(max * (4 - g) / 4)}</text>`;
}
months.forEach((m, i) => inner += `<text class="gt" x="${X(i)}" y="${H - 10}" text-anchor="middle">${m}</text>`);
const pts = done.map((v, i) => [X(i), Y(v)]);
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
const area = `${line} L${X(months.length - 1)} ${H - padB} L${padL} ${H - padB} Z`;
const ptsAdded = added.map((v, i) => [X(i), Y(v)]);
const lineAdded = ptsAdded.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
inner += `<path class="area-done" d="${area}"/>`;
inner += `<path class="series-added" d="${lineAdded}"/>`;
inner += `<path class="series-done" id="donePath" d="${line}"/>`;
pts.forEach(([x, y], i) => {
if (i % 2 === 0) inner += `<circle class="chart-dot" style="animation-delay:${(0.9 + i * 0.09).toFixed(2)}s" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3.6"/>`;
});
inner += `<circle class="runner-halo" id="runnerHalo" r="7" cx="-50" cy="-50"/><circle class="runner" id="runnerDot" r="4.5" cx="-50" cy="-50"/>`;
svg.innerHTML = inner;
// 主线长度作为 CSS 变量,描边生长动画完全由 CSS 驱动
$('#donePath').style.setProperty('--len', $('#donePath').getTotalLength().toFixed(1));
// 流动光点沿主线循环(30fps 限帧)
const dot = $('#runnerDot'), halo = $('#runnerHalo'), path = $('#donePath'), L = path.getTotalLength();
if (!REDUCED) {
let t0 = null;
rafLoop((now) => {
if (!t0) t0 = now;
const p = ((now - t0) / 12000) % 1;
const pt = path.getPointAtLength(p * L);
dot.setAttribute('cx', pt.x); dot.setAttribute('cy', pt.y);
halo.setAttribute('cx', pt.x); halo.setAttribute('cy', pt.y);
}, 30);
} else {
const pt = path.getPointAtLength(L);
dot.setAttribute('cx', pt.x); dot.setAttribute('cy', pt.y); halo.style.display = 'none';
}
})();
/* ===================== 中下左:雷达(信号点静态布设) ===================== */
(function radar() {
const layer = $('#blipLayer');
reportData.signals.forEach((s, i) => {
const el = document.createElement('span');
el.className = 'blip' + (i === 0 ? ' warn' : '');
const a = Math.random() * Math.PI * 2, d = 10 + Math.random() * 32;
el.style.left = (50 + Math.cos(a) * d) + '%';
el.style.top = (50 + Math.sin(a) * d) + '%';
layer.appendChild(el);
});
$('#radarCount').textContent = reportData.signals.length;
$('#signalList').innerHTML = reportData.signals.map(([n, v], i) => `
<div class="sig"><div class="s-name"><span class="s-dot"></span>${n}</div>
<div class="s-bar"><b style="width:${v}%;animation-delay:${(0.45 + i * 0.12).toFixed(2)}s"></b></div></div>`).join('');
})();
/* ===================== 中下右:环形图(弧长同步直出) ===================== */
(function donut() {
const C = 2 * Math.PI * 60, total = reportData.types.reduce((s, t) => s + t[1], 0);
const g = $('#donutParts');
let acc = 0;
reportData.types.forEach(([name, v, color], i) => {
const len = C * v / total;
const seg = Math.max(0, len - 3);
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('class', 'donut-part'); c.setAttribute('cx', 88); c.setAttribute('cy', 88); c.setAttribute('r', 60);
c.setAttribute('stroke', color);
c.style.strokeDasharray = `${seg.toFixed(1)} ${(C - seg).toFixed(1)}`;
c.style.strokeDashoffset = (-acc).toFixed(1);
c.style.animationDelay = `${(0.42 + i * 0.16).toFixed(2)}s`;
g.appendChild(c);
acc += len;
});
countUp($('#donutNum'), total, { delay: 400 });
$('#donutLegend').innerHTML = reportData.types.map(([name, v, color], i) => `
<div class="dl-row" style="animation-delay:${(0.65 + i * 0.12).toFixed(2)}s"><i style="background:${color}"></i><span>${name}</span><b>${(v / total * 100).toFixed(1)}%</b></div>`).join('');
})();
/* ===================== 右上:预警滚动 ===================== */
$('#alertGroup').innerHTML = reportData.alerts.map(([lv, title, meta, time]) => `
<div class="alert ${lv}"><span class="sev"></span>
<div><b>${title}</b><span>${meta}</span></div><time>${time}</time></div>`).join('');
startMarquee($('#alertView'), 'y', 26);
/* ===================== 右中:月度柱状(高度同步直出) ===================== */
(function bars() {
const vals = reportData.months.map((m) => m[1]);
const peak = Math.max(...vals);
$('#monthBars').innerHTML = reportData.months.map(([m, v], i) => `
<div class="bcol ${v === peak ? 'peak' : ''}">
<span class="bv">${fmt(v)}</span>
<div class="bar" style="height:${(v / peak * 100).toFixed(1)}%;animation-delay:${(0.38 + i * 0.075).toFixed(2)}s"></div>
<span class="bl">${m}</span>
</div>`).join('');
$('#barsPeak').textContent = `峰值 ${fmt(peak)} 件`;
})();
/* ===================== 右下:任务进度(宽度同步直出) ===================== */
$('#taskList').innerHTML = reportData.tasks.map(([n, p, st], i) => `
<div class="task">
<div class="t-head"><span>${n}</span><span class="t-state ${st.includes('关注') ? 'warn' : ''}">${st}</span><b>${p}%</b></div>
<div class="pbar"><div class="pfill" style="width:${p}%;animation-delay:${(0.42 + i * 0.14).toFixed(2)}s"></div></div>
</div>`).join('');
/* ===================== 底部跑马灯 ===================== */
$('#tickerGroup').innerHTML = reportData.ticker.map((t) => `<span>${t}</span>`).join('');
startMarquee($('#tickerView'), 'x', 90);
</script>
</body>
</html>
@@ -0,0 +1,750 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>浅色轻盈风 · 大屏汇报模版(玻璃拟态动画版)</title>
<!--
============================================================
大屏汇报模版 B · 浅色轻盈玻璃风(全动画版)
============================================================
· 1920×1080 固定画布,JS 自动等比缩放,任意分辨率大屏/投影直接全屏打开即可
· 零依赖单文件:不需要网络、不需要构建,双击即可运行
· 智能体填充方式(弱模型友好):
1) 标题、面板名称等文案:直接全局替换 HTML 里的中文文本
2) 所有数字、图表、列表:只改页面底部 <script> 里的 reportData 对象
3) 不要改 CSS 类名与页面结构,所有动画会自动生效
· 内置动画:渐变光斑漂浮 / 上升粒子 / 标题下划线生长 / 滚轮数字翻牌 /
波浪流动 / 折线平滑生长 + 悬停取数提示 / 环形图弧线生长 /
弹簧柱状回弹 / 排行条流光 / 时间线进度填充 / 卡片悬浮光晕 / 要点跑马灯
-->
<style>
:root {
color-scheme: light;
--ink: #1e2a4a;
--sub: #66789a;
--indigo: #4d6bf2;
--cyan: #17b3ae;
--coral: #ff7a66;
--amber: #ffb648;
--violet: #8b7cf6;
--card: rgba(255, 255, 255, 0.66);
--card-line: rgba(255, 255, 255, 0.9);
--shadow: 0 18px 44px rgba(99, 125, 190, 0.16);
}
* { box-sizing: border-box; margin: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
color: var(--ink);
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
background: linear-gradient(160deg, #eef3fc 0%, #f6f4f0 55%, #eaf6f3 100%);
}
/* ===== 背景层:光斑 / 点阵 / 上升粒子 ===== */
.blob { position: fixed; border-radius: 50%; filter: blur(70px); z-index: 0; pointer-events: none; }
.blob.b1 { width: 44vw; height: 44vw; left: -12vw; top: -16vw; background: rgba(146, 176, 255, .5); animation: drift1 26s ease-in-out infinite alternate; }
.blob.b2 { width: 36vw; height: 36vw; right: -10vw; top: 18vh; background: rgba(255, 205, 224, .42); animation: drift2 30s ease-in-out infinite alternate; }
.blob.b3 { width: 40vw; height: 40vw; left: 24vw; bottom: -18vw; background: rgba(152, 232, 216, .45); animation: drift3 34s ease-in-out infinite alternate; }
@keyframes drift1 { to { transform: translate(9vw, 7vh) scale(1.12); } }
@keyframes drift2 { to { transform: translate(-7vw, -9vh) scale(.9); } }
@keyframes drift3 { to { transform: translate(-9vw, -6vh) scale(1.08); } }
.dots { position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image: radial-gradient(rgba(90, 120, 190, .13) 1px, transparent 1.5px);
background-size: 26px 26px; }
#motes { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
#motes i { position: absolute; bottom: -18px; border-radius: 50%; opacity: .8;
background: radial-gradient(circle, rgba(77, 107, 242, .3), rgba(77, 107, 242, 0) 70%);
animation: riseUp var(--d) linear var(--dl) infinite; }
@keyframes riseUp { to { transform: translateY(-112vh); opacity: 0; } }
/* ===== 固定舞台 1920×1080 ===== */
.stage {
position: absolute; z-index: 2; left: 50%; top: 50%;
width: 1920px; height: 1080px;
transform: translate(-50%, -50%); transform-origin: center;
display: flex; flex-direction: column; gap: 12px;
padding: 20px 28px 14px;
}
/* ===== 顶部 ===== */
.top { flex: none; height: 84px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; animation: fadeDown .8s ease both; }
@keyframes fadeDown { from { opacity: 0; transform: translateY(-14px); } }
.brand { display: flex; align-items: center; gap: 14px; }
.orbit { position: relative; width: 44px; height: 44px; }
.orbit::before, .orbit::after { content: ""; position: absolute; border-radius: 50%; border: 2px solid transparent; }
.orbit::before { inset: 0; border-top-color: var(--indigo); border-right-color: rgba(77, 107, 242, .3); animation: spin 5s linear infinite; }
.orbit::after { inset: 8px; border-bottom-color: var(--cyan); border-left-color: rgba(23, 179, 174, .3); animation: spin 3.4s linear infinite reverse; }
@keyframes spin { to { transform: rotate(360deg); } }
.orbit-core { position: absolute; left: 50%; top: 50%; width: 12px; height: 12px; border-radius: 50%;
transform: translate(-50%, -50%); background: linear-gradient(135deg, var(--indigo), var(--cyan));
box-shadow: 0 0 12px rgba(77, 107, 242, .55); animation: corePulse 2s ease-in-out infinite; }
@keyframes corePulse { 50% { transform: translate(-50%, -50%) scale(1.3); box-shadow: 0 0 20px rgba(77, 107, 242, .8); } }
.brand b { display: block; font-size: 16px; letter-spacing: 2px; color: var(--ink); }
.brand span { display: block; margin-top: 3px; font-size: 10px; letter-spacing: 3px; color: var(--sub); }
.t-wrap { text-align: center; }
.t-wrap h1 { font-size: 38px; font-weight: 800; letter-spacing: 8px; white-space: nowrap;
background: linear-gradient(100deg, #3d55c8 15%, #4d6bf2 45%, #17b3ae 85%);
-webkit-background-clip: text; background-clip: text; color: transparent; }
.t-line { position: relative; height: 4px; width: 0; margin: 12px auto 0; border-radius: 2px;
background: linear-gradient(90deg, transparent, var(--indigo), var(--cyan), transparent);
animation: lineGrow 1s cubic-bezier(.2, .7, .2, 1) .35s forwards; }
@keyframes lineGrow { to { width: 62%; } }
.t-line::after { content: ""; position: absolute; top: -2px; width: 34px; height: 8px; border-radius: 4px;
background: rgba(255, 255, 255, .9); filter: blur(2px); opacity: 0; animation: lineGlint 2.8s ease-in-out 1.4s infinite; }
@keyframes lineGlint { 0%, 100% { opacity: 0; left: 10%; } 50% { opacity: .95; left: calc(100% - 44px); } }
.t-wrap p { margin-top: 8px; font-size: 11px; letter-spacing: 5px; color: var(--sub); }
.top-right { display: flex; justify-content: flex-end; align-items: center; gap: 10px; }
.chip { display: inline-flex; align-items: center; gap: 7px; padding: 7px 14px; border-radius: 999px;
border: 1px solid var(--card-line); background: var(--card); backdrop-filter: blur(10px);
box-shadow: 0 8px 20px rgba(99, 125, 190, .12); font-size: 13px; color: var(--ink); }
.chip.accent { color: #fff; border: 0; background: linear-gradient(120deg, var(--indigo), #6a8bff); box-shadow: 0 10px 22px rgba(77, 107, 242, .3); }
/* ===== 玻璃面板通用 ===== */
.panel { position: relative; min-height: 0; display: flex; flex-direction: column;
padding: 16px 18px; border-radius: 22px; border: 1px solid var(--card-line);
background: var(--card); backdrop-filter: blur(14px);
box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, .95);
animation: fadeUp .7s cubic-bezier(.2, .7, .2, 1) both;
transition: box-shadow .35s ease; }
.panel:hover { box-shadow: 0 26px 56px rgba(99, 125, 190, .24), inset 0 1px 0 rgba(255, 255, 255, .95); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(18px); } }
.d1 { animation-delay: .08s; } .d2 { animation-delay: .18s; } .d3 { animation-delay: .28s; }
.d4 { animation-delay: .38s; } .d5 { animation-delay: .48s; } .d6 { animation-delay: .58s; }
.p-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex: none; }
.p-head h2 { display: flex; align-items: center; gap: 10px; font-size: 16px; font-weight: 700; color: var(--ink); letter-spacing: 1px; }
.p-head h2::before { content: ""; width: 22px; height: 22px; border-radius: 7px; flex: none;
background: linear-gradient(135deg, var(--indigo), var(--cyan)); box-shadow: 0 6px 12px rgba(77, 107, 242, .3);
animation: chipBreath 3s ease-in-out infinite alternate; }
@keyframes chipBreath { from { transform: scale(1) rotate(0deg); } to { transform: scale(1.08) rotate(8deg); } }
.p-tag { margin-left: auto; font-size: 11px; color: var(--sub); padding: 3px 10px; border-radius: 999px;
border: 1px dashed rgba(102, 120, 154, .4); }
.p-body { flex: 1; min-height: 0; position: relative; }
/* ===== KPI 行 ===== */
.kpis { flex: none; height: 156px; display: grid; grid-template-columns: 1.55fr 1fr 1fr 1fr; gap: 14px; }
.kpi { padding: 18px 20px; }
.kpi.hero { border: 0; color: #fff; background: linear-gradient(128deg, #4d6bf2 0%, #6a5cf0 52%, #17b3ae 100%);
box-shadow: 0 22px 48px rgba(77, 107, 242, .38), inset 0 1px 0 rgba(255, 255, 255, .35); overflow: hidden; }
.kpi-label { font-size: 13px; color: var(--sub); letter-spacing: 1px; }
.hero .kpi-label { color: rgba(255, 255, 255, .82); }
.hero-ring { position: absolute; right: -34px; top: -34px; width: 130px; height: 130px; border-radius: 50%;
border: 1.5px dashed rgba(255, 255, 255, .4); animation: spin 16s linear infinite; }
.hero-ring::after { content: ""; position: absolute; left: 50%; top: -4px; width: 8px; height: 8px; border-radius: 50%;
transform: translateX(-50%); background: #fff; box-shadow: 0 0 10px rgba(255, 255, 255, .9); }
.odo { display: inline-flex; height: 1em; line-height: 1; margin-top: 14px; font-size: 46px; font-weight: 800;
font-variant-numeric: tabular-nums; text-shadow: 0 4px 18px rgba(0, 20, 60, .25); }
.odo .odo-d { position: relative; width: .6em; height: 1em; overflow: hidden; }
.odo .odo-strip { position: absolute; left: 0; top: 0; display: flex; flex-direction: column;
transform: translateY(0); transition: transform 1.5s cubic-bezier(.3, .9, .3, 1); }
.odo .odo-strip i { display: block; height: 1em; font-style: normal; text-align: center; }
.odo .odo-ch { height: 1em; }
.hero .kpi-meta { display: flex; align-items: center; gap: 8px; margin-top: 16px; font-size: 12px; color: rgba(255, 255, 255, .85); }
.delta-pill { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 999px; font-weight: 700; font-size: 12px; }
.hero .delta-pill { color: #eafff5; background: rgba(255, 255, 255, .22); }
.waves { position: absolute; left: 0; right: 0; bottom: -2px; width: 100%; height: 64px; overflow: hidden; pointer-events: none; }
.waves svg { position: absolute; left: 0; bottom: 0; width: 2400px; height: 64px; }
.waves .w1 { fill: rgba(255, 255, 255, .16); animation: waveShift 8s linear infinite; }
.waves .w2 { fill: rgba(255, 255, 255, .1); animation: waveShift 13s linear infinite reverse; }
@keyframes waveShift { to { transform: translateX(-600px); } }
.kpi-v { margin-top: 10px; font-size: 34px; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
.kpi-v small { font-size: 15px; font-weight: 600; color: var(--sub); margin-left: 2px; }
.kpi-meta { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 12px; color: var(--sub); }
.delta-pill.up { color: #0e9c8f; background: rgba(23, 179, 174, .14); }
.delta-pill.down { color: #e2543e; background: rgba(255, 122, 102, .14); }
.spark { position: absolute; right: 18px; bottom: 16px; width: 92px; height: 32px; opacity: .9; }
.spark path { fill: none; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
/* ===== 主区 ===== */
.main { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(12, 1fr); grid-template-rows: minmax(0, .95fr) minmax(0, 1fr); gap: 14px; }
.span8 { grid-column: span 8; } .span4 { grid-column: span 4; }
/* 折线图 */
.chart-host { position: relative; height: 100%; }
.chart-host svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.gl { stroke: rgba(120, 145, 190, .16); stroke-width: 1; }
.gt { fill: #93a5c4; font-size: 12px; font-variant-numeric: tabular-nums; }
.line-target { fill: none; stroke: #b9c6e2; stroke-width: 2.5; stroke-dasharray: 7 8; opacity: 0; transition: opacity .9s 1.1s; }
.line-actual { fill: none; stroke: url(#lineGrad); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round;
filter: drop-shadow(0 6px 10px rgba(77, 107, 242, .3)); }
.line-area { fill: url(#areaGrad); opacity: 0; transition: opacity 1.1s .7s; }
.ldot { fill: #fff; stroke: var(--indigo); stroke-width: 3; transform: scale(0); transform-box: fill-box; transform-origin: center;
transition: transform .5s cubic-bezier(.34, 1.56, .64, 1); }
.ldot.on { transform: scale(1); }
.guide { stroke: rgba(77, 107, 242, .5); stroke-width: 1.5; stroke-dasharray: 4 4; opacity: 0; }
.tip { position: absolute; z-index: 3; min-width: 128px; padding: 10px 12px; border-radius: 12px; pointer-events: none;
border: 1px solid var(--card-line); background: rgba(255, 255, 255, .92); backdrop-filter: blur(8px);
box-shadow: 0 14px 30px rgba(99, 125, 190, .24); opacity: 0; transform: translateY(6px) scale(.96);
transition: opacity .18s ease, transform .18s ease; }
.tip.show { opacity: 1; transform: none; }
.tip b { display: block; font-size: 12px; color: var(--sub); margin-bottom: 6px; }
.tip div { display: flex; justify-content: space-between; gap: 16px; font-size: 12px; color: var(--ink); }
.tip div i { font-style: normal; font-weight: 700; font-variant-numeric: tabular-nums; }
.tip .dotk { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
.line-legend { position: absolute; right: 4px; top: 0; z-index: 2; display: flex; gap: 16px; font-size: 12px; color: var(--sub); }
.line-legend i { display: inline-block; width: 16px; height: 3px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
/* 环形图 */
.donut-body { display: grid; grid-template-columns: 172px 1fr; align-items: center; gap: 16px; height: 100%; }
.donut-wrap { position: relative; width: 172px; height: 172px; margin: 0 auto; }
.donut-wrap svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.donut-track { fill: none; stroke: rgba(120, 145, 190, .14); stroke-width: 14; }
.donut-part { fill: none; stroke-width: 14; stroke-linecap: round; transition: stroke-dasharray 1.1s cubic-bezier(.25, .8, .25, 1); }
.donut-read { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.donut-read b { font-size: 30px; font-weight: 800; color: var(--ink); }
.donut-read span { display: block; margin-top: 2px; font-size: 11px; color: var(--sub); letter-spacing: 2px; }
.dl-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; font-size: 13px; color: var(--ink);
background: rgba(255, 255, 255, .45); opacity: 0; transform: translateX(12px); transition: all .5s ease both; margin-bottom: 8px; }
.dl-row i { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.dl-row .dl-bar { flex: 1; height: 6px; border-radius: 3px; background: rgba(120, 145, 190, .16); overflow: hidden; }
.dl-row .dl-bar b { display: block; height: 100%; width: 0; border-radius: 3px; transition: width 1s cubic-bezier(.2, .7, .2, 1); }
.dl-row b.v { font-variant-numeric: tabular-nums; color: var(--sub); }
/* 排行 */
.rank-list { height: 100%; min-height: 0; display: flex; flex-direction: column; justify-content: space-around; gap: 7px; }
.rank-row { display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 10px; }
.medal { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 9px; font-size: 13px; font-weight: 800; color: #fff;
background: linear-gradient(135deg, #9db1cf, #c3d1e8); box-shadow: 0 6px 12px rgba(99, 125, 190, .25); }
.rank-row:nth-child(1) .medal { background: linear-gradient(135deg, #ffb648, #ff8f3c); box-shadow: 0 6px 14px rgba(255, 170, 60, .45); }
.rank-row:nth-child(2) .medal { background: linear-gradient(135deg, #8fa6c4, #b9c9de); }
.rank-row:nth-child(3) .medal { background: linear-gradient(135deg, #e2a06b, #c97d4e); }
.rank-name { font-size: 13px; color: var(--ink); margin-bottom: 4px; display: flex; justify-content: space-between; }
.rank-name em { font-style: normal; color: var(--sub); font-size: 12px; font-variant-numeric: tabular-nums; }
.rbar { position: relative; height: 8px; border-radius: 5px; overflow: hidden; background: rgba(120, 145, 190, .14); }
.rbar b { position: absolute; inset: 0 auto 0 0; width: 0; border-radius: 5px;
background: linear-gradient(90deg, var(--indigo), #7c9bff); transition: width 1.1s cubic-bezier(.34, 1.2, .4, 1); }
.rbar b::after { content: ""; position: absolute; inset: 0;
background: linear-gradient(100deg, transparent 25%, rgba(255, 255, 255, .65) 50%, transparent 75%);
transform: translateX(-100%); animation: meterShine 2.8s ease-in-out infinite; }
@keyframes meterShine { 55%, 100% { transform: translateX(100%); } }
/* 柱状图 */
.bars-host { height: 100%; display: flex; flex-direction: column; }
.bars-row { flex: 1; min-height: 0; position: relative; display: flex; align-items: flex-end; gap: 10px; padding: 20px 4px 26px;
border-bottom: 1.5px solid rgba(120, 145, 190, .2); }
.avg-line { position: absolute; left: 0; right: 0; border-top: 2px dashed rgba(255, 182, 72, .75); opacity: 0; transition: opacity .8s 1.4s; }
.avg-line span { position: absolute; right: 0; bottom: 4px; padding: 2px 8px; border-radius: 999px; font-size: 10px;
color: #b06f13; background: rgba(255, 182, 72, .18); }
.bcol { position: relative; flex: 1; height: 100%; display: flex; align-items: flex-end; justify-content: center; }
.bcol .bar { width: 20px; height: 0; border-radius: 9px 9px 4px 4px; position: relative;
background: linear-gradient(180deg, #7c9bff, var(--indigo) 70%, rgba(77, 107, 242, .55));
box-shadow: 0 8px 16px rgba(77, 107, 242, .28);
transition: height 1s cubic-bezier(.34, 1.56, .64, 1), box-shadow .3s ease; }
.bcol:hover .bar { box-shadow: 0 10px 22px rgba(77, 107, 242, .5); }
.bcol.peak .bar { background: linear-gradient(180deg, #63dbd3, var(--cyan) 70%, rgba(23, 179, 174, .6)); box-shadow: 0 10px 20px rgba(23, 179, 174, .42); }
.bcol .bv { position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%) translateY(4px);
font-size: 11px; font-weight: 700; color: var(--ink); white-space: nowrap; opacity: 0; transition: all .4s ease; font-variant-numeric: tabular-nums; }
.bcol .bl { position: absolute; bottom: -22px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #93a5c4; white-space: nowrap; }
.bars-foot { flex: none; padding-top: 9px; font-size: 11px; color: var(--sub); display: flex; justify-content: space-between; }
/* 时间线 */
.tl { position: relative; height: 100%; display: flex; align-items: stretch; padding-top: 16px; }
.tl-line { position: absolute; left: 2%; right: 2%; top: 40px; height: 5px; border-radius: 3px; background: rgba(120, 145, 190, .16); }
.tl-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 3px;
background: linear-gradient(90deg, var(--indigo), var(--cyan));
box-shadow: 0 4px 12px rgba(77, 107, 242, .4); transition: width 1.6s cubic-bezier(.2, .7, .2, 1) .5s; }
.tl-item { position: relative; flex: 1; padding: 0 10px 0 0; opacity: 0; transform: translateX(-14px); transition: all .55s ease both; }
.tl-node { position: relative; width: 17px; height: 17px; margin: 31px 0 14px 2%; border-radius: 50%;
background: #fff; border: 3px solid #b9c6e2; box-shadow: 0 0 0 4px rgba(255, 255, 255, .7); }
.tl-item.done .tl-node { border-color: var(--cyan); background: var(--cyan); }
.tl-item.active .tl-node { border-color: var(--indigo); animation: nodePulse 1.8s ease-out infinite; }
@keyframes nodePulse { from { box-shadow: 0 0 0 0 rgba(77, 107, 242, .45); } to { box-shadow: 0 0 0 13px rgba(77, 107, 242, 0); } }
.tl-date { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 700;
color: var(--sub); background: rgba(120, 145, 190, .13); }
.tl-item.active .tl-date { color: #fff; background: linear-gradient(120deg, var(--indigo), #6a8bff); }
.tl-item b { display: block; margin-top: 8px; font-size: 13px; color: var(--ink); }
.tl-item p { margin-top: 4px; font-size: 11px; line-height: 1.55; color: var(--sub); }
/* 行动建议 */
.actions { flex: none; height: 124px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.actions .panel:nth-child(2) { animation-delay: .12s; }
.actions .panel:nth-child(3) { animation-delay: .24s; }
.act { display: grid; grid-template-columns: 52px 1fr; gap: 14px; align-items: center;
padding: 0 20px 0 16px; border-radius: 22px; }
.act.teal { border-top: 4px solid var(--cyan); } .act.amber { border-top: 4px solid var(--amber); } .act.indigo { border-top: 4px solid var(--indigo); }
.act-icon { position: relative; width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center;
font-size: 22px; font-weight: 800; animation: bob 3.2s ease-in-out infinite; }
.act.teal .act-icon { color: #0e9c8f; background: rgba(23, 179, 174, .14); }
.act.amber .act-icon { color: #cf8412; background: rgba(255, 182, 72, .16); }
.act.indigo .act-icon { color: var(--indigo); background: rgba(77, 107, 242, .12); }
@keyframes bob { 50% { transform: translateY(-6px); } }
.act h3 { font-size: 15px; color: var(--ink); }
.act p { margin-top: 5px; font-size: 12px; line-height: 1.6; color: var(--sub);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.act-meta { display: flex; gap: 8px; margin-top: 7px; }
.act-meta span { padding: 2px 9px; border-radius: 999px; font-size: 10px; color: var(--sub);
border: 1px solid rgba(120, 145, 190, .3); background: rgba(255, 255, 255, .5); }
/* 跑马灯 + 页脚 */
.ticker { flex: none; height: 38px; display: flex; align-items: center; gap: 12px; border-radius: 999px; overflow: hidden;
border: 1px solid var(--card-line); background: var(--card); backdrop-filter: blur(10px); box-shadow: 0 10px 24px rgba(99, 125, 190, .12); }
.tk-label { flex: none; display: flex; align-items: center; gap: 7px; padding: 0 16px; height: 100%; border-right: 1px solid rgba(120, 145, 190, .18);
font-size: 12px; font-weight: 700; letter-spacing: 2px; color: var(--indigo); background: rgba(77, 107, 242, .07); }
.tk-label i { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); animation: dotPulse 1.2s infinite; }
@keyframes dotPulse { 50% { opacity: .3; } }
.tk-view { flex: 1; overflow: hidden; }
.m-track { display: inline-flex; align-items: center; white-space: nowrap; will-change: transform; }
.m-group { display: inline-flex; align-items: center; gap: 40px; padding-right: 40px; }
.m-track span { font-size: 12px; color: var(--sub); }
.m-track span::before { content: "●"; margin-right: 12px; font-size: 7px; color: rgba(77, 107, 242, .6); }
.foot { flex: none; height: 26px; display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: #93a5c4; }
.foot-right { display: flex; align-items: center; gap: 12px; }
.refresh { display: inline-flex; align-items: center; gap: 7px; }
.spin-arc { width: 12px; height: 12px; border-radius: 50%; border: 2px solid rgba(77, 107, 242, .25); border-top-color: var(--indigo); animation: spin 1.4s linear infinite; }
.b-dots { display: inline-flex; gap: 4px; }
.b-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--indigo); animation: bdots 1.2s ease-in-out infinite; }
.b-dots i:nth-child(2) { animation-delay: .15s; } .b-dots i:nth-child(3) { animation-delay: .3s; }
@keyframes bdots { 30% { transform: translateY(-5px); opacity: .5; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
</style>
</head>
<body>
<div class="blob b1"></div><div class="blob b2"></div><div class="blob b3"></div>
<div class="dots"></div>
<div id="motes"></div>
<div class="stage" id="stage">
<!-- ===== 顶部:文案直接改这里 ===== -->
<header class="top">
<div class="brand">
<div class="orbit"><span class="orbit-core"></span></div>
<div><b>经营分析中心</b><span>EXECUTIVE INSIGHTS</span></div>
</div>
<div class="t-wrap">
<h1>年度经营分析汇报</h1>
<div class="t-line"></div>
<p>BUSINESS PERFORMANCE REVIEW · 2026</p>
</div>
<div class="top-right">
<span class="chip accent">累计至 09 月</span>
<span class="chip" id="dateChip">—</span>
</div>
</header>
<!-- ===== KPI 行 ===== -->
<section class="kpis">
<article class="panel kpi hero d1">
<div class="hero-ring"></div>
<div class="kpi-label" id="heroLabel">年度累计达成</div>
<div class="odo" id="odoHero"></div>
<div class="kpi-meta"><span class="delta-pill" id="heroDelta">↑ +6.2%</span><span id="heroNote">较去年同期</span></div>
<div class="waves">
<svg viewBox="0 0 2400 64" preserveAspectRatio="none">
<path class="w1" d="M0,34 C150,10 300,58 450,34 C600,10 750,58 900,34 C1050,10 1200,58 1350,34 C1500,10 1650,58 1800,34 C1950,10 2100,58 2250,34 C2350,18 2380,26 2400,30 L2400,64 L0,64 Z" />
<path class="w2" d="M0,42 C150,22 300,62 450,42 C600,22 750,62 900,42 C1050,22 1200,62 1350,42 C1500,22 1650,62 1800,42 C1950,22 2100,62 2250,42 C2350,30 2380,36 2400,38 L2400,64 L0,64 Z" />
</svg>
</div>
</article>
<article class="panel kpi d2" id="kpiA"></article>
<article class="panel kpi d3" id="kpiB"></article>
<article class="panel kpi d4" id="kpiC"></article>
</section>
<!-- ===== 主区 ===== -->
<main class="main">
<article class="panel span8 d2">
<div class="p-head"><h2>月度目标达成趋势</h2><span class="p-tag">单位:% · 悬停查看数值</span></div>
<div class="p-body chart-host" id="lineHost">
<div class="line-legend"><span><i style="background:linear-gradient(90deg,#4d6bf2,#17b3ae)"></i>实际达成</span><span><i style="background:#b9c6e2"></i>目标基线</span></div>
<svg id="lineSvg"></svg>
<div class="tip" id="lineTip"></div>
</div>
</article>
<article class="panel span4 d3">
<div class="p-head"><h2>业务结构占比</h2><span class="p-tag">累计规模</span></div>
<div class="p-body donut-body">
<div class="donut-wrap">
<svg viewBox="0 0 172 172">
<circle class="donut-track" cx="86" cy="86" r="60" />
<g id="donutParts"></g>
</svg>
<div class="donut-read"><b id="donutNum">0</b><span id="donutUnit">亿元</span></div>
</div>
<div id="donutLegend"></div>
</div>
</article>
<article class="panel span4 d4">
<div class="p-head"><h2>区域达成排行</h2><span class="p-tag">目标完成率</span></div>
<div class="p-body"><div class="rank-list" id="rankList"></div></div>
</article>
<article class="panel span4 d5">
<div class="p-head"><h2>月度处置节奏</h2><span class="p-tag">单位:件</span></div>
<div class="p-body bars-host">
<div class="bars-row" id="barsRow"></div>
<div class="bars-foot"><span>青色柱体为月度峰值</span><span id="barsAvg"></span></div>
</div>
</article>
<article class="panel span4 d6">
<div class="p-head"><h2>关键里程碑</h2><span class="p-tag">本期节点</span></div>
<div class="p-body">
<div class="tl" id="tl">
<div class="tl-line"><div class="tl-fill" id="tlFill"></div></div>
</div>
</div>
</article>
</main>
<!-- ===== 行动建议 ===== -->
<section class="actions" id="actionRow"></section>
<!-- ===== 跑马灯 + 页脚 ===== -->
<footer class="ticker">
<div class="tk-label"><i></i>要点速递</div>
<div class="tk-view" id="tkView"><div class="m-track" id="tkTrack"></div></div>
</footer>
<div class="foot">
<span>本页面为独立 HTML 汇报模板 · 数据与文案均可替换</span>
<div class="foot-right">
<span class="refresh"><span class="spin-arc"></span>数据每 5 分钟自动刷新</span>
<span class="b-dots"><i></i><i></i><i></i></span>
</div>
</div>
</div>
<script>
/* =====================================================
数据入口:弱模型只需要改这个对象,图表与动画自动重算
===================================================== */
const reportData = {
// 左侧主 KPI:value 字符串会逐位滚动翻牌(可带小数点)
hero: { label: '年度累计达成', value: '86.8', suffix: '%', delta: '↑ +6.2%', note: '较去年同期' },
// 其余三张 KPI:spark 为迷你走势(数值越大越高)
kpis: [
{ label: '累计业务规模', value: 8.42, prefix: '¥ ', suffix: ' 亿', decimals: 2, delta: '↑ +12.6%', up: true, color: '#4d6bf2', spark: [30, 38, 35, 48, 52, 60, 58, 72, 80] },
{ label: '重点项目完成率', value: 94.1, suffix: '%', decimals: 1, delta: '↑ +3.8%', up: true, color: '#17b3ae', spark: [40, 44, 52, 50, 62, 68, 75, 82, 90] },
{ label: '风险事项数量', value: 18, delta: '↓ -21.7%', up: false, color: '#ff7a66', spark: [80, 76, 70, 72, 62, 55, 48, 38, 30] },
],
// 折线:labels 与两个等长序列(0-100 百分比口径)
trend: {
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月'],
actual: [55, 62, 66, 70, 73, 78, 81, 85, 86.8],
target: [52, 58, 64, 69, 74, 79, 83, 87, 91],
},
// 环形图:[名称, 数值, 颜色];center 为中心读数
donut: {
center: '8.42', unit: '亿元',
parts: [['核心业务', 47.4, '#4d6bf2'], ['专项业务', 28.6, '#17b3ae'], ['协同业务', 16.1, '#8b7cf6'], ['其他业务', 7.9, '#ffb648']],
},
// 排行:[名称, 0-100, 展示数值]
ranking: [
['华东区域', 96, '2.18 亿'], ['华南区域', 92, '1.76 亿'], ['华北区域', 88, '1.58 亿'],
['西南区域', 84, '1.22 亿'], ['西北区域', 76, '0.81 亿'],
],
// 柱状:[月份, 数值]
months: [
['10月', 1090], ['11月', 1210], ['12月', 1140], ['1月', 1330], ['2月', 1260], ['3月', 1520],
['4月', 1640], ['5月', 1570], ['6月', 1760], ['7月', 1920], ['8月', 2150], ['9月', 2080],
],
// 时间线:[日期, 标题, 描述, 状态 done/active/todo]
milestones: [
['08.18', '区域复盘机制启动', '围绕高增长区域沉淀可复制经验', 'done'],
['08.27', '重点项目阶段验收', '协同项目成果达标,进入交付准备', 'done'],
['09.01', '经营数据期末更新', '财务与业务双重校验完成', 'active'],
['09.15', '四季度资源预置', '锁定关键岗位与外部协作资源', 'todo'],
['10.01', '年度目标冲刺', '启动收口专项与周度偏差跟踪', 'todo'],
],
// 行动建议:tone 为 teal/amber/indigo,icon 为单个字符
actions: [
{ tone: 'teal', icon: '↗', title: '增长机会 · 区域复制', desc: '将华东协同打法沉淀为标准流程,向华南、西南推广,预计新增 0.32 亿元规模。', meta: ['责任:区域经营组', '期限:09.15'] },
{ tone: 'amber', icon: '!', title: '临界风险 · 项目收口', desc: '两项专项接近资源临界区,本周内完成补充排期与责任人确认。', meta: ['责任:项目统筹组', '期限:本周'] },
{ tone: 'indigo', icon: '→', title: '下步行动 · 周期复盘', desc: '以周为单位刷新里程碑,偏差超过 5% 的事项启动专项复盘。', meta: ['责任:经营管理组', '期限:持续'] },
],
// 跑马灯文案
ticker: [
'年度累计达成率 86.8%,较去年同期提高 6.2 个百分点',
'华东区域协同机制经验开始向华南、西南复制推广',
'重点项目阶段完成率 94.1%,交付进度整体领先计划',
'风险事项数量较年初下降 21.7%,预警规则持续生效',
'四季度经营重点:增长复制、项目收口、风险闭环',
],
};
/* ===================== 工具 ===================== */
const $ = (sel) => document.querySelector(sel);
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const fmt = (n, d = 0) => n.toLocaleString('zh-CN', { minimumFractionDigits: d, maximumFractionDigits: d });
const raf2 = (fn) => requestAnimationFrame(() => requestAnimationFrame(fn));
function countUp(el, target, { decimals = 0, duration = 1600, delay = 0, prefix = '', suffix = '' } = {}) {
if (REDUCED) { el.textContent = prefix + fmt(target, decimals) + suffix; return; }
const t0 = performance.now() + delay;
(function frame(now) {
if (now < t0) return requestAnimationFrame(frame);
const p = Math.min(1, (now - t0) / duration);
const e = 1 - Math.pow(1 - p, 3);
el.textContent = prefix + fmt(target * e, decimals) + suffix;
if (p < 1) requestAnimationFrame(frame);
})(performance.now());
}
function startMarquee(view, speed) {
const track = view.querySelector('.m-track');
const group = document.createElement('span');
group.className = 'm-group';
while (track.firstChild) group.appendChild(track.firstChild);
track.appendChild(group);
track.appendChild(group.cloneNode(true));
if (REDUCED) return;
let off = 0, last = performance.now(), paused = false;
view.addEventListener('mouseenter', () => paused = true);
view.addEventListener('mouseleave', () => paused = false);
(function frame(now) {
const dt = (now - last) / 1000; last = now;
if (!paused) {
off += speed * dt;
const size = group.offsetWidth;
if (size > 0) { if (off >= size) off -= size; track.style.transform = `translateX(${-off}px)`; }
}
requestAnimationFrame(frame);
})(last);
}
/* ===================== 舞台缩放 / 日期 / 上升粒子 ===================== */
const stage = $('#stage');
function fit() {
const s = Math.min(innerWidth / 1920, innerHeight / 1080);
stage.style.transform = `translate(-50%, -50%) scale(${s})`;
}
addEventListener('resize', fit); fit();
const n0 = new Date();
$('#dateChip').textContent = n0.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
(function motes() {
const wrap = $('#motes');
for (let i = 0; i < 26; i++) {
const m = document.createElement('i');
const d = 9 + Math.random() * 9, sz = 4 + Math.random() * 9;
m.style.left = Math.random() * 100 + '%';
m.style.width = m.style.height = sz + 'px';
m.style.setProperty('--d', d + 's');
m.style.setProperty('--dl', -Math.random() * d + 's');
wrap.appendChild(m);
}
})();
/* ===================== KPI 行 ===================== */
// 主 KPI:滚轮翻牌数字
(function hero() {
const h = reportData.hero;
$('#heroLabel').textContent = h.label;
$('#heroDelta').textContent = h.delta;
$('#heroNote').textContent = h.note;
const host = $('#odoHero');
const chars = h.value.split('');
chars.forEach((ch, i) => {
if (/\d/.test(ch)) {
const d = document.createElement('span'); d.className = 'odo-d';
const strip = document.createElement('span'); strip.className = 'odo-strip';
strip.innerHTML = Array.from({ length: 10 }, (_, k) => `<i>${k}</i>`).join('');
d.appendChild(strip); host.appendChild(d);
raf2(() => { strip.style.transitionDelay = (0.25 + i * 0.1) + 's'; strip.style.transform = `translateY(${-Number(ch)}em)`; });
} else {
const s = document.createElement('span'); s.className = 'odo-ch'; s.textContent = ch; host.appendChild(s);
}
});
if (h.suffix) { const s = document.createElement('span'); s.style.cssText = 'font-size:22px;font-weight:700;margin-left:6px;align-self:flex-end;padding-bottom:4px;'; s.textContent = h.suffix; host.appendChild(s); }
})();
// 其余三张 KPI + 迷你走势
['kpiA', 'kpiB', 'kpiC'].forEach((id, i) => {
const k = reportData.kpis[i];
const card = $('#' + id);
card.innerHTML = `
<div class="kpi-label">${k.label}</div>
<div class="kpi-v"><span class="num"></span>${k.suffix ? `<small>${k.suffix}</small>` : ''}</div>
<div class="kpi-meta"><span class="delta-pill ${k.up ? 'up' : 'down'}">${k.delta}</span></div>
<svg class="spark" viewBox="0 0 92 32" preserveAspectRatio="none"></svg>`;
countUp(card.querySelector('.num'), k.value, { decimals: k.decimals || 0, prefix: k.prefix || '', delay: 300 + i * 180 });
// 迷你走势
const svg = card.querySelector('.spark');
const max = Math.max(...k.spark), min = Math.min(...k.spark);
const pts = k.spark.map((v, j) => [(4 + j * (84 / (k.spark.length - 1))).toFixed(1), (26 - (v - min) / (max - min || 1) * 20).toFixed(1)]);
const d = pts.map(([x, y], j) => `${j ? 'L' : 'M'}${x} ${y}`).join(' ');
svg.innerHTML = `<path d="${d}" stroke="${k.color}" style="stroke-dasharray:200;stroke-dashoffset:${REDUCED ? 0 : 200};transition:stroke-dashoffset 1.4s ease ${0.5 + i * 0.2}s"/>`;
raf2(() => svg.querySelector('path').style.strokeDashoffset = '0');
});
/* ===================== 折线图:平滑生长 + 悬停取数 ===================== */
(function lineChart() {
const svg = $('#lineSvg'), host = $('#lineHost'), tip = $('#lineTip');
const W = host.offsetWidth, H = host.offsetHeight;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const { labels, actual, target } = reportData.trend;
const padL = 48, padR = 20, padT = 20, padB = 32;
const max = 100;
const X = (i) => padL + i * (W - padL - padR) / (labels.length - 1);
const Y = (v) => H - padB - (v / max) * (H - padT - padB);
let inner = `
<defs>
<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4d6bf2" /><stop offset="1" stop-color="#17b3ae" />
</linearGradient>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4d6bf2" stop-opacity=".22" /><stop offset="1" stop-color="#4d6bf2" stop-opacity="0" />
</linearGradient>
</defs>`;
for (let g = 0; g <= 4; g++) {
const y = padT + g * (H - padT - padB) / 4;
inner += `<line class="gl" x1="${padL}" x2="${W - padR}" y1="${y}" y2="${y}"/>`;
inner += `<text class="gt" x="${padL - 10}" y="${y + 4}" text-anchor="end">${max * (4 - g) / 4}%</text>`;
}
labels.forEach((m, i) => inner += `<text class="gt" x="${X(i)}" y="${H - 10}" text-anchor="middle">${m}</text>`);
const smooth = (vals) => vals.map((v, i) => [X(i), Y(v)]);
function smoothPath(pts) {
if (pts.length < 3) return 'M' + pts.map((p) => p.join(' ')).join(' L');
let dPath = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
dPath += ` C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;
}
return dPath;
}
const aPts = smooth(actual), tPts = smooth(target);
const aPath = smoothPath(aPts), tPath = smoothPath(tPts);
const area = `${aPath} L${X(labels.length - 1)} ${H - padB} L${padL} ${H - padB} Z`;
inner += `<path class="line-area" d="${area}"/>`;
inner += `<path class="line-target" d="${tPath}"/>`;
inner += `<path class="line-actual" id="aPath" d="${aPath}"/>`;
aPts.forEach(([x, y]) => inner += `<circle class="ldot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="5"/>`);
inner += `<line class="guide" id="lgGuide" y1="${padT}" y2="${H - padB}" x1="0" x2="0"/>`;
svg.innerHTML = inner;
const pathEl = $('#aPath');
if (!REDUCED) {
const L = pathEl.getTotalLength();
pathEl.style.strokeDasharray = L; pathEl.style.strokeDashoffset = L;
pathEl.style.transition = 'stroke-dashoffset 1.8s cubic-bezier(.4,0,.2,1) .3s';
raf2(() => pathEl.style.strokeDashoffset = '0');
}
raf2(() => {
svg.querySelector('.line-area').style.opacity = 1;
svg.querySelector('.line-target').style.opacity = 1;
document.querySelectorAll('#lineSvg .ldot').forEach((d, i) => setTimeout(() => d.classList.add('on'), 750 + i * 90));
});
// 悬停取数(舞台缩放坐标换算,画布宽 W 即未缩放像素宽)
const guide = $('#lgGuide');
svg.addEventListener('mousemove', (e) => {
const rect = svg.getBoundingClientRect();
const s = rect.width / W;
const x = (e.clientX - rect.left) / s;
let idx = 0, best = Infinity;
aPts.forEach(([px], i) => { const dd = Math.abs(px - x); if (dd < best) { best = dd; idx = i; } });
guide.setAttribute('x1', aPts[idx][0]); guide.setAttribute('x2', aPts[idx][0]); guide.style.opacity = 1;
tip.innerHTML = `<b>${labels[idx]}</b>
<div><span><i class="dotk" style="background:#4d6bf2"></i>实际达成</span><i>${fmt(actual[idx], 1)}%</i></div>
<div><span><i class="dotk" style="background:#b9c6e2"></i>目标基线</span><i>${fmt(target[idx], 1)}%</i></div>`;
tip.style.left = Math.min(Math.max(aPts[idx][0] - 64, 4), W - 150) + 'px';
tip.style.top = Math.max(aPts[idx][1] - 100, 4) + 'px';
tip.classList.add('show');
});
svg.addEventListener('mouseleave', () => { tip.classList.remove('show'); guide.style.opacity = 0; });
})();
/* ===================== 环形图 ===================== */
(function donut() {
const C = 2 * Math.PI * 60;
const parts = reportData.donut.parts;
const g = $('#donutParts');
$('#donutNum').textContent = reportData.donut.center;
$('#donutUnit').textContent = reportData.donut.unit;
const total = parts.reduce((s, p) => s + p[1], 0);
let acc = 0;
parts.forEach(([name, v, color], i) => {
const len = C * v / total;
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('class', 'donut-part'); c.setAttribute('cx', 86); c.setAttribute('cy', 86); c.setAttribute('r', 60);
c.setAttribute('stroke', color); c.style.strokeDasharray = `0.01 ${C - 0.01}`;
c.style.strokeDashoffset = -acc - 1;
g.appendChild(c);
setTimeout(() => c.style.strokeDasharray = `${Math.max(0.01, len - 4)} ${C - Math.max(0.01, len - 4)}`, 420 + i * 170);
acc += len;
});
$('#donutLegend').innerHTML = parts.map(([name, v, color]) => `
<div class="dl-row"><i style="background:${color}"></i><span style="flex:none">${name}</span>
<span class="dl-bar"><b data-w="${v / total * 100}" style="background:${color}"></b></span><b class="v">${v}%</b></div>`).join('');
const rows = document.querySelectorAll('#donutLegend .dl-row');
rows.forEach((r, i) => setTimeout(() => { r.style.opacity = 1; r.style.transform = 'none'; }, 620 + i * 130));
raf2(() => rows.forEach((r, i) => setTimeout(() => { const b = r.querySelector('.dl-bar b'); b.style.width = b.dataset.w + '%'; }, 750 + i * 130)));
})();
/* ===================== 排行 ===================== */
$('#rankList').innerHTML = reportData.ranking.map(([name, score, val]) => `
<div class="rank-row">
<span class="medal"></span>
<div>
<div class="rank-name"><span>${name}</span><em>${val}</em></div>
<div class="rbar"><b data-w="${score}"></b></div>
</div>
</div>`).join('');
document.querySelectorAll('#rankList .medal').forEach((el, i) => el.textContent = i + 1);
raf2(() => document.querySelectorAll('#rankList .rbar b').forEach((b, i) => setTimeout(() => b.style.width = b.dataset.w + '%', 380 + i * 130)));
/* ===================== 柱状图(弹簧回弹 + 均值线) ===================== */
(function bars() {
const vals = reportData.months.map((m) => m[1]);
const peak = Math.max(...vals);
const avg = vals.reduce((s, v) => s + v, 0) / vals.length;
$('#barsRow').innerHTML = reportData.months.map(([m, v]) => `
<div class="bcol ${v === peak ? 'peak' : ''}" title="${m}:${fmt(v)} 件">
<span class="bv">${v === peak ? fmt(v) : ''}</span>
<div class="bar" data-h="${(v / peak * 100).toFixed(1)}"></div>
<span class="bl">${m}</span>
</div>`).join('');
const avgLine = document.createElement('div');
avgLine.className = 'avg-line';
avgLine.style.bottom = (26 + avg / peak * ( $('#barsRow').offsetHeight - 46 )) + 'px';
avgLine.innerHTML = `<span>均值 ${fmt(avg)}</span>`;
$('#barsRow').appendChild(avgLine);
$('#barsAvg').textContent = `月均 ${fmt(avg)} 件`;
raf2(() => {
document.querySelectorAll('#barsRow .bar').forEach((b, i) => setTimeout(() => b.style.height = b.dataset.h + '%', 380 + i * 70));
setTimeout(() => avgLine.style.opacity = 1, 1500);
});
})();
/* ===================== 时间线 ===================== */
(function timeline() {
const tl = $('#tl');
reportData.milestones.forEach(([date, title, desc, state]) => {
const el = document.createElement('div');
el.className = `tl-item ${state}`;
el.innerHTML = `<span class="tl-node"></span><span class="tl-date">${date}</span><b>${title}</b><p>${desc}</p>`;
tl.appendChild(el);
});
raf2(() => {
document.querySelectorAll('#tl .tl-item').forEach((el, i) => setTimeout(() => { el.style.opacity = 1; el.style.transform = 'none'; }, 450 + i * 150));
const n = reportData.milestones.length;
const ai = reportData.milestones.findIndex((m) => m[3] === 'active');
setTimeout(() => { $('#tlFill').style.width = ((ai < 0 ? n - 1 : ai) / (n - 1) * 100) + '%'; }, 700);
});
})();
/* ===================== 行动建议 ===================== */
$('#actionRow').innerHTML = reportData.actions.map((a) => `
<article class="panel act ${a.tone}">
<div class="act-icon">${a.icon}</div>
<div>
<h3>${a.title}</h3>
<p>${a.desc}</p>
<div class="act-meta">${a.meta.map((m) => `<span>${m}</span>`).join('')}</div>
</div>
</article>`).join('');
/* ===================== 跑马灯 ===================== */
$('#tkTrack').innerHTML = reportData.ticker.map((t) => `<span>${t}</span>`).join('');
startMarquee($('#tkView'), 85);
</script>
</body>
</html>
@@ -0,0 +1,517 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>多页经营汇报 · HTML 模板</title>
<style>
:root {
--page: #060e24;
--page-2: #08142f;
--panel: #122347;
--panel-2: #0c1836;
--line: #1a2f5a;
--line-soft: #2d4473;
--text: #e0e9f7;
--subtext: #9db1d1;
--muted: #6e88b0;
--primary: #1677ff;
--primary-soft: rgba(22, 119, 255, .16);
--cyan: #13c2c2;
--cyan-soft: rgba(19, 194, 194, .15);
--warning: #faad14;
--danger: #ff6b78;
--success: #52c41a;
--shadow: 0 14px 34px rgba(0, 8, 28, .32);
--card-radius: 8px;
--sans: "Noto Sans SC", "Microsoft YaHei", Arial, sans-serif;
--mono: "DIN Alternate", "Bahnschrift", Arial, sans-serif;
}
body[data-theme="light"] {
--page: #eef4fb;
--page-2: #f7faff;
--panel: #ffffff;
--panel-2: #f5f8fd;
--line: #d8e4f2;
--line-soft: #c8d8eb;
--text: #17355d;
--subtext: #597497;
--muted: #7890ad;
--primary: #2476e8;
--primary-soft: rgba(36, 118, 232, .10);
--cyan: #0da6a6;
--cyan-soft: rgba(13, 166, 166, .10);
--warning: #e89110;
--danger: #e45163;
--success: #3da314;
--shadow: 0 14px 34px rgba(58, 85, 121, .12);
}
* { box-sizing: border-box; }
html { min-width: 1180px; }
body {
margin: 0;
min-width: 1180px;
color: var(--text);
font-family: var(--sans);
font-size: 16px;
line-height: 1.6;
background:
radial-gradient(circle at 13% 6%, var(--primary-soft), transparent 30%),
radial-gradient(circle at 92% 18%, var(--cyan-soft), transparent 28%),
var(--page);
transition: background .35s ease, color .35s ease;
}
button { font: inherit; }
button:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; }
.suite {
width: min(100%, 1920px);
margin: 0 auto;
padding: 24px 28px 28px;
}
.topbar {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 30px;
min-height: 68px;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 12px; color: var(--subtext); font-size: 16px; font-weight: 650; letter-spacing: .08em; white-space: nowrap; }
.brand-mark {
display: grid; place-items: center; width: 28px; height: 28px; border: 1px solid var(--primary); border-radius: 50%;
box-shadow: 0 0 0 5px var(--primary-soft); color: var(--cyan);
}
.brand-mark svg { width: 15px; height: 15px; }
.page-nav { display: flex; align-self: stretch; justify-self: start; align-items: stretch; gap: 4px; background: transparent; }
.page-nav button {
display: flex; align-items: center; min-height: 54px; position: relative; border: 0; border-bottom: 3px solid transparent;
padding: 0 18px; cursor: pointer; color: var(--subtext); background: transparent; text-align: left;
transition: color .2s ease, background .2s ease;
}
.page-nav button::after { content: ""; position: absolute; right: 18px; bottom: -3px; left: 18px; height: 3px; transform: scaleX(0); transform-origin: center; background: var(--primary); transition: transform .22s ease; }
.page-nav button:hover { color: var(--text); background: var(--primary-soft); }
.page-nav button:hover::after { transform: scaleX(.45); }
.page-nav button[aria-selected="true"] { color: var(--primary); background: var(--primary-soft); font-weight: 700; }
.page-nav button[aria-selected="true"]::after { transform: scaleX(1); }
.nav-label { position: relative; z-index: 1; font-size: 17px; letter-spacing: .01em; white-space: nowrap; }
/* 深蓝主题的当前项:保持标准菜单形态,仅对“已选中”赋予轻量科技感。 */
body[data-theme="dark"] .page-nav button[aria-selected="true"] {
isolation: isolate;
overflow: hidden;
color: #e9f5ff;
text-shadow: 0 0 10px rgba(98, 204, 255, .34);
background:
linear-gradient(100deg, rgba(22, 119, 255, .34), rgba(13, 39, 92, .34)),
linear-gradient(rgba(99, 203, 255, .08) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 203, 255, .08) 1px, transparent 1px);
background-size: auto, 8px 8px, 8px 8px;
box-shadow: inset 0 0 0 1px rgba(88, 181, 255, .26), 0 0 16px rgba(22, 119, 255, .18);
}
body[data-theme="dark"] .page-nav button[aria-selected="true"]::before {
content: "";
position: absolute;
z-index: 0;
inset: 5px 5px 5px 5px;
pointer-events: none;
background:
linear-gradient(var(--cyan), var(--cyan)) left top / 12px 1px no-repeat,
linear-gradient(var(--cyan), var(--cyan)) left top / 1px 12px no-repeat,
linear-gradient(var(--primary), var(--primary)) right bottom / 12px 1px no-repeat,
linear-gradient(var(--primary), var(--primary)) right bottom / 1px 12px no-repeat;
opacity: .85;
}
body[data-theme="dark"] .page-nav button[aria-selected="true"]::after {
background: linear-gradient(90deg, transparent 0%, var(--primary) 30%, #8de7ff 50%, var(--cyan) 70%, transparent 100%);
background-size: 220% 100%;
box-shadow: 0 0 11px rgba(19, 194, 194, .76);
animation: nav-energy-scan 2.8s linear infinite;
}
@keyframes nav-energy-scan { from { background-position: 180% 0; } to { background-position: -40% 0; } }
.top-actions { display: flex; justify-content: flex-end; align-items: center; gap: 14px; }
.update { color: var(--muted); font-size: 16px; white-space: nowrap; }
.update i { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 4px rgba(82,196,26,.12); }
.theme-switch { display: flex; padding: 3px; gap: 2px; border: 1px solid var(--line); background: var(--panel-2); border-radius: 5px; }
.theme-switch button { min-height: 30px; border: 0; border-radius: 3px; padding: 0 10px; cursor: pointer; color: var(--subtext); background: transparent; font-size: 16px; transition: background .2s ease, color .2s ease; }
.theme-switch button:hover { color: var(--text); background: var(--primary-soft); }
.theme-switch button.is-active { color: #fff; background: var(--primary); box-shadow: 0 3px 10px rgba(22, 119, 255, .2); }
.report-page { display: none; gap: 16px; }
.report-page.is-active { display: grid; animation: page-in .42s both ease-out; }
@keyframes page-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.panel { border: 1px solid var(--line); border-radius: var(--card-radius); background: linear-gradient(145deg, color-mix(in srgb, var(--panel) 97%, var(--primary) 3%), var(--panel)); box-shadow: var(--shadow); }
.panel-pad { padding: 20px 22px; }
.eyebrow { margin: 0 0 8px; color: var(--primary); font-size: 16px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 10px; font-size: clamp(32px, 2.5vw, 48px); line-height: 1.24; letter-spacing: -.03em; }
h2 { margin-bottom: 8px; font-size: 24px; line-height: 1.35; }
h3 { margin-bottom: 8px; font-size: 18px; line-height: 1.45; }
.section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-title h2 { margin: 0; }
.section-title span { color: var(--muted); font-size: 16px; white-space: nowrap; }
.muted, .caption { color: var(--subtext); }
.caption { font-size: 16px; }
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.mono { font-family: var(--mono); }
.tag { display: inline-flex; align-items: center; min-height: 26px; padding: 2px 8px; border: 1px solid color-mix(in srgb, var(--primary) 45%, var(--line)); border-radius: 3px; color: var(--primary); background: var(--primary-soft); font-size: 16px; font-weight: 600; }
.tag.warning { color: var(--warning); border-color: color-mix(in srgb, var(--warning) 45%, var(--line)); background: color-mix(in srgb, var(--warning) 13%, transparent); }
.tag.cyan { color: var(--cyan); border-color: color-mix(in srgb, var(--cyan) 45%, var(--line)); background: var(--cyan-soft); }
/* Page 01: Executive overview */
.hero-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(360px, .85fr); gap: 16px; }
.hero-story { min-height: 242px; padding: 30px 34px; background: linear-gradient(115deg, var(--panel), color-mix(in srgb, var(--panel) 80%, var(--primary) 20%)); overflow: hidden; position: relative; }
.hero-story::after { content: ""; position: absolute; right: -80px; top: -170px; width: 370px; height: 370px; border: 1px solid color-mix(in srgb, var(--primary) 55%, transparent); border-radius: 50%; box-shadow: 0 0 0 42px rgba(22,119,255,.035), 0 0 0 84px rgba(22,119,255,.025); pointer-events: none; }
.hero-copy { max-width: 820px; position: relative; z-index: 1; }
.hero-copy .lead { max-width: 900px; margin: 0; color: var(--subtext); font-size: 18px; line-height: 1.8; }
.hero-copy .lead strong { color: var(--text); font-weight: 700; }
.hero-meta { display: flex; flex-wrap: wrap; gap: 12px 18px; margin-top: 18px; color: var(--muted); font-size: 16px; }
.hero-meta b { color: var(--cyan); }
.decision-card { padding: 24px; position: relative; overflow: hidden; }
.decision-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: linear-gradient(var(--primary), var(--cyan)); }
.decision-card .decision-summary { margin: 16px 0 20px; color: var(--subtext); font-size: 17px; line-height: 1.75; }
.decision-points { display: grid; gap: 10px; }
.decision-point { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
.decision-point:first-child { border-top: 0; padding-top: 0; }
.decision-point .point-no { color: var(--primary); font-family: var(--mono); font-size: 18px; font-weight: 700; }
.decision-point b { font-size: 16px; }
.decision-point span:last-child { color: var(--cyan); font-size: 16px; }
.metric-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.metric { padding: 16px 20px; min-height: 106px; position: relative; overflow: hidden; }
.metric::after { content: ""; position: absolute; width: 86px; height: 86px; right: -28px; bottom: -46px; border: 14px solid var(--primary-soft); border-radius: 50%; }
.metric-label { color: var(--subtext); font-size: 16px; }
.metric-value { margin: 3px 0 2px; font-family: var(--mono); font-size: 32px; font-weight: 700; line-height: 1.1; letter-spacing: -.02em; }
.metric-note { color: var(--muted); font-size: 16px; }
.metric-note b { color: var(--success); }
.overview-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 16px; }
.chart-card { min-height: 294px; padding: 20px 22px; }
.chart-wrap { position: relative; height: 190px; margin-top: 2px; }
.chart-wrap svg { display: block; width: 100%; height: 100%; overflow: visible; }
.axis-labels { display: flex; justify-content: space-between; margin-top: -2px; color: var(--muted); font-size: 16px; }
.chart-legend { display: flex; align-items: center; gap: 16px; color: var(--subtext); font-size: 16px; }
.chart-legend i { display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: var(--primary); }
.chart-legend i.dash { background: transparent; border: 2px solid var(--cyan); }
.chart-insight { display: grid; grid-template-columns: auto 1fr; gap: 10px; margin-top: 12px; padding: 10px 12px; border-left: 3px solid var(--cyan); background: var(--cyan-soft); color: var(--subtext); font-size: 16px; line-height: 1.55; }
.chart-insight strong { color: var(--cyan); }
.positioning-card { padding: 20px 22px; }
.positioning-card > p { color: var(--subtext); font-size: 17px; line-height: 1.75; }
.stance-list { display: grid; gap: 10px; margin-top: 12px; }
.stance { display: grid; grid-template-columns: 96px 1fr; gap: 12px; align-items: start; padding: 12px 0; border-top: 1px solid var(--line); }
.stance b { color: var(--text); font-size: 16px; }
.stance p { margin: 0; color: var(--subtext); font-size: 16px; line-height: 1.6; }
.article-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 16px; }
.narrative { padding: 22px 24px; }
.narrative .body-copy { columns: 2; column-gap: 28px; color: var(--subtext); font-size: 16px; line-height: 1.85; }
.narrative .body-copy p { break-inside: avoid; margin-bottom: 12px; }
.pull-quote { margin-top: 15px; padding: 12px 16px; border-left: 3px solid var(--primary); background: var(--primary-soft); color: var(--text); font-size: 18px; font-weight: 650; line-height: 1.6; }
.priority-list { padding: 22px 24px; }
.priority-item { display: grid; grid-template-columns: 34px 1fr; gap: 12px; padding: 11px 0; border-top: 1px solid var(--line); }
.priority-item:first-of-type { border-top: 0; padding-top: 0; }
.priority-no { color: var(--primary); font-family: var(--mono); font-size: 20px; font-weight: 700; }
.priority-item b { display: block; margin-bottom: 2px; font-size: 16px; }
.priority-item p { margin: 0; color: var(--subtext); font-size: 16px; line-height: 1.55; }
/* Page 02: Topic analysis */
.topic-banner { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; padding: 26px 30px; background: linear-gradient(105deg, var(--panel), color-mix(in srgb, var(--panel) 84%, var(--cyan) 16%)); }
.topic-banner p:last-child { max-width: 850px; margin: 0; color: var(--subtext); font-size: 18px; line-height: 1.8; }
.topic-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; align-self: center; }
.topic-fact { padding: 12px 14px; border-left: 2px solid var(--cyan); background: color-mix(in srgb, var(--panel-2) 78%, transparent); }
.topic-fact b { display: block; color: var(--cyan); font-family: var(--mono); font-size: 24px; line-height: 1.1; }
.topic-fact span { color: var(--subtext); font-size: 16px; }
.analysis-grid { display: grid; grid-template-columns: 1.08fr .92fr; gap: 16px; }
.briefing { padding: 24px 26px; }
.briefing h2 { max-width: 760px; font-size: 26px; }
.briefing .intro { margin-bottom: 14px; color: var(--subtext); font-size: 17px; line-height: 1.85; }
.briefing .paragraphs { columns: 2; column-gap: 28px; color: var(--subtext); font-size: 16px; line-height: 1.85; }
.briefing .paragraphs p { break-inside: avoid; margin-bottom: 12px; }
.quote-line { margin-top: 14px; padding: 13px 16px; border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--line)); background: color-mix(in srgb, var(--warning) 11%, transparent); color: var(--text); font-size: 16px; }
.quote-line b { color: var(--warning); }
.radar-card { padding: 20px 22px; min-height: 340px; }
.radar-layout { display: grid; grid-template-columns: 1.08fr .92fr; align-items: center; gap: 8px; }
.radar-layout svg { width: 100%; max-height: 240px; }
.radar-list { display: grid; gap: 10px; }
.radar-list div { padding-bottom: 9px; border-bottom: 1px solid var(--line); }
.radar-list b { display: block; font-size: 16px; }
.radar-list span { color: var(--subtext); font-size: 16px; }
.argument-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.argument { padding: 20px 22px; min-height: 192px; position: relative; }
.argument::before { content: ""; position: absolute; width: 6px; height: 6px; top: 22px; left: 0; background: var(--primary); box-shadow: 0 0 0 5px var(--primary-soft); }
.argument:nth-child(2)::before { background: var(--cyan); box-shadow: 0 0 0 5px var(--cyan-soft); }
.argument:nth-child(3)::before { background: var(--warning); box-shadow: 0 0 0 5px color-mix(in srgb, var(--warning) 15%, transparent); }
.argument h3 { padding-left: 10px; }
.argument p { margin-bottom: 12px; color: var(--subtext); font-size: 16px; line-height: 1.65; }
.argument span { color: var(--primary); font-size: 16px; font-weight: 700; }
.argument:nth-child(2) span { color: var(--cyan); }
.argument:nth-child(3) span { color: var(--warning); }
.matrix-card { padding: 20px 22px; }
.matrix { width: 100%; border-collapse: collapse; table-layout: fixed; overflow: hidden; }
.matrix th { padding: 10px 12px; color: var(--muted); background: var(--panel-2); font-size: 16px; font-weight: 600; text-align: left; }
.matrix td { padding: 12px; border-top: 1px solid var(--line); color: var(--subtext); font-size: 16px; line-height: 1.55; vertical-align: top; }
.matrix td:first-child { color: var(--text); font-weight: 650; }
.matrix td:last-child { color: var(--cyan); font-weight: 600; }
/* Page 03: Action command */
.action-top { display: grid; grid-template-columns: 1.12fr .88fr; gap: 16px; }
.action-intro { padding: 27px 30px; }
.action-intro p:last-child { max-width: 880px; margin: 0; color: var(--subtext); font-size: 18px; line-height: 1.8; }
.command-note { padding: 22px 24px; }
.command-note p { margin: 10px 0 14px; color: var(--subtext); font-size: 16px; line-height: 1.7; }
.command-note .owner { display: flex; justify-content: space-between; gap: 12px; padding-top: 10px; border-top: 1px solid var(--line); color: var(--muted); font-size: 16px; }
.command-note .owner b { color: var(--text); }
.command-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 16px; }
.gantt-card { padding: 20px 22px; }
.gantt-head, .gantt-row { display: grid; grid-template-columns: 150px repeat(6, minmax(0, 1fr)); gap: 0; }
.gantt-head { color: var(--muted); font-size: 16px; border-bottom: 1px solid var(--line); }
.gantt-head span { padding: 6px 8px 8px; text-align: center; }
.gantt-head span:first-child { text-align: left; }
.gantt-row { min-height: 44px; align-items: center; border-bottom: 1px solid var(--line); }
.gantt-row:last-child { border-bottom: 0; }
.gantt-label { padding-right: 10px; color: var(--subtext); font-size: 16px; }
.gantt-cell { height: 100%; min-height: 44px; border-left: 1px solid color-mix(in srgb, var(--line) 65%, transparent); position: relative; }
.gantt-bar { position: absolute; left: 6px; right: 6px; top: 13px; height: 18px; border-radius: 3px; background: linear-gradient(90deg, var(--primary), #5ca6ff); box-shadow: inset 0 1px rgba(255,255,255,.35); }
.gantt-bar.cyan { background: linear-gradient(90deg, #13a9c3, var(--cyan)); }
.gantt-bar.warn { background: linear-gradient(90deg, #e89110, var(--warning)); }
.gantt-bar::after { content: ""; position: absolute; inset: 0 auto 0 0; width: 36%; background: rgba(255,255,255,.38); animation: progress-shine 2.6s ease-in-out infinite; }
@keyframes progress-shine { 50% { transform: translateX(120%); } }
.risk-card { padding: 20px 22px; }
.risk { display: grid; grid-template-columns: 26px 1fr auto; align-items: start; gap: 10px; padding: 12px 0; border-top: 1px solid var(--line); }
.risk:first-of-type { border-top: 0; padding-top: 0; }
.risk-dot { width: 9px; height: 9px; margin-top: 8px; border-radius: 50%; background: var(--danger); box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 12%, transparent); }
.risk:nth-of-type(3) .risk-dot { background: var(--warning); box-shadow: 0 0 0 5px color-mix(in srgb, var(--warning) 12%, transparent); }
.risk:nth-of-type(4) .risk-dot { background: var(--cyan); box-shadow: 0 0 0 5px var(--cyan-soft); }
.risk b { display: block; font-size: 16px; }
.risk p { margin: 2px 0 0; color: var(--subtext); font-size: 16px; line-height: 1.5; }
.risk time { color: var(--muted); font-size: 16px; white-space: nowrap; }
.mandate-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 16px; }
.timeline-card, .instruction-card { padding: 22px 24px; }
.timeline { position: relative; display: grid; gap: 0; padding-left: 24px; }
.timeline::before { content: ""; position: absolute; left: 5px; top: 8px; bottom: 8px; width: 1px; background: var(--line-soft); }
.timeline-item { position: relative; padding: 0 0 14px; }
.timeline-item:last-child { padding-bottom: 0; }
.timeline-item::before { content: ""; position: absolute; left: -23px; top: 7px; width: 9px; height: 9px; border: 2px solid var(--primary); border-radius: 50%; background: var(--panel); }
.timeline-item:nth-child(2)::before { border-color: var(--cyan); }
.timeline-item:nth-child(3)::before { border-color: var(--warning); }
.timeline-item time { display: block; color: var(--primary); font-family: var(--mono); font-size: 16px; }
.timeline-item b { display: block; font-size: 16px; }
.timeline-item p { margin: 1px 0 0; color: var(--subtext); font-size: 16px; line-height: 1.55; }
.instruction-card .instruction-lead { padding: 12px 14px; border-left: 3px solid var(--cyan); background: var(--cyan-soft); color: var(--text); font-size: 17px; font-weight: 650; line-height: 1.65; }
.instruction-list { display: grid; gap: 10px; margin: 16px 0 0; }
.instruction-list div { display: grid; grid-template-columns: 28px 1fr; gap: 8px; color: var(--subtext); font-size: 16px; }
.instruction-list b { color: var(--cyan); font-family: var(--mono); }
.decision-table-card { padding: 20px 22px; }
.page-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-top: 2px; color: var(--muted); font-size: 16px; }
.page-footer strong { color: var(--subtext); font-weight: 600; }
@media (max-width: 1280px) { .suite { padding: 18px; } .hero-grid, .topic-banner, .action-top { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } }
</style>
</head>
<body data-theme="dark">
<main class="suite" aria-label="多页经营汇报模板">
<header class="topbar">
<div class="brand"><span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="7"/><path d="M12 5v14M5 12h14"/></svg></span> STRATEGIC REPORTING</div>
<nav class="page-nav" aria-label="汇报页切换" role="tablist">
<button id="tab-overview" type="button" role="tab" aria-controls="page-overview" aria-selected="true" data-page="overview"><span class="nav-label">经营总览</span></button>
<button id="tab-topic" type="button" role="tab" aria-controls="page-topic" aria-selected="false" data-page="topic"><span class="nav-label">专题研判</span></button>
<button id="tab-action" type="button" role="tab" aria-controls="page-action" aria-selected="false" data-page="action"><span class="nav-label">行动督办</span></button>
</nav>
<div class="top-actions"><span class="update"><i></i>数据更新于 <span id="report-date">2026-09-01 10:30</span></span><div class="theme-switch" aria-label="主题切换"><button type="button" class="is-active" data-theme="dark">深蓝</button><button type="button" data-theme="light">浅色</button></div></div>
</header>
<section id="page-overview" class="report-page is-active" role="tabpanel" aria-labelledby="tab-overview">
<div class="hero-grid">
<article class="panel hero-story">
<div class="hero-copy">
<p class="eyebrow">Executive Situation · 2026 Q3</p>
<h1>增长保持韧性,当前决策重点转向<span style="color:var(--cyan)">结构优化与风险回收</span></h1>
<p class="lead">本周期累计完成目标的 <strong class="num">86.8%</strong>,较计划节奏领先 2.6 个百分点。业务规模维持稳定增长,但区域资源利用率出现分化;建议将管理重心从“扩大投入”调整为“提升产出质量”。</p>
<div class="hero-meta"><span><b>03</b> 项需本周决策</span><span><b>06</b> 项重点事项推进中</span><span>报告口径:截至 09 月 01 日</span></div>
</div>
</article>
<aside class="panel decision-card" aria-label="本页决策摘要">
<span class="tag cyan">管理层先读</span>
<p class="decision-summary">趋势并未转弱,核心矛盾在于高价值项目的资源集中度不足。先处理跨区域协同与风险闭环,可在不新增预算的前提下改善本期达成质量。</p>
<div class="decision-points">
<div class="decision-point"><span class="point-no">01</span><b>确认重点项目优先级</b><span>本周</span></div>
<div class="decision-point"><span class="point-no">02</span><b>收紧两项低效投入</b><span>本周</span></div>
<div class="decision-point"><span class="point-no">03</span><b>建立区域共担机制</b><span>本月</span></div>
</div>
</aside>
</div>
<section class="metric-strip" aria-label="核心指标">
<article class="panel metric"><div class="metric-label">累计业务规模</div><div class="metric-value">¥ 8.42 亿</div><div class="metric-note"><b>↑ 12.6%</b> 年度目标 9.70 亿</div></article>
<article class="panel metric"><div class="metric-label">重点项目完成率</div><div class="metric-value">94.1%</div><div class="metric-note"><b>↑ 3.8%</b> 较上月改善</div></article>
<article class="panel metric"><div class="metric-label">交付及时率</div><div class="metric-value">96.4%</div><div class="metric-note"><b>↑ 1.2%</b> 保持健康区间</div></article>
<article class="panel metric"><div class="metric-label">待收敛风险事项</div><div class="metric-value">18</div><div class="metric-note"><span style="color:var(--danger);font-weight:700">↑ 21.7%</span> 较年初增加</div></article>
</section>
<section class="overview-grid">
<article class="panel chart-card">
<div class="section-title"><h2>目标达成趋势</h2><div class="chart-legend"><span><i></i>累计达成</span><span><i class="dash"></i>目标节奏</span></div></div>
<div class="chart-wrap" aria-label="1月至9月累计达成趋势图">
<svg viewBox="0 0 760 190" preserveAspectRatio="none" role="img" aria-label="累计达成由一月 58% 上升至九月 86.8%">
<g stroke="var(--line)" stroke-width="1"><path d="M0 20H760M0 64H760M0 108H760M0 152H760"/><path d="M0 178H760"/></g>
<path d="M0 118 L94 108 L188 97 L282 88 L376 78 L470 67 L564 57 L658 43 L760 33" fill="none" stroke="var(--cyan)" stroke-width="2.5" stroke-dasharray="7 6"/>
<path d="M0 132 L94 120 L188 112 L282 96 L376 82 L470 71 L564 58 L658 47 L760 35 L760 178 L0 178 Z" fill="url(#areaFill)" opacity=".72"/>
<path d="M0 132 L94 120 L188 112 L282 96 L376 82 L470 71 L564 58 L658 47 L760 35" fill="none" stroke="var(--primary)" stroke-width="3.2"/>
<g fill="var(--panel)" stroke="var(--primary)" stroke-width="2.5"><circle cx="0" cy="132" r="4"/><circle cx="94" cy="120" r="4"/><circle cx="188" cy="112" r="4"/><circle cx="282" cy="96" r="4"/><circle cx="376" cy="82" r="4"/><circle cx="470" cy="71" r="4"/><circle cx="564" cy="58" r="4"/><circle cx="658" cy="47" r="4"/><circle cx="760" cy="35" r="4"/></g>
<defs><linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--primary)" stop-opacity=".40"/><stop offset="1" stop-color="var(--primary)" stop-opacity="0"/></linearGradient></defs>
</svg>
</div>
<div class="axis-labels"><span>1月</span><span>2月</span><span>3月</span><span>4月</span><span>5月</span><span>6月</span><span>7月</span><span>8月</span><span>9月</span></div>
<div class="chart-insight"><strong>趋势判断</strong><span>7 月后实际曲线持续位于目标节奏上方;若四季度维持当前转化效率,年度目标存在提前达成空间。</span></div>
</article>
<article class="panel positioning-card">
<div class="section-title"><h2>经营判断</h2><span>本期重点</span></div>
<p>规模增长仍是正向信号,但数字本身不足以说明结构健康。当前需用“优先级、责任人、交付时点”把增长机会转译为可执行的经营动作。</p>
<div class="stance-list">
<div class="stance"><b>保持</b><p>继续放大华东与华北的高质量订单转化,维持交付节奏。</p></div>
<div class="stance"><b>收紧</b><p>对低产出项目设置阶段阈值,避免资源被长期占用。</p></div>
<div class="stance"><b>补强</b><p>将区域协同与风险闭环纳入周度共担,缩短决策链路。</p></div>
</div>
</article>
</section>
<section class="article-grid">
<article class="panel narrative">
<div class="section-title"><h2>阶段经营综述</h2><span>长文 / 可直接替换 Markdown</span></div>
<div class="body-copy">
<p>本阶段经营目标整体推进平稳,核心业务和协同项目均处于计划线以上。增长主要来自既有客户的深化合作以及重点项目的提前回款,说明市场需求并未明显减弱。</p>
<p>需要关注的是,区域间的项目成熟度差异正在放大:头部区域已进入规模与效率同步提升阶段,部分区域仍依赖单点项目拉动。后续应让成熟经验形成可迁移的方法包。</p>
</div>
<div class="pull-quote">“下一阶段的关键不是做更多事情,而是让每一项投入都有清晰的收益路径、责任主体与退出条件。”</div>
</article>
<aside class="panel priority-list">
<div class="section-title"><h2>本周优先事项</h2><span>TOP 4</span></div>
<div class="priority-item"><span class="priority-no">01</span><div><b>重点项目验收闭环</b><p>补齐华东区域验收材料,锁定回款与下一阶段增购节奏。</p></div></div>
<div class="priority-item"><span class="priority-no">02</span><div><b>区域资源共担机制</b><p>明确高峰期支援规则,避免同类问题重复排队处理。</p></div></div>
<div class="priority-item"><span class="priority-no">03</span><div><b>风险事项分级处置</b><p>对 3 项临界风险设定责任人和 48 小时反馈节点。</p></div></div>
<div class="priority-item"><span class="priority-no">04</span><div><b>低效投入复盘</b><p>在周会上确认保留、优化或退出的具体建议。</p></div></div>
</aside>
</section>
<footer class="page-footer"><span>页面 01 / 03 · 总览决策</span><strong>CONFIDENTIAL · INTERNAL REPORTING</strong></footer>
</section>
<section id="page-topic" class="report-page" role="tabpanel" aria-labelledby="tab-topic" hidden>
<article class="panel topic-banner">
<div><p class="eyebrow">Topic Brief · Regional Collaboration</p><h1>专题研判:把区域差异从“结果问题”转化为<span style="color:var(--cyan)">组织协同问题</span></h1><p>区域完成率的差异并不只由市场容量决定。复盘显示,项目协同速度、关键角色前置介入和风险反馈密度,对结果的解释力更强。</p></div>
<div class="topic-facts"><div class="topic-fact"><b>56%</b><span>新增规模来自两大区域</span></div><div class="topic-fact"><b>2.3x</b><span>高低区资源利用率差</span></div><div class="topic-fact"><b>48h</b><span>风险闭环建议时限</span></div></div>
</article>
<section class="analysis-grid">
<article class="panel briefing">
<p class="eyebrow">Analysis Narrative</p><h2>同样的投入结构,为什么会出现不同的交付与增长质量?</h2>
<p class="intro">本次研判以项目进度、客户反馈、资源调度和风险处置记录为基础。结论不是对单一区域做评价,而是识别可复制机制与必须补齐的组织能力。</p>
<div class="paragraphs"><p>领先区域的共同特征是业务、交付和客户侧负责人在立项前就完成了关键边界对齐,后续资源调度以周为单位复核。因此,问题会在影响结果之前被暴露并解决。</p><p>相对滞后的区域并非缺少项目机会,而是跨角色协同仍依赖临时沟通。建议将成熟区域的前置评审、异常升级和复盘节奏固化为标准动作,再由区域负责人进行本地化适配。</p></div>
<div class="quote-line"><b>研判结论:</b>短期优先补齐“协同效率”和“风险前置”两项能力,预计比单纯增加资源投入更能改善第四季度的结构性差距。</div>
</article>
<article class="panel radar-card">
<div class="section-title"><h2>能力画像对比</h2><span>领先区域 / 基准</span></div>
<div class="radar-layout">
<svg viewBox="0 0 260 240" role="img" aria-label="区域能力雷达图"><g fill="none" stroke="var(--line-soft)"><path d="M130 20 221 87 186 194 74 194 39 87Z"/><path d="M130 52 193 98 169 171 91 171 67 98Z"/><path d="M130 85 165 110 152 148 108 148 95 110Z"/><path d="M130 20V220M39 87 221 87M74 194 186 194"/></g><path d="M130 44 199 96 164 178 86 158 68 98Z" fill="var(--primary-soft)" stroke="var(--primary)" stroke-width="3"/><g fill="var(--primary)"><circle cx="130" cy="44" r="4"/><circle cx="199" cy="96" r="4"/><circle cx="164" cy="178" r="4"/><circle cx="86" cy="158" r="4"/><circle cx="68" cy="98" r="4"/></g><g fill="var(--subtext)" font-size="13"><text x="112" y="14">协同</text><text x="224" y="90">交付</text><text x="174" y="214">风险</text><text x="47" y="214">客户</text><text x="2" y="90">转化</text></g></svg>
<div class="radar-list"><div><b>协同效率 92</b><span>前置评审机制已形成稳定节奏</span></div><div><b>交付稳定性 88</b><span>跨团队切换损耗较低</span></div><div><b>风险前置 84</b><span>需持续提高异常反馈密度</span></div></div>
</div>
</article>
</section>
<section class="argument-grid" aria-label="专题观点">
<article class="panel argument"><h3>机会:成熟机制可被复制</h3><p>领先区域的项目预审、里程碑共识和复盘模板已较为成熟,可在不改变客户策略的前提下推广至同类团队。</p><span>建议:建立方法包与陪跑机制</span></article>
<article class="panel argument"><h3>关注:资源调度仍偏被动</h3><p>问题常在临近交付时才进入管理视野,导致支援资源无法提前锁定,影响了协同项目的整体完成质量。</p><span>建议:固定周度预警与升级窗口</span></article>
<article class="panel argument"><h3>边界:不宜用统一目标覆盖差异</h3><p>区域客户结构与项目周期存在差异,统一的结果指标应保留,但过程阈值需要结合成熟度做分层管理。</p><span>建议:按成熟度设置过程指标</span></article>
</section>
<section class="panel matrix-card">
<div class="section-title"><h2>判断—依据—行动矩阵</h2><span>结构化文字面板</span></div>
<table class="matrix"><thead><tr><th style="width:22%">核心判断</th><th style="width:42%">数据与事实依据</th><th style="width:36%">建议行动</th></tr></thead><tbody>
<tr><td>协同效率是主要变量</td><td>领先区域在立项前完成关键角色对齐,跨部门阻塞平均闭环时长低于基准区 37%。</td><td>复制前置评审模板,并在周会跟踪跨部门阻塞项。</td></tr>
<tr><td>资源并非绝对不足</td><td>部分区域资源利用率偏低,但关键时点仍出现重复排队和临时求援。</td><td>设立区域共担池,按优先级调度而非按部门归属调度。</td></tr>
<tr><td>风险需要前置管理</td><td>临界风险多在交付前一周暴露,留给纠偏的时间不足。</td><td>执行 48 小时反馈规则,对高影响事项设管理层升级通道。</td></tr>
</tbody></table>
</section>
<footer class="page-footer"><span>页面 02 / 03 · 专题研判</span><strong>CONFIDENTIAL · INTERNAL REPORTING</strong></footer>
</section>
<section id="page-action" class="report-page" role="tabpanel" aria-labelledby="tab-action" hidden>
<section class="action-top">
<article class="panel action-intro"><p class="eyebrow">Action Command · Sep 2026</p><h1>行动督办:把共识固化为<span style="color:var(--cyan)">时点、责任与可验收结果</span></h1><p>本页保留必要的计划图表,但阅读重点是行动说明。每项动作均应写清楚为何要做、由谁负责、何时反馈、什么状态下可以关闭,避免“有安排、无闭环”。</p></article>
<aside class="panel command-note"><span class="tag warning">本周管理提示</span><p>先锁定影响本期结果的三项动作,再处理一般性优化事项。对于跨区域协同问题,牵头人需拥有资源协调权限。</p><div class="owner"><span>总牵头人</span><b>经营管理组 · 李明</b></div></aside>
</section>
<section class="command-grid">
<article class="panel gantt-card"><div class="section-title"><h2>重点事项推进计划</h2><span>09.01 — 09.30</span></div>
<div class="gantt-head"><span>项目事项</span><span>第 1 周</span><span>第 2 周</span><span>第 3 周</span><span>第 4 周</span><span>复盘</span><span>状态</span></div>
<div class="gantt-row"><span class="gantt-label">重点项目验收闭环</span><span class="gantt-cell"><i class="gantt-bar"></i></span><span class="gantt-cell"><i class="gantt-bar"></i></span><span class="gantt-cell"></span><span class="gantt-cell"></span><span class="gantt-cell"></span><span class="gantt-cell"></span></div>
<div class="gantt-row"><span class="gantt-label">区域共担机制复盘</span><span class="gantt-cell"></span><span class="gantt-cell"><i class="gantt-bar cyan"></i></span><span class="gantt-cell"><i class="gantt-bar cyan"></i></span><span class="gantt-cell"></span><span class="gantt-cell"></span><span class="gantt-cell"></span></div>
<div class="gantt-row"><span class="gantt-label">低效投入分级处置</span><span class="gantt-cell"></span><span class="gantt-cell"></span><span class="gantt-cell"><i class="gantt-bar warn"></i></span><span class="gantt-cell"><i class="gantt-bar warn"></i></span><span class="gantt-cell"></span><span class="gantt-cell"></span></div>
<div class="gantt-row"><span class="gantt-label">风险闭环专项检查</span><span class="gantt-cell"><i class="gantt-bar"></i></span><span class="gantt-cell"><i class="gantt-bar"></i></span><span class="gantt-cell"><i class="gantt-bar"></i></span><span class="gantt-cell"></span><span class="gantt-cell"></span><span class="gantt-cell"></span></div>
</article>
<aside class="panel risk-card"><div class="section-title"><h2>关键风险清单</h2><span>需持续跟踪</span></div>
<div class="risk"><i class="risk-dot"></i><div><b>重点项目验收材料不完整</b><p>可能影响本月回款确认,需同步客户侧审核节点。</p></div><time>09.06</time></div>
<div class="risk"><i class="risk-dot"></i><div><b>跨区域支援仍依赖临时协调</b><p>高峰期资源响应可能出现滞后,影响交付稳定性。</p></div><time>09.09</time></div>
<div class="risk"><i class="risk-dot"></i><div><b>低效投入退出机制待确认</b><p>若无明确阈值,可能继续稀释核心事项资源。</p></div><time>09.13</time></div>
</aside>
</section>
<section class="mandate-grid">
<article class="panel timeline-card"><div class="section-title"><h2>关键进展时间线</h2><span>事件型文字展示</span></div><div class="timeline">
<div class="timeline-item"><time>09.01 · 09:30</time><b>经营复盘会确认三项优先动作</b><p>负责人、协同部门与首次反馈时点已写入周度台账。</p></div>
<div class="timeline-item"><time>09.05 · 17:00</time><b>完成重点项目验收材料预审</b><p>异常项不得带入正式验收流程,客户侧问题同步升级。</p></div>
<div class="timeline-item"><time>09.12 · 12:00</time><b>区域共担机制第一次复盘</b><p>复盘资源响应时长和支援结果,调整优先级规则。</p></div>
<div class="timeline-item"><time>09.20 · 16:00</time><b>提交阶段成果与风险闭环报告</b><p>只保留尚需决策的事项,已关闭问题沉淀为方法包。</p></div>
</div></article>
<aside class="panel instruction-card"><div class="section-title"><h2>督办口径</h2><span>写作范式</span></div><div class="instruction-lead">每一条督办记录都应能回答四个问题:什么问题、谁来推进、什么时候反馈、如何判定完成。</div><div class="instruction-list"><div><b>01</b><span>避免只写“持续跟进”,必须补充下一次可验证的交付物。</span></div><div><b>02</b><span>跨部门事项明确一名总牵头人,协同人仅承担具体子任务。</span></div><div><b>03</b><span>风险关闭前保留证据链接或复盘结论,确保可追溯。</span></div></div></aside>
</section>
<section class="panel decision-table-card"><div class="section-title"><h2>责任—交付—验收清单</h2><span>可替换为 JSON 表格</span></div><table class="matrix"><thead><tr><th style="width:22%">行动事项</th><th style="width:18%">责任人 / 协同方</th><th style="width:34%">本周应交付内容</th><th style="width:26%">验收标准</th></tr></thead><tbody><tr><td>重点项目验收闭环</td><td>王晨 / 区域负责人</td><td>完成客户侧材料核对、异常项清单和回款节点确认。</td><td>验收材料齐备,异常项均明确处置人和截止时间。</td></tr><tr><td>区域资源共担机制</td><td>陈琳 / 交付、运营</td><td>提交共担池规则与首轮资源调度记录。</td><td>资源申请有优先级依据,响应时间可量化复盘。</td></tr><tr><td>风险分级处置</td><td>赵磊 / 各项目经理</td><td>更新风险台账,完成临界事项的管理层反馈。</td><td>高风险事项 48 小时内有结论或升级路径。</td></tr></tbody></table></section>
<footer class="page-footer"><span>页面 03 / 03 · 行动督办</span><strong>CONFIDENTIAL · INTERNAL REPORTING</strong></footer>
</section>
</main>
<script>
// 供生成智能体替换:统一的数据入口可改为外部 JSON,再按字段回填卡片、段落、表格和图表。
const reportData = { reportDate: '2026-09-01 10:30', pages: ['overview', 'topic', 'action'] };
const body = document.body;
const pageButtons = [...document.querySelectorAll('[data-page]')];
const pages = [...document.querySelectorAll('.report-page')];
const themeButtons = [...document.querySelectorAll('[data-theme]')];
document.querySelector('#report-date').textContent = reportData.reportDate;
function syncQuery() {
if (!window.history?.replaceState) return;
const query = new URLSearchParams(window.location.search);
const activePage = pageButtons.find((button) => button.getAttribute('aria-selected') === 'true')?.dataset.page;
if (activePage) query.set('page', activePage);
query.set('theme', body.dataset.theme);
window.history.replaceState(null, '', `${window.location.pathname}?${query.toString()}${window.location.hash}`);
}
function switchPage(name, shouldSync = true) {
pageButtons.forEach((button) => button.setAttribute('aria-selected', String(button.dataset.page === name)));
pages.forEach((page) => {
const active = page.id === `page-${name}`;
page.classList.toggle('is-active', active);
page.hidden = !active;
});
if (shouldSync) syncQuery();
}
pageButtons.forEach((button) => button.addEventListener('click', () => switchPage(button.dataset.page)));
themeButtons.forEach((button) => button.addEventListener('click', () => {
body.dataset.theme = button.dataset.theme;
themeButtons.forEach((item) => item.classList.toggle('is-active', item === button));
syncQuery();
}));
window.addEventListener('keydown', (event) => {
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const current = pageButtons.findIndex((button) => button.getAttribute('aria-selected') === 'true');
const next = (current + (event.key === 'ArrowRight' ? 1 : -1) + pageButtons.length) % pageButtons.length;
pageButtons[next].focus();
switchPage(pageButtons[next].dataset.page);
});
// 支持直接打开某个页面与主题,例如:?page=topic&theme=light
const query = new URLSearchParams(window.location.search);
const initialTheme = query.get('theme');
const initialPage = query.get('page');
if (['dark', 'light'].includes(initialTheme)) {
body.dataset.theme = initialTheme;
themeButtons.forEach((button) => button.classList.toggle('is-active', button.dataset.theme === initialTheme));
}
if (reportData.pages.includes(initialPage)) switchPage(initialPage, false);
</script>
</body>
</html>
@@ -0,0 +1,324 @@
"""Catalog and deterministic selector for bundled report-page templates.
HTML skeletons are packaged with the backend so page generation never depends
on the frontend checkout or a browser upload. Deployments can optionally
override the built-in library with ``DEERFLOW_PAGE_TEMPLATE_DIR``.
"""
from __future__ import annotations
import os
import re
from dataclasses import asdict, dataclass
from hashlib import sha256
from pathlib import Path
from typing import Literal
Theme = Literal["dark", "light"]
@dataclass(frozen=True)
class PageTemplateProfile:
"""A concise, agent-facing description of one HTML report skeleton."""
id: str
filename: str
theme: Theme
title: str
style: str
page_count: int
features: tuple[str, ...]
data_entry: str
best_for: tuple[str, ...]
def to_dict(self) -> dict[str, object]:
"""Serialize tuples as JSON-friendly lists."""
value = asdict(self)
value["features"] = list(self.features)
value["best_for"] = list(self.best_for)
return value
# Keep this declarative. Later templates only require one profile here; their
# original HTML never needs to be copied into the backend or rewritten.
_TEMPLATES: tuple[PageTemplateProfile, ...] = (
PageTemplateProfile(
id="dark-charts-portal",
filename="report-screen-dark-charts-portal-template.html",
theme="dark",
title="深蓝图表运营门户",
style="深蓝科技感、密度较高、带动效的大屏门户",
page_count=5,
features=("kpi", "line", "bar", "pie", "radar", "scatter", "gantt", "table", "text-panels", "animation"),
data_entry="reportData(图表和滚动列表统一入口)",
best_for=("经营汇报", "数据较多", "多页面", "图表优先", "督办"),
),
PageTemplateProfile(
id="dark-neon-portal",
filename="report-screen-dark-neon-portal-template.html",
theme="dark",
title="深蓝霓虹专题门户",
style="深蓝霓虹、沉浸式科技感、内容分区明显",
page_count=5,
features=("kpi", "timeline", "table", "text-panels", "animation"),
data_entry="页面结构直接编辑(先复制再删减)",
best_for=("专题汇报", "文字较多", "多页面", "研判"),
),
PageTemplateProfile(
id="dark-neon",
filename="report-screen-dark-neon-template.html",
theme="dark",
title="深蓝霓虹单页大屏",
style="深蓝发光边框、重点指标突出",
page_count=1,
features=("kpi", "line", "bar", "text-panels", "animation"),
data_entry="页面结构直接编辑(先复制再删减)",
best_for=("单页汇报", "简洁数据", "值班态势"),
),
PageTemplateProfile(
id="dark-classic",
filename="report-screen-dark-template.html",
theme="dark",
title="深蓝综合经营大屏",
style="稳重深蓝、经营驾驶舱、信息密度适中",
page_count=3,
features=("kpi", "bar", "table", "timeline", "text-panels"),
data_entry="页面内置数据变量和结构化区块",
best_for=("经营汇报", "文字与数据均衡", "三页汇报"),
),
PageTemplateProfile(
id="light-charts-portal",
filename="report-screen-light-charts-portal-template.html",
theme="light",
title="浅色图表运营门户",
style="明亮专业、留白充足、带动效的多页面门户",
page_count=5,
features=("kpi", "line", "bar", "pie", "radar", "scatter", "gantt", "table", "text-panels", "animation"),
data_entry="reportData(图表和滚动列表统一入口)",
best_for=("经营汇报", "数据较多", "多页面", "图表优先", "督办"),
),
PageTemplateProfile(
id="light-airy-portal",
filename="report-screen-light-airy-portal-template.html",
theme="light",
title="浅色轻盈专题门户",
style="浅色留白、内容阅读友好、清爽分栏",
page_count=5,
features=("kpi", "timeline", "table", "text-panels", "animation"),
data_entry="页面结构直接编辑(先复制再删减)",
best_for=("专题汇报", "文字较多", "多页面", "研判"),
),
PageTemplateProfile(
id="light-airy",
filename="report-screen-light-airy-template.html",
theme="light",
title="浅色轻盈单页报告",
style="浅色卡片、阅读节奏舒展",
page_count=1,
features=("kpi", "bar", "table", "text-panels"),
data_entry="页面内置数据变量和结构化区块",
best_for=("单页汇报", "文字为主", "简洁数据"),
),
PageTemplateProfile(
id="multipage-suite",
filename="report-screen-multipage-suite-template.html",
theme="dark",
title="多页面汇报套件",
style="深蓝 / 浅色可切换、文字信息组织强、标准化汇报套件",
page_count=3,
features=("kpi", "bar", "radar", "gantt", "table", "timeline", "text-panels", "theme-switch"),
data_entry="reportData(日期、可见页面)+ 页面结构",
best_for=("多页面", "文字为主", "汇报研判", "行动督办"),
),
)
def _template_root_candidates() -> list[Path]:
explicit = os.getenv("DEERFLOW_PAGE_TEMPLATE_DIR", "").strip()
candidates: list[Path] = [Path(explicit).expanduser()] if explicit else []
candidates.append(Path(__file__).resolve().parent / "assets")
return candidates
def resolve_template_root() -> Path:
"""Locate the template directory or raise a useful deployment error."""
for candidate in _template_root_candidates():
if candidate.is_dir():
return candidate.resolve()
raise FileNotFoundError(
"Page template directory is unavailable. Set DEERFLOW_PAGE_TEMPLATE_DIR "
"to the directory containing the report-screen-*-template.html files."
)
def _auto_profile(source: Path) -> PageTemplateProfile:
"""Create a conservative profile for a newly dropped-in HTML template.
Auto-discovered files are intentionally tagged as ``待人工确认`` rather
than claiming capabilities we cannot prove. The designer can use them for
broad page requests immediately; a later catalog entry can add richer,
hand-verified selection metadata without changing the source HTML.
"""
html = source.read_text(encoding="utf-8", errors="ignore")[:1_000_000]
lowered = html.lower()
stem = source.stem
is_dark = "dark" in stem.lower() or 'data-theme="dark"' in lowered or "#061" in lowered
features: list[str] = ["text-panels"]
for feature, hints in {
"kpi": ("kpi", "metric", "stat-"),
"line": ("linechart", "line-chart", "line-svg"),
"bar": ("bar-chart", "bars", "bar >"),
"pie": ("pie", "donut"),
"radar": ("radar",),
"scatter": ("scatter",),
"gantt": ("gantt",),
"table": ("<table",),
"timeline": ("timeline",),
"animation": ("@keyframes", "requestanimationframe", "transition:"),
}.items():
if any(hint in lowered for hint in hints):
features.append(feature)
page_count = max(
1,
len(re.findall(r"\bdata-page\s*=", lowered)),
len(re.findall(r"\breport-page\b", lowered)),
)
identifier = "auto-" + sha256(source.name.encode("utf-8")).hexdigest()[:10]
return PageTemplateProfile(
id=identifier,
filename=source.name,
theme="dark" if is_dark else "light",
title=f"自动发现:{stem}",
style="自动发现的 HTML 页面骨架(能力待人工确认)",
page_count=page_count,
features=tuple(dict.fromkeys(features)),
data_entry="reportData" if "const reportdata" in lowered else "页面结构直接编辑(先复制再删减)",
best_for=("自动发现", "待人工确认"),
)
def list_template_profiles() -> tuple[PageTemplateProfile, ...]:
"""Return registered templates plus safely auto-discovered future files."""
profiles = list(_TEMPLATES)
known_filenames = {profile.filename for profile in profiles}
try:
root = resolve_template_root()
for source in sorted(root.glob("report-screen-*-template.html"), key=lambda item: item.name.lower()):
if source.name not in known_filenames:
profiles.append(_auto_profile(source))
except (FileNotFoundError, OSError):
# Catalog information remains useful in deployments where the frontend
# package is not mounted yet; prepare() will surface the real error.
pass
return tuple(profiles)
def get_template_profile(template_id: str) -> PageTemplateProfile:
normalized = (template_id or "").strip().lower()
for profile in list_template_profiles():
if profile.id == normalized:
return profile
available = ", ".join(profile.id for profile in list_template_profiles())
raise ValueError(f"Unknown page template '{template_id}'. Available: {available}")
def resolve_template_file(template_id: str) -> Path:
"""Return a verified source-template path for a catalog id."""
profile = get_template_profile(template_id)
source = (resolve_template_root() / profile.filename).resolve()
root = resolve_template_root()
try:
source.relative_to(root)
except ValueError as exc: # defensive: profile filenames are repository data
raise ValueError("Template path escapes the template root") from exc
if not source.is_file():
raise FileNotFoundError(
f"Template '{template_id}' is listed in the catalog but is missing: {source}"
)
return source
def _normalize_theme(theme: str | None) -> Theme | None:
value = (theme or "").strip().lower()
if value in {"dark", "deep-blue", "dark-blue"}:
return "dark"
if value in {"light", "white", "bright"}:
return "light"
return None
def _score_template(
profile: PageTemplateProfile,
*,
content_density: str,
required_features: set[str],
preferred_page_count: int | None,
) -> int:
score = 0
features = set(profile.features)
# A required feature weighs substantially more than aesthetics.
score += 12 * len(features & required_features)
score -= 30 * len(required_features - features)
density = (content_density or "balanced").strip().lower()
if density in {"text-heavy", "rich-text", "文字较多"} and "text-panels" in features:
score += 8
if density in {"data-heavy", "chart-heavy", "图表较多"} and {"line", "bar", "pie"}.issubset(features):
score += 8
if preferred_page_count:
score -= min(abs(profile.page_count - preferred_page_count), 3) * 3
return score
def choose_template(
*,
theme: str | None,
content_density: str = "balanced",
required_features: list[str] | None = None,
preferred_page_count: int | None = None,
selection_key: str = "",
exclude_template_ids: list[str] | None = None,
) -> PageTemplateProfile:
"""Choose a suitable template, varying ties deterministically by request.
The caller can pass recent choices through ``exclude_template_ids``. We
only fall back to an excluded item when it is the sole match for the chosen
theme, which prevents an agent from losing the requested colour mode.
"""
target_theme = _normalize_theme(theme)
all_profiles = list_template_profiles()
candidates = [profile for profile in all_profiles if target_theme is None or profile.theme == target_theme]
if not candidates:
candidates = list(all_profiles)
excluded = {value.strip().lower() for value in (exclude_template_ids or []) if value.strip()}
non_excluded = [profile for profile in candidates if profile.id not in excluded]
if non_excluded:
candidates = non_excluded
feature_set = {value.strip().lower() for value in (required_features or []) if value.strip()}
scored = sorted(
((
_score_template(
profile,
content_density=content_density,
required_features=feature_set,
preferred_page_count=preferred_page_count,
),
profile,
) for profile in candidates),
key=lambda item: (-item[0], item[1].id),
)
best_score = scored[0][0]
ties = [profile for score, profile in scored if score == best_score]
if len(ties) == 1:
return ties[0]
digest = sha256((selection_key or "page-report").encode("utf-8")).digest()
return ties[int.from_bytes(digest[:4], "big") % len(ties)]
__all__ = [
"PageTemplateProfile",
"choose_template",
"get_template_profile",
"list_template_profiles",
"resolve_template_file",
"resolve_template_root",
]
@@ -0,0 +1,322 @@
"""Thread-safe page-report template preparation and static quality checks."""
from __future__ import annotations
import json
import re
from collections.abc import Iterable
from pathlib import Path
from langchain.tools import ToolRuntime, tool
from langgraph.typing import ContextT
from deerflow.agents.thread_state import ThreadState
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
from deerflow.page_reports.templates import (
choose_template,
get_template_profile,
list_template_profiles,
resolve_template_file,
)
from deerflow.runtime.thread_paths import resolve_path_user_id
_MAX_HTML_BYTES = 4 * 1024 * 1024
# Allow human-readable Chinese filenames while still rejecting path separators,
# Windows-reserved filename characters, and control characters.
_SAFE_OUTPUT_NAME = re.compile(r'^[^<>:"/\\|?*\x00-\x1f]+\.html$', re.IGNORECASE)
_FONT_SIZE_RE = re.compile(r"font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)px", re.IGNORECASE)
_MIN_TEXT_FONT_SIZE = 16.0
def _thread_id(runtime: ToolRuntime[ContextT, ThreadState]) -> str:
context = runtime.context or {}
thread_id = context.get("thread_id")
if not thread_id:
thread_id = (getattr(runtime, "config", {}) or {}).get("configurable", {}).get("thread_id")
if not thread_id:
raise ValueError("当前会话缺少 thread_id,无法准备页面模板。")
return str(thread_id)
def _virtual_path(path: Path, *, thread_id: str, user_id: str | None) -> str:
root = get_paths().sandbox_user_data_dir(thread_id, user_id=user_id).resolve()
relative = path.resolve().relative_to(root)
return f"{VIRTUAL_PATH_PREFIX}/{relative.as_posix()}"
def _thread_directories(runtime: ToolRuntime[ContextT, ThreadState]) -> tuple[str, str | None, Path, Path]:
thread_id = _thread_id(runtime)
user_id = resolve_path_user_id(thread_id)
paths = get_paths()
workspace = paths.sandbox_work_dir(thread_id, user_id=user_id)
outputs = paths.sandbox_outputs_dir(thread_id, user_id=user_id)
workspace.mkdir(parents=True, exist_ok=True)
outputs.mkdir(parents=True, exist_ok=True)
return thread_id, user_id, workspace, outputs
def _safe_output_filename(output_name: str) -> str:
raw = (output_name or "report-page.html").strip()
if not raw.lower().endswith(".html"):
raw += ".html"
if Path(raw).name != raw or not _SAFE_OUTPUT_NAME.fullmatch(raw):
raise ValueError("output_name 必须是单个安全的 .html 文件名,不能包含路径或 Windows 非法字符。")
return raw
def _apply_minimum_font_size(html: str) -> str:
"""Normalize editable template copies to the report designer's font floor.
The library includes some legacy dashboard skeletons with compact labels.
They are useful layout starting points, but the designer contract requires
that visible text is at least 16px. Normalize the *per-thread copy* here
so a weaker model does not have to remember a large CSS-only cleanup before
it can work on the actual report content. The immutable source template is
never changed, and ``validate`` remains the final guard if later edits add
a smaller declaration.
"""
def replace(match: re.Match[str]) -> str:
size = float(match.group(1))
if size >= _MIN_TEXT_FONT_SIZE:
return match.group(0)
return f"font-size: {_MIN_TEXT_FONT_SIZE:g}px"
return _FONT_SIZE_RE.sub(replace, html)
def _edit_contract(template_id: str) -> dict[str, object]:
profile = get_template_profile(template_id)
chart_driven = "reportData" in profile.data_entry
return {
"data_entry": profile.data_entry,
"edit_order": [
"先把用户材料整理为文字摘要、指标、表格行、图表数据和待办事项。",
"只编辑工作区的副本;禁止改动模板源文件。",
"若存在 reportData,先改该对象中的图表数组,再替换标题、段落、卡片和表格文案。",
"根据材料删掉无数据的卡片或整页;不要为了凑页数保留空白面板。",
"将最终 HTML 写入 outputs 后调用 validate;通过后用 present_files 交付。",
],
"must_keep": (
["reportData 的字段名和图表渲染函数", "页面切换及 prefers-reduced-motion 逻辑"]
if chart_driven
else ["已有布局类名", "移动端 / reduced-motion 样式"]
),
"illustrative_data_rule": "没有可核验数值但用户明确要求图表时,可画示意图;页面必须在图例或数据口径处标注“示意数据 / 待补充数据”,不得伪装为事实。",
}
def _prepare(
runtime: ToolRuntime[ContextT, ThreadState],
*,
theme: str,
content_density: str,
required_features: list[str],
preferred_page_count: int | None,
selection_key: str,
exclude_template_ids: list[str],
template_id: str | None,
output_name: str,
page_brief: str,
) -> str:
profile = (
get_template_profile(template_id)
if (template_id or "").strip()
else choose_template(
theme=theme,
content_density=content_density,
required_features=required_features,
preferred_page_count=preferred_page_count,
selection_key=selection_key,
exclude_template_ids=exclude_template_ids,
)
)
source = resolve_template_file(profile.id)
thread_id, user_id, workspace, outputs = _thread_directories(runtime)
filename = _safe_output_filename(output_name)
working_copy = workspace / f"{Path(filename).stem}--{profile.id}.html"
brief_path = workspace / f"{Path(filename).stem}--page-brief.json"
# Copy first, then normalize only the editable thread-local copy. This
# preserves the bundled assets exactly as supplied while giving every run a
# usable 16px-or-larger starting point.
working_copy.write_text(_apply_minimum_font_size(source.read_text(encoding="utf-8")), encoding="utf-8")
brief = {
"template": profile.to_dict(),
"requested_theme": theme or "auto",
"requested_content_density": content_density or "balanced",
"required_features": required_features,
"recommended_final_output": _virtual_path(outputs / filename, thread_id=thread_id, user_id=user_id),
"working_copy": _virtual_path(working_copy, thread_id=thread_id, user_id=user_id),
"user_material_brief": (page_brief or "").strip(),
"edit_contract": _edit_contract(profile.id),
"font_rule": "所有可见文字最小字号为 16px;生成后必须通过 validate 校验。",
}
brief_path.write_text(json.dumps(brief, ensure_ascii=False, indent=2), encoding="utf-8")
return json.dumps(
{
"ok": True,
"template": profile.to_dict(),
"working_copy": _virtual_path(working_copy, thread_id=thread_id, user_id=user_id),
"brief_path": _virtual_path(brief_path, thread_id=thread_id, user_id=user_id),
"final_output": brief["recommended_final_output"],
"next_step": "读取 brief_path 和 working_copy,改完后写入 final_output,再调用 action=validate。",
},
ensure_ascii=False,
)
def _resolve_current_thread_file(
runtime: ToolRuntime[ContextT, ThreadState],
virtual_path: str,
) -> tuple[Path, str, str | None]:
thread_id, user_id, workspace, outputs = _thread_directories(runtime)
normalized = (virtual_path or "").strip()
if not normalized.startswith(f"{VIRTUAL_PATH_PREFIX}/"):
raise ValueError(f"file_path 必须位于 {VIRTUAL_PATH_PREFIX}/workspace 或 {VIRTUAL_PATH_PREFIX}/outputs。")
actual = get_paths().resolve_virtual_path(thread_id, normalized, user_id=user_id)
permitted_roots: Iterable[Path] = (workspace.resolve(), outputs.resolve())
if not any(actual.is_relative_to(root) for root in permitted_roots):
raise ValueError("file_path 只能指向当前会话的 workspace 或 outputs 目录。")
if not actual.is_file():
raise ValueError(f"待校验 HTML 不存在:{virtual_path}")
return actual, thread_id, user_id
def _count_textual_content(html: str) -> int:
plain = re.sub(r"<script[\s\S]*?</script>|<style[\s\S]*?</style>|<[^>]+>", " ", html, flags=re.IGNORECASE)
return len(re.sub(r"\s+", "", plain))
def _quality_report(html: str, *, data_mode: str, expected_features: set[str]) -> dict[str, object]:
lowered = html.lower()
errors: list[str] = []
warnings: list[str] = []
if "<!doctype html" not in lowered or "</html>" not in lowered:
errors.append("HTML 不完整:需要 <!doctype html> 和 </html>。")
if "<body" not in lowered or "</body>" not in lowered:
errors.append("HTML 缺少完整 body 结构。")
if "<title" not in lowered:
errors.append("页面缺少 title。")
if _count_textual_content(html) < 500:
warnings.append("有效文字内容偏少;本类页面应以结论、要点、说明和行动项为主体。")
chart_markers = {"line": "svg", "bar": "bar", "pie": "pie", "radar": "radar", "scatter": "scatter", "gantt": "gantt"}
for feature in expected_features:
marker = chart_markers.get(feature)
if marker and marker not in lowered:
warnings.append(f"未检测到请求的 {feature} 图表结构,请确认是否应删去该要求或补充图表。")
font_sizes = [float(value) for value in _FONT_SIZE_RE.findall(html)]
too_small = sorted({value for value in font_sizes if value < 16})
if too_small:
errors.append("发现小于 16px 的字号:" + "、".join(f"{value:g}px" for value in too_small[:8]) + "。")
if (data_mode or "provided").strip().lower() in {"illustrative", "mixed"} and not any(
mark in html for mark in ("示意数据", "待补充数据", "模拟数据")
):
errors.append("示意图表没有标注“示意数据 / 待补充数据”,可能被误认为事实。")
if "reportdata" in lowered and "const reportdata" not in lowered:
warnings.append("检测到 reportData 引用但未检测到 const reportData 定义,请检查图表脚本。")
return {
"ok": not errors,
"errors": errors,
"warnings": warnings,
"metrics": {
"bytes": len(html.encode("utf-8")),
"text_characters": _count_textual_content(html),
"page_sections": len(re.findall(r"class=[\"'][^\"']*(?:report-page|page)[^\"']*[\"']", html, flags=re.IGNORECASE)),
"has_script": "<script" in lowered,
},
}
def _validate(
runtime: ToolRuntime[ContextT, ThreadState],
*,
file_path: str,
data_mode: str,
expected_features: list[str],
) -> str:
actual, thread_id, user_id = _resolve_current_thread_file(runtime, file_path)
if actual.stat().st_size > _MAX_HTML_BYTES:
raise ValueError(f"HTML 超过 {_MAX_HTML_BYTES // 1024 // 1024}MB,拒绝读取。")
html = actual.read_text(encoding="utf-8")
report = _quality_report(
html,
data_mode=data_mode,
expected_features={item.strip().lower() for item in expected_features if item.strip()},
)
report_path = actual.with_name(f"{actual.stem}.quality.json")
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
report["quality_report"] = _virtual_path(report_path, thread_id=thread_id, user_id=user_id)
report["file_path"] = _virtual_path(actual, thread_id=thread_id, user_id=user_id)
return json.dumps(report, ensure_ascii=False)
@tool("page_report_template", parse_docstring=True)
def page_report_template_tool(
runtime: ToolRuntime[ContextT, ThreadState],
action: str,
theme: str = "auto",
content_density: str = "balanced",
required_features: list[str] | None = None,
preferred_page_count: int | None = None,
selection_key: str = "",
exclude_template_ids: list[str] | None = None,
template_id: str | None = None,
output_name: str = "report-page.html",
page_brief: str = "",
file_path: str = "",
data_mode: str = "provided",
) -> str:
"""Select, copy, and quality-check reusable HTML report-page templates.
Use this tool instead of editing a source template in place.
Args:
action: One of ``catalog``, ``prepare``, or ``validate``. ``catalog`` lists
profiles; ``prepare`` selects/copies a source into this thread's
workspace and writes an editing brief; ``validate`` checks an edited
HTML file and writes a JSON quality report next to it.
theme: ``dark`` or ``light``. Use the UI-provided presentation theme when
available; ``auto`` lets the selector use all profiles.
content_density: ``text-heavy``, ``balanced``, or ``data-heavy``.
required_features: Needed components such as ``line``, ``pie``,
``radar``, ``scatter``, ``gantt``, ``table``, ``text-panels``.
preferred_page_count: Preferred number of report pages. The agent may
remove unused skeleton pages after preparation.
selection_key: Stable request/thread-derived text used to vary equally
good template choices.
exclude_template_ids: Recently-used template ids to avoid where possible.
template_id: Optional explicit catalog id. Omit to select automatically.
output_name: Final safe HTML filename, e.g. ``经营月报.html``.
page_brief: Concise summary of supplied materials and desired output.
file_path: For ``validate`` only: current-thread HTML under workspace or
outputs.
data_mode: ``provided`` for factual data, ``illustrative`` when charts
are intentionally demonstrative, or ``mixed``.
"""
normalized_action = (action or "").strip().lower()
if normalized_action == "catalog":
return json.dumps({"templates": [profile.to_dict() for profile in list_template_profiles()]}, ensure_ascii=False)
if normalized_action == "prepare":
return _prepare(
runtime,
theme=theme,
content_density=content_density,
required_features=required_features or [],
preferred_page_count=preferred_page_count,
selection_key=selection_key,
exclude_template_ids=exclude_template_ids or [],
template_id=template_id,
output_name=output_name,
page_brief=page_brief,
)
if normalized_action == "validate":
return _validate(
runtime,
file_path=file_path,
data_mode=data_mode,
expected_features=required_features or [],
)
raise ValueError("action 仅支持 catalog、prepare、validate。")
__all__ = ["page_report_template_tool"]