Files
2026-07-13 21:35:48 +08:00

636 lines
30 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<!--
OD · live-dashboard · 模板种子
----------------------------------------------------------------------
这是代理(agent)的起始骨架。结构是锁定的。
代理需要执行以下操作:
1. 将每个 {{placeholder}} 替换为用户简报或 DESIGN.md 中的内容。
2. 在底部的四个 SEED_* 数组(people、rows、feed、series)中填入合理的模拟数据——当连接器离线或用户选择 `inputs.connector === mock` 时显示这些数据。
3. 使用当前活跃的 DESIGN.md 令牌(tokens)重新设置 CSS 自定义属性块(`:root`)的样式。不要引入新的属性名。
4. 通过将 `seedNextChange()` 的内容替换为对 `/api/od/connectors/poll` 的 `poll()` 调用,将刷新按钮连接到真实的连接器。请参阅 references/connectors.md。
5. 在所有地方遵循 `prefers-reduced-motion: reduce`。
严格禁止:请参阅 references/checklist.mdP0 行是不可协商的)。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="od:project" content="{{project_id}}" />
<title>{{workspace_name}} · {{page_title}}</title>
<style>
:root {
--bg: #ffffff;
--bg-soft: #f7f6f3;
--bg-hover: #efeeec;
--ink: #37352f;
--ink-2: #787774;
--ink-3: #9b9a97;
--line: #ececea;
--line-strong: #d3d1cb;
--accent: oklch(72% 0.10 var(--accent-hue, 198));
--accent-ink: oklch(45% 0.12 var(--accent-hue, 198));
--pill-done-bg: #dbeddb; --pill-done-ink: #2b593f;
--pill-progress-bg: #fdecc8; --pill-progress-ink: #976d23;
--pill-blocked-bg: #ffe2dd; --pill-blocked-ink: #b13b2c;
--pill-todo-bg: #e9e5e3; --pill-todo-ink: #5a534f;
--pill-review-bg: #d3e5ef; --pill-review-ink: #1f5b78;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
"Noto Sans", "PingFang SC", "Hiragino Sans GB", sans-serif;
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
a { color: inherit; text-decoration: none; }
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
/* 侧边栏 */
.side { background: var(--bg-soft); border-right: 1px solid var(--line);
padding: 14px 8px; user-select: none; }
.ws { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border-radius: var(--radius-sm); cursor: pointer; }
.ws:hover { background: var(--bg-hover); }
.ws-icon { width: 22px; height: 22px; border-radius: 4px;
background: linear-gradient(135deg,#1f1f1f,#3a3a3a); color: #fff;
display: grid; place-items: center; font-weight: 600; font-size: 12px; }
.ws-name { font-weight: 600; flex: 1; font-size: 14px; }
.ws-caret { color: var(--ink-3); font-size: 12px; }
.side-search { margin: 12px 4px 6px; padding: 5px 8px; color: var(--ink-3);
font-size: 13px; display: flex; align-items: center; gap: 8px;
border-radius: var(--radius-sm); cursor: pointer; }
.side-search:hover { background: var(--bg-hover); }
.side-search kbd { margin-left: auto; font-family: inherit; font-size: 11px;
color: var(--ink-3); background: #fff; border: 1px solid var(--line);
padding: 1px 5px; border-radius: 3px; }
.side-section { margin-top: 14px; padding: 0 8px; color: var(--ink-3);
font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
.nav { margin-top: 4px; display: flex; flex-direction: column; gap: 1px; }
.nav-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px;
border-radius: var(--radius-sm); color: var(--ink); cursor: pointer; }
.nav-item:hover { background: var(--bg-hover); }
.nav-item.active { background: #e8e6e1; font-weight: 500; }
.nav-icon { width: 18px; text-align: center; }
.nav-caret { margin-left: auto; color: var(--ink-3); font-size: 10px; }
/* 主区域 */
.main { display: flex; flex-direction: column; min-width: 0; }
.topbar { height: 44px; padding: 0 16px; border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 12px;
color: var(--ink-2); font-size: 13px; }
.crumbs span:not(:last-child)::after { content: "/"; margin: 0 6px; color: var(--ink-3); }
.topbar-spacer { flex: 1; }
.pill-live { display: inline-flex; align-items: center; gap: 6px;
padding: 2px 8px 2px 6px; background: #fff;
border: 1px solid var(--line-strong); border-radius: 999px;
font-size: 12px; color: var(--ink-2); }
.pill-live .dot { width: 7px; height: 7px; border-radius: 50%;
background: #2ecc71; box-shadow: 0 0 0 0 rgba(46,204,113,.55);
animation: pulse 1.8s infinite; }
.pill-live.syncing .dot { background: var(--accent); animation: none; }
.pill-live.stale .dot { background: #e6a23c; animation: none; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(46,204,113,.55); }
70% { box-shadow: 0 0 0 7px rgba(46,204,113,0); }
100% { box-shadow: 0 0 0 0 rgba(46,204,113,0); }
}
.page { padding: 56px max(80px, 8vw) 80px; max-width: 1100px; width: 100%; align-self: center; }
.page-cover { height: 24px; }
.page-emoji { font-size: 64px; line-height: 1; margin: -4px 0 12px; }
h1.page-title { font-size: calc(40px * var(--display-scale, 1));
line-height: 1.15; font-weight: 700; letter-spacing: -0.01em;
margin: 0 0 8px; color: var(--ink); }
.page-meta { display: flex; align-items: center; gap: 14px;
color: var(--ink-2); font-size: 13px; margin-bottom: 28px; }
.page-meta .updated { display: inline-flex; align-items: center; gap: 6px; }
.page-meta .updated .who { width: 18px; height: 18px; border-radius: 50%;
background: #f1c40f; color: #5a4500;
display: inline-grid; place-items: center;
font-size: 11px; font-weight: 700; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
font-size: 13px; color: var(--ink); background: #fff;
border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
cursor: pointer; transition: background .15s ease, transform .15s ease; }
.btn:hover { background: var(--bg-hover); }
.btn .ico { width: 14px; height: 14px; display: inline-block; }
.btn .ico.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.btn.primary { background: var(--accent); color: #fff; border-color: transparent; }
.btn.primary:hover { background: var(--accent-ink); }
.btn.ghost { border-color: transparent; }
.btn.ghost:hover { background: var(--bg-hover); }
.callout { background: var(--bg-soft); border-radius: var(--radius-md);
padding: 14px 16px; display: flex; gap: 12px;
align-items: flex-start; margin: 10px 0 28px; color: var(--ink); }
.callout .emj { font-size: 18px; line-height: 1.4; }
.callout small { color: var(--ink-2); }
.kpis { display: grid; grid-template-columns: repeat(var(--kpi-count, 4), 1fr);
gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: var(--radius-md); overflow: hidden; margin: 8px 0 32px; }
.kpi { background: #fff; padding: 18px 20px;
display: flex; flex-direction: column; gap: 4px; }
.kpi .label { color: var(--ink-2); font-size: 12px; letter-spacing: .03em;
text-transform: uppercase; font-weight: 500; }
.kpi .value { font-size: 32px; font-weight: 600; letter-spacing: -0.01em;
color: var(--ink); font-variant-numeric: tabular-nums;
transition: color .25s ease; }
.kpi .value.flash { color: var(--accent-ink); }
.kpi .delta { font-size: 12px; color: var(--ink-2); }
.kpi .delta.up { color: #2b8a3e; }
.kpi .delta.down { color: #c0392b; }
.kpi .delta .arr { display: inline-block; width: 10px; }
h2.h { font-size: 18px; font-weight: 600; margin: 32px 0 8px;
letter-spacing: -0.005em; }
.h-sub { color: var(--ink-2); font-size: 13px; margin-bottom: 12px; }
.two-col { display: grid; grid-template-columns: 1.1fr 1fr; gap: 24px;
margin-bottom: 8px; }
.card { background: #fff; border: 1px solid var(--line);
border-radius: var(--radius-md); padding: 16px 18px; }
.card-title { display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: var(--ink-2); margin-bottom: 8px; }
.card-title strong { color: var(--ink); font-weight: 600; }
.spark { width: 100%; height: 120px; }
.spark-axis { color: var(--ink-3); font-size: 11px; }
.spark-fill { fill: rgba(46,170,220,.10); }
.spark-line { fill: none; stroke: var(--accent); stroke-width: 2; }
.spark-dot { fill: var(--accent); }
.spark-grid line { stroke: var(--line); stroke-dasharray: 2 3; }
.feed { display: flex; flex-direction: column; }
.feed-row { display: flex; gap: 12px; padding: 10px 4px;
border-bottom: 1px solid var(--line); }
.feed-row:last-child { border-bottom: none; }
.feed-row .av { flex: 0 0 26px; width: 26px; height: 26px; border-radius: 50%;
display: grid; place-items: center; color: #fff; font-size: 11px; font-weight: 700; }
.feed-row .body { flex: 1; min-width: 0; font-size: 13px; }
.feed-row .body .who { font-weight: 600; }
.feed-row .body .what { color: var(--ink); }
.feed-row .body .target { color: var(--accent-ink);
border-bottom: 1px dotted var(--accent); cursor: pointer; }
.feed-row .time { color: var(--ink-3); font-size: 12px; flex: 0 0 auto; }
.feed-row.new { background: rgba(46,170,220,.06); }
.feed-row.new .body .who::before { content: "•"; color: var(--accent); margin-right: 6px; }
.db { border: 1px solid var(--line); border-radius: var(--radius-md);
overflow: hidden; margin-top: 4px; background: #fff; }
.db-head, .db-row {
display: grid; grid-template-columns: 32px 2.4fr 1fr 1fr 0.9fr 0.9fr;
align-items: center; padding: 8px 12px;
border-bottom: 1px solid var(--line); font-size: 13px;
}
.db-head { background: var(--bg-soft); color: var(--ink-2);
font-size: 12px; text-transform: uppercase; letter-spacing: .03em;
font-weight: 500; }
.db-row:last-child { border-bottom: none; }
.db-row:hover { background: var(--bg-soft); }
.db-row.changed { animation: rowflash 1.4s ease; }
@keyframes rowflash {
0% { background: rgba(46,170,220,.18); }
100% { background: transparent; }
}
.db-cell.title { font-weight: 500; display: flex; align-items: center; gap: 6px; min-width: 0; }
.db-cell.title .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.db-cell .pill { display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 3px; font-size: 12px; font-weight: 500; }
.pill.done { background: var(--pill-done-bg); color: var(--pill-done-ink); }
.pill.progress { background: var(--pill-progress-bg); color: var(--pill-progress-ink); }
.pill.blocked { background: var(--pill-blocked-bg); color: var(--pill-blocked-ink); }
.pill.todo { background: var(--pill-todo-bg); color: var(--pill-todo-ink); }
.pill.review { background: var(--pill-review-bg); color: var(--pill-review-ink); }
.person { display: inline-flex; align-items: center; gap: 6px; }
.person .av { width: 18px; height: 18px; border-radius: 50%;
color: #fff; display: grid; place-items: center;
font-size: 10px; font-weight: 700; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--line);
color: var(--ink-3); font-size: 12px;
display: flex; align-items: center; gap: 8px; }
.footer .key { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--bg-soft); padding: 1px 5px;
border-radius: 3px; color: var(--ink-2); }
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #1f1f1f; color: #fff; padding: 8px 14px;
border-radius: 8px; font-size: 13px;
opacity: 0; pointer-events: none;
transition: opacity .2s ease, transform .2s ease; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
@media (max-width: 980px) {
.app { grid-template-columns: 1fr; }
.side { display: none; }
.page { padding: 32px 20px 64px; }
.kpis { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.db-head, .db-row { grid-template-columns: 24px 1.8fr 0.8fr 0.8fr; }
.db-cell.due, .db-cell.priority { display: none; }
}
</style>
</head>
<body>
<div class="app">
<!--
侧边栏导航行是可交互的——保留每个可点击的 .ws / .side-search
/ .nav-item 上的键盘语义(role="button" + tabindex="0"),即使代理重新设置了视觉效果。
-->
<aside class="side">
<div class="ws" role="button" tabindex="0">
<div class="ws-icon">{{workspace_initial}}</div>
<div class="ws-name">{{workspace_name}}</div>
<div class="ws-caret"></div>
</div>
<div class="side-search" role="button" tabindex="0"><span>🔎</span><span>搜索</span><kbd>⌘K</kbd></div>
<div class="side-search" role="button" tabindex="0"><span></span><span>更新</span></div>
<div class="side-search" role="button" tabindex="0"><span>⚙️</span><span>设置与成员</span></div>
<div class="side-section">工作区</div>
<div class="nav">
<div class="nav-item active" role="button" tabindex="0" aria-current="page"><span class="nav-icon">📊</span><span>{{page_title}}</span></div>
<!-- 代理:在此列出 3-5 个合理的同级页面("任务"、"文档"、"路线图"……);每个 nav-item 保持 role="button" + tabindex="0" -->
</div>
<div class="side-section">连接</div>
<div class="nav">
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">🔌</span><span>{{connector_label}}</span></div>
</div>
</aside>
<main class="main">
<div class="topbar">
<div class="crumbs">
<span>{{workspace_name}}</span>
<span>工作区</span>
<span>{{page_title}}</span>
</div>
<div class="topbar-spacer"></div>
<div class="pill-live" id="livePill">
<span class="dot"></span>
<span id="liveText">实时 · 已同步</span>
</div>
</div>
<div class="page">
<div class="page-cover"></div>
<div class="page-emoji">{{page_emoji}}</div>
<h1 class="page-title">{{page_title}}</h1>
<div class="page-meta">
<div class="updated">
<span class="who">{{owner_initials}}</span>
<span>最后由 <strong>{{owner_name}}</strong> 编辑</span>
</div>
<span>·</span>
<span id="updatedAt">刚刚刷新</span>
<span style="flex:1"></span>
<button class="btn ghost" id="autoBtn" title="自动刷新"><span></span><span id="autoLbl">自动 · 开</span></button>
<button class="btn primary" id="refreshBtn" title="从 {{connector_label}} 拉取最新数据">
<span class="ico" id="refreshIco"></span>
<span>从 {{connector_label}} 刷新</span>
</button>
</div>
<div class="callout">
<div class="emj">💡</div>
<div>
<div><strong>此页面是一个实时产物(Live Artifact)。</strong> 下方数据通过 Composio 连接器从您的 <strong>{{workspace_name}}</strong> {{connector_label}} 拉取。</div>
<small>按需刷新或在页面打开时自动刷新。仅限最近 7 天数据。</small>
</div>
</div>
<!-- 代理:在此网格中渲染 <inputs.kpi_count> 个 .kpi 单元格 -->
<div class="kpis" id="kpis" style="--kpi-count: {{kpi_count}}"></div>
<div class="two-col">
<div class="card">
<div class="card-title">
<strong>创建的任务 · 最近 7 天</strong>
<span id="sparkSum">— 总计</span>
</div>
<svg class="spark" viewBox="0 0 600 140" preserveAspectRatio="none">
<g class="spark-grid">
<line x1="0" y1="35" x2="600" y2="35"/>
<line x1="0" y1="70" x2="600" y2="70"/>
<line x1="0" y1="105" x2="600" y2="105"/>
</g>
<path class="spark-fill" id="sparkFill" d=""></path>
<path class="spark-line" id="sparkLine" d=""></path>
<g id="sparkDots"></g>
<g id="sparkLabels" class="spark-axis"></g>
</svg>
</div>
<div class="card">
<div class="card-title">
<strong>最近活动</strong>
<span>来自 <span style="color: var(--accent-ink)">{{connector_label}}</span></span>
</div>
<div class="feed" id="feed"></div>
</div>
</div>
<h2 class="h">📋 任务 · 当前迭代</h2>
<div class="h-sub">关联数据库 · 筛选条件:状态 ≠ 已完成 · 按更新时间排序</div>
<div class="db" id="db">
<div class="db-head">
<div></div><div>名称</div><div>状态</div><div>负责人</div>
<div class="db-cell due">截止日期</div>
<div class="db-cell priority">优先级</div>
</div>
</div>
<div class="footer">
<span>来源:{{connector_label}} API · 工作区 <strong>{{workspace_slug}}</strong></span>
<span>·</span>
<span>连接器 <span class="key">composio.{{connector_slug}}</span></span>
<span>·</span>
<span id="footerTime"></span>
</div>
</div>
</main>
</div>
<div class="toast" id="toast">已同步</div>
<script>
/* =====================================================================
live-dashboard 运行时
---------------------------------------------------------------------
代理在下方填写 SEED_PEOPLE / SEED_ROWS / SEED_FEED / SEED_SERIES。
运行时负责渲染、刷新(模拟或真实),并跟踪数据陈旧状态。
将 seedNextChange() 替换为 poll() 以连接到真实连接器。
===================================================================== */
/* --- 代理填写:people、rows、feed、series ------------------------ */
const SEED_PEOPLE = [
/* { id: "SC", name: "Sarah Chen", color: "#f1c40f" }, … */
];
const SEED_ROWS = [
/* { id, icon, title, status: "todo|progress|review|blocked|done",
who: <person id>, due, prio: "High|Med|Low", updated: <ms> }, … */
];
const SEED_FEED = [
/* { who: "<full name>", action: "moved|created|commented on|updated|shared",
target: "<task title>", suffix: "<optional clause>", icon: "<emoji>",
mins: <minutes ago> }, … */
];
const SEED_SERIES = [/* 7 个整数,最早 → 今天的桶 */];
const DAY_LABELS = ["周三","周四","周五","周六","周日","周一","周二"];
const KPI_DEFS = [
/* { id: "kTotal", label: "任务总数", get: () => total } */
];
/* --- 运行时 ------------------------------------------------------- */
const $ = id => document.getElementById(id);
let people = SEED_PEOPLE.slice();
let rows = SEED_ROWS.slice();
let feed = SEED_FEED.map(x => ({ ...x, when: new Date(Date.now() - x.mins * 60000), id: rid() }));
let series = SEED_SERIES.slice();
let lastRefresh = new Date();
let autoOn = JSON.parse(localStorage.live_dashboard_auto || "true");
let autoTimer = null;
let busy = false;
function rid() { return "f" + Math.random().toString(36).slice(2, 9); }
function rand(a) { return a[Math.floor(Math.random() * a.length)]; }
function timeAgo(d) {
const s = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
if (s < 5) return "刚刚"; if (s < 60) return s + " 秒前";
const m = Math.floor(s / 60); if (m < 60) return m + " 分钟前";
const h = Math.floor(m / 60); if (h < 24) return h + " 小时前";
return Math.floor(h / 24) + " 天前";
}
/* 所有通过 innerHTML 插入的数据都必须经过 e() 处理——任务
标题/活动字符串/人员姓名可能来自连接器负载,
未转义的 innerHTML 是一个有效的 XSS 注入点。 */
function e(v) {
return String(v == null ? "" : v)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
/* 用于实时刷新过程的渐变 + 闪烁钩子——对应上方 CSS 中的 .flash /
.feed-row.new / .db-row.changed。renderKpi(prev) 是
公共入口;传入前一个 KPI 快照,以便在数值变化时进行
数字渐变和高亮显示。两个辅助函数都遵循
prefers-reduced-motion: reduce——CSS 中的 @media 块已经
消除了动画/过渡效果,但 tweenText() 会调度 JS rAF 更新,
而 flash() 会切换高亮类,因此我们在
这里为减少动效的用户提前短路返回(checklist.md 中的 P0 行)。 */
function reduceMotion() {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tweenText(el, from, to, ms = 600) {
if (!el) return;
if (from === to || reduceMotion()) { el.textContent = String(to); return; }
const start = performance.now();
function step(now) {
const t = Math.min(1, (now - start) / ms);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = String(Math.round(from + (to - from) * eased));
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function flash(el) {
if (!el || reduceMotion()) return;
el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 700);
}
/* 连接器钩子:将其替换为真实的 poll()——请参阅 references/connectors.md */
async function seedNextChange() {
await new Promise(r => setTimeout(r, 700 + Math.random() * 600));
const candidates = rows.filter(r => r.status !== "done");
const r = candidates.length ? rand(candidates) : null;
if (r) { r.status = "done"; r.updated = Date.now(); }
series[series.length - 1] += 1 + Math.floor(Math.random() * 2);
const person = rand(people);
feed = [{
id: rid(), when: new Date(),
who: person.name, action: "moved",
target: r ? r.title : "—", suffix: "to Done", icon: "✅"
}, ...feed].slice(0, 8);
return { changedRowId: r ? r.id : null };
}
/* 渲染:KPI / 迷你趋势图 / 动态信息流 / 数据行 --------------------------------- */
function renderKpi(prev) {
const wrap = $("kpis");
// 首次渲染:从头构建卡片(已转义),以便 KPI 标签 /
// 增量值和数值可以安全地来自任何连接器负载。
if (!wrap.children.length) {
wrap.innerHTML = KPI_DEFS.map(def => `
<div class="kpi">
<div class="label">${e(def.label)}</div>
<div class="value" id="${e(def.id)}">${e(def.get())}</div>
<div class="delta">${e(def.delta || "")}</div>
</div>`).join("");
return;
}
// 后续渲染:对数值型 KPI 从 prev → 当前进行数字渐变,
// 并在数值实际发生变化时闪烁单元格。
for (const def of KPI_DEFS) {
const cell = $(def.id);
if (!cell) continue;
const next = def.get();
const before = prev ? Number(prev[def.id]) : Number(cell.textContent);
const after = Number(next);
if (Number.isFinite(before) && Number.isFinite(after)) {
tweenText(cell, before, after);
if (before !== after) flash(cell);
} else {
cell.textContent = String(next);
}
}
}
/* 快照 KPI_DEFS 生成的数值,以便下一次 renderKpi 可以从
"旧" 渐变到 "新"。在修改状态之前调用。 */
function snapshotKpi() {
const out = {};
for (const def of KPI_DEFS) out[def.id] = Number(def.get());
return out;
}
function renderSpark() {
const W=600,H=140,padL=24,padR=16,padT=10,padB=24;
const max=Math.max(...series,1);
const stepX=(W-padL-padR)/(series.length-1);
const pts=series.map((v,i)=>[padL+i*stepX, padT+(1-v/(max*1.15))*(H-padT-padB)]);
const line="M "+pts.map(p=>p.join(" ")).join(" L ");
const fill=line+` L ${pts.at(-1)[0]} ${H-padB} L ${pts[0][0]} ${H-padB} Z`;
$("sparkLine").setAttribute("d",line);
$("sparkFill").setAttribute("d",fill);
$("sparkDots").innerHTML = pts.map(([x,y])=>`<circle class="spark-dot" cx="${x}" cy="${y}" r="3"/>`).join("");
$("sparkLabels").innerHTML = DAY_LABELS.map((d,i)=>{const x=padL+i*stepX;return `<text x="${x}" y="${H-6}" text-anchor="middle">${d}</text>`;}).join("");
$("sparkSum").textContent = series.reduce((a,b)=>a+b,0)+" 总计";
}
function pcolor(name) { return (people.find(p=>p.name===name)||{}).color || "#7f8c8d"; }
const STATUS_CSS = { done: 1, progress: 1, blocked: 1, todo: 1, review: 1 };
const STATUS_LABEL = { done: "已完成", progress: "进行中", blocked: "已阻塞", todo: "待办", review: "审核中" };
/* 连接器负载可能包含攻击者控制的字符串(任务
标题、活动描述、人员姓名)。下面每个动态值在
通过 innerHTML 插入之前都会经过 e() 处理。 */
function renderFeed(highlightId) {
// 在 reduce-motion 模式下移除临时的 .new 高亮,以免
// 行显示有色背景脉冲——这适用于已选择退出动效的用户。
const effectiveHighlight = reduceMotion() ? null : highlightId;
$("feed").innerHTML = feed.slice(0,6).map(it=>{
const init = String(it.who||"").split(" ").map(s=>s[0]).join("").slice(0,2).toUpperCase();
const cls = it.id===effectiveHighlight ? "feed-row new" : "feed-row";
const suffixHtml = it.suffix ? `<span class="what"> ${e(it.suffix)}</span>` : "";
return `<div class="${cls}">
<span class="av" style="background:${e(pcolor(it.who))}">${e(init)}</span>
<div class="body"><span class="who">${e(it.who)}</span>
<span class="what"> ${e(it.action)}</span>
<span class="target"> ${e(it.icon||"")} ${e(it.target)}</span>
${suffixHtml}</div>
<div class="time">${e(timeAgo(it.when))}</div></div>`;
}).join("");
}
function personChip(id) {
const p = people.find(x=>x.id===id) || {};
return `<span class="person"><span class="av" style="background:${e(p.color||"#888")}">${e(p.id||"")}</span><span>${e(p.name||"")}</span></span>`;
}
function renderRows(changedId) {
// 与 renderFeed 相同的 reduce-motion 保护:跳过 .changed 行闪烁
// 类,以免为已选择退出动效的用户绘制临时高亮。
const effectiveChanged = reduceMotion() ? null : changedId;
const db=$("db"); [...db.querySelectorAll(".db-row")].forEach(n=>n.remove());
[...rows].sort((a,b)=>b.updated-a.updated).forEach(r=>{
const div=document.createElement("div");
div.className="db-row"+(r.id===effectiveChanged?" changed":"");
const statusKey = STATUS_CSS[r.status] ? r.status : "todo";
const lbl = STATUS_LABEL[statusKey];
div.innerHTML=`<div class="db-cell">${e(r.icon)}</div>
<div class="db-cell title"><span class="t">${e(r.title)}</span></div>
<div class="db-cell"><span class="pill ${statusKey}">${e(lbl)}</span></div>
<div class="db-cell">${personChip(r.who)}</div>
<div class="db-cell due">${e(r.due)}</div>
<div class="db-cell priority">${e(r.prio)}</div>`;
db.appendChild(div);
});
}
async function refresh({silent=false}={}) {
if (busy) return; busy = true;
$("refreshIco").classList.add("spin");
$("livePill").classList.add("syncing"); $("liveText").textContent="同步中…";
// 在修改之前快照 KPI,以便 renderKpi(prev) 可以从
// 旧值渐变到新值,并在实际发生变化的数值上闪烁。
const prev = snapshotKpi();
try {
const result = await seedNextChange(); /* 代理:替换为真实的 poll() */
lastRefresh = new Date();
renderKpi(prev); renderSpark(); renderFeed(feed[0]?.id);
renderRows(result?.changedRowId);
if (!silent) showToast("已同步 · 移动了 1 个任务,新增了 1 条活动");
} catch (err) {
$("livePill").classList.add("stale"); $("liveText").textContent = "已过期 · " + timeAgo(lastRefresh);
} finally {
busy = false; $("refreshIco").classList.remove("spin");
$("livePill").classList.remove("syncing"); updateTimes();
}
}
function showToast(msg) {
const t = $("toast"); t.textContent = msg; t.classList.add("show");
clearTimeout(showToast._t); showToast._t = setTimeout(()=>t.classList.remove("show"), 1800);
}
function updateTimes() {
$("updatedAt").textContent = "上次刷新 " + timeAgo(lastRefresh);
$("footerTime").textContent = "上次同步 · " + lastRefresh.toLocaleTimeString();
const stale = (Date.now() - lastRefresh.getTime()) > ({{stale_after_seconds}} * 1000);
$("livePill").classList.toggle("stale", stale);
$("liveText").textContent = stale ? ("已过期 · " + timeAgo(lastRefresh)) : "实时 · 已同步";
}
function startAuto() { stopAuto(); autoTimer = setInterval(()=>refresh({silent:true}), {{refresh_seconds}} * 1000); }
function stopAuto() { if (autoTimer) clearInterval(autoTimer); autoTimer = null; }
// .ws / .side-search / .nav-item 被样式化为可点击的行,但它们
// 是 <div>,因此键盘用户默认无法获得任何交互。为任何
// 并非真实按钮/链接且具有 role="button" 的元素,将 Enter/空格
// 映射为合成点击。
document.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
if (t.getAttribute("role") !== "button") return;
if (t.matches("button, a, input, select, textarea")) return;
ev.preventDefault();
t.click();
});
document.getElementById("refreshBtn").addEventListener("click", ()=>refresh());
document.getElementById("autoBtn").addEventListener("click", () => {
autoOn = !autoOn;
document.getElementById("autoLbl").textContent = "自动 · " + (autoOn ? "开" : "关");
localStorage.live_dashboard_auto = JSON.stringify(autoOn);
autoOn ? startAuto() : stopAuto();
});
setInterval(updateTimes, 5000);
setInterval(()=>renderFeed(), 15000);
renderKpi(); renderSpark(); renderFeed(); renderRows(); updateTimes();
if (autoOn) startAuto();
setTimeout(()=>refresh({silent:true}), 600); // 打开时刷新
</script>
</body>
</html>