项目文件夹

文件
2026-07-13 21:35:48 +08:00

636 行
30 KiB
HTML

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<!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>