feat(backend): 新增页面报告设计器 Seed 与模板工具
功能说明:\n- 新增页面报告设计器的路由级 seed 接口与初始化模板资源加载能力\n- 新增内置页面报告模板工具,实现报告生成流程中的模板路径与参数接入\n- 补充页面报告模板与相关资产目录,供运行时/离线环境直接读取\n- 将页面报告配置与工具链纳入后端启动路径,支持离线部署下可复用的默认模板
This commit is contained in:
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",
|
||||
]
|
||||
+37
@@ -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。
|
||||
+18
@@ -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",
|
||||
]
|
||||
+1475
File diff suppressed because it is too large.
Load diff
+1053
File diff suppressed because it is too large.
Load diff
+824
@@ -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>
|
||||
+1337
File diff suppressed because it is too large.
Load diff
+1029
File diff suppressed because it is too large.
Load diff
+750
@@ -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>
|
||||
+1468
File diff suppressed because it is too large.
Load diff
+517
@@ -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",
|
||||
]
|
||||
+322
@@ -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"]
|
||||
Reference in new issue
Block a user