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

944 lines
36 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>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Acme · 团队仪表盘</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: #2eaadc;
--accent-ink: #0b6e91;
--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;
--shadow-card: 0 1px 0 rgba(15,15,15,0.04), 0 1px 2px rgba(15,15,15,0.04);
--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.stale .dot { background: #e6a23c; animation: none; }
/* "Sample data" — 未绑定真实连接器。保持灰色静态,
使制品不会声称它没有的实时数据源。 */
.pill-live.sample {
background: var(--bg-soft); color: var(--ink-3);
border-color: var(--line);
}
.pill-live.sample .dot {
background: var(--ink-4); animation: none; box-shadow: 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; /* 保持像 Notion 默认一样的通透感 */
}
.page-emoji {
font-size: 64px; line-height: 1;
margin: -4px 0 12px; cursor: default;
}
h1.page-title {
font-size: 40px; 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); }
/* ---------- KPI 网格 ---------- */
.kpis {
display: grid; grid-template-columns: repeat(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">A</div>
<div class="ws-name">Acme Studio</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>团队仪表盘</span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon"></span><span>任务</span>
<span class="nav-caret"></span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon">📓</span><span>文档</span>
<span class="nav-caret"></span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon">🚀</span><span>路线图</span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon">🧠</span><span>脑暴笔记</span>
</div>
</div>
<div class="side-section">私人</div>
<div class="nav">
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">🗒️</span><span>每日日志</span></div>
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">📥</span><span>收件箱</span></div>
</div>
<div class="side-section">连接</div>
<div class="nav">
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">🔌</span><span>Notion · acme.so</span></div>
</div>
</aside>
<!-- ============ 主区域 ============ -->
<main class="main">
<div class="topbar">
<div class="crumbs">
<span>Acme Studio</span>
<span>工作区</span>
<span>团队仪表盘</span>
</div>
<div class="topbar-spacer"></div>
<div class="pill-live sample" id="livePill" title="未绑定连接器——以下数值为预设样本数据">
<span class="dot"></span>
<span id="liveText">样本数据</span>
</div>
</div>
<div class="page">
<div class="page-cover"></div>
<div class="page-emoji">📊</div>
<h1 class="page-title">团队仪表盘</h1>
<div class="page-meta">
<div class="updated">
<span class="who">SC</span>
<span>最后编辑者 <strong>Sarah Chen</strong></span>
</div>
<span>·</span>
<span id="updatedAt">刚刚刷新</span>
<span style="flex:1"></span>
<button class="btn ghost" id="autoBtn" title="每 30 秒自动刷新">
<span></span><span id="autoLbl">自动 · 开</span>
</button>
<button class="btn primary" id="refreshBtn" title="从 Notion 拉取最新数据(样本模式——刷新预设数据)">
<span class="ico" id="refreshIco"></span>
<span>从 Notion 刷新</span>
</button>
</div>
<div class="callout">
<div class="emj">💡</div>
<div>
<div><strong>此页面是基于样本数据运行的实时制品。</strong>
其形态模拟了 <strong>Acme Studio</strong> 的 Notion 工作区
通过 Composio 连接器返回的内容——但以下数字、名称、
任务和活动动态均为预设夹具数据,并非真实工作区。</div>
<small>当连接器绑定后,徽章将切换为<em>实时 · 已同步</em>
相同的渲染器将展示真实数据。在此之前,刷新仅
对预设夹具数据进行微调,以便离线演示实时行为。</small>
</div>
</div>
<!-- KPI -->
<div class="kpis">
<div class="kpi">
<div class="label">总任务数</div>
<div class="value" id="kTotal">142</div>
<div class="delta up"><span class="arr"></span> 较上周 +6</div>
</div>
<div class="kpi">
<div class="label">本周已完成</div>
<div class="value" id="kDone">23</div>
<div class="delta up"><span class="arr"></span> 较上周 +4</div>
</div>
<div class="kpi">
<div class="label">活跃成员</div>
<div class="value" id="kPeople">11<span style="color:var(--ink-3); font-size:18px; font-weight:500"> / 14</span></div>
<div class="delta">· 稳定</div>
</div>
<div class="kpi">
<div class="label">待审阅文档</div>
<div class="value" id="kReview">7</div>
<div class="delta down"><span class="arr"></span> 较上周 -2</div>
</div>
</div>
<!-- 双列 -->
<div class="two-col">
<div class="card">
<div class="card-title">
<strong>创建的任务 · 过去 7 天</strong>
<span id="sparkSum">共计 98</span>
</div>
<svg class="spark" viewBox="0 0 600 140" preserveAspectRatio="none" id="sparkSvg">
<!-- 网格 -->
<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)">Notion</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>数据源:Notion API · 工作区 <strong>acme.so</strong></span>
<span>·</span>
<span>连接器 <span class="key">composio.notion</span></span>
<span>·</span>
<span id="footerTime"></span>
</div>
</div>
</main>
</div>
<div class="toast" id="toast">已同步——来自 Notion 的 3 项变更</div>
<script>
(function(){
// ---------- 伪数据状态 ----------
const PEOPLE = [
{ id: "SC", name: "Sarah Chen", color: "#f1c40f" },
{ id: "MK", name: "Mike Park", color: "#3498db" },
{ id: "LZ", name: "Lisa Zhao", color: "#e67e22" },
{ id: "DT", name: "Dave Torres", color: "#9b59b6" },
{ id: "AN", name: "Anna Nakamura",color: "#1abc9c" },
{ id: "RJ", name: "Raj Patel", color: "#e74c3c" }
];
const ROW_POOL = [
{ icon: "🚀", title: "Q3 Roadmap planning", status: "progress", who: "SC", due: "May 12", prio: "High" },
{ icon: "📐", title: "Design tokens v2 spec", status: "review", who: "LZ", due: "May 14", prio: "Med" },
{ icon: "🐛", title: "Fix mobile nav overflow on iOS", status: "blocked", who: "MK", due: "May 09", prio: "High" },
{ icon: "📊", title: "Pricing experiment readout", status: "todo", who: "DT", due: "May 18", prio: "Low" },
{ icon: "✍️", title: "Launch blog draft · Live Artifacts", status: "progress", who: "AN", due: "May 11", prio: "High" },
{ icon: "🔐", title: "Rotate Notion integration token", status: "todo", who: "RJ", due: "May 20", prio: "Med" },
{ icon: "🧪", title: "QA onboarding flow regressions", status: "review", who: "MK", due: "May 16", prio: "Med" },
{ icon: "🎨", title: "Refresh marketing OG images", status: "progress", who: "LZ", due: "May 13", prio: "Low" }
];
const ACTIVITY_TEMPLATES = [
(p, t) => ({ who: p, action: "moved", target: t, suffix: "to Done", icon: "✅" }),
(p, t) => ({ who: p, action: "created", target: t, suffix: "in Tasks", icon: "" }),
(p, t) => ({ who: p, action: "commented on", target: t, suffix: "", icon: "💬" }),
(p, t) => ({ who: p, action: "updated", target: t, suffix: "properties", icon: "✏️" }),
(p, t) => ({ who: p, action: "shared", target: t, suffix: "with Marketing", icon: "🔗" }),
(p, t) => ({ who: p, action: "added", target: t, suffix: "to sprint", icon: "📌" })
];
const SEED_FEED = [
{ who: "Sarah Chen", action: "moved", target: "Q3 Roadmap planning", suffix: "to In progress", icon: "🚀", mins: 3 },
{ who: "Mike Park", action: "commented on", target: "Fix mobile nav overflow on iOS", suffix: "", icon: "💬", mins: 11 },
{ who: "Lisa Zhao", action: "created", target: "Design tokens v2 spec", suffix: "in Docs", icon: "📐", mins: 26 },
{ who: "Anna Nakamura",action: "shared", target: "Launch blog draft · Live Artifacts", suffix: "with Marketing", icon: "🔗", mins: 41 },
{ who: "Dave Torres", action: "updated", target: "Pricing experiment readout", suffix: "properties", icon: "✏️", mins: 58 }
];
let kpi = { total: 142, done: 23, people: 11, peopleAll: 14, review: 7 };
let series = [8, 12, 15, 9, 18, 22, 14];
let dayLabels = ["Wed","Thu","Fri","Sat","Sun","Mon","Tue"];
let lastRefresh = new Date();
let autoOn = true;
let autoTimer = null;
// ---------- DOM 引用 ----------
const $ = (id) => document.getElementById(id);
const refreshBtn = $("refreshBtn");
const refreshIco = $("refreshIco");
const autoBtn = $("autoBtn");
const autoLbl = $("autoLbl");
const updatedAt = $("updatedAt");
const liveText = $("liveText");
const livePill = $("livePill");
const toast = $("toast");
const footerTime = $("footerTime");
// ---------- 辅助函数 ----------
// 下方所有通过 innerHTML 渲染的动态值都经过 e() 处理:
// 任务标题/活动字符串/人员姓名可能来自连接器载荷,
// 未经转义的 innerHTML 是一个有效的 XSS 注入点。
function e(v) {
return String(v == null ? "" : v)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
function avatar(personId) {
const p = PEOPLE.find(x => x.id === personId);
if (!p) return "";
return `<span class="av" style="background:${e(p.color)}">${e(p.id)}</span>`;
}
function personChip(personId) {
const p = PEOPLE.find(x => x.id === personId);
if (!p) return "";
return `<span class="person">${avatar(personId)}<span>${e(p.name)}</span></span>`;
}
function timeAgo(date) {
const s = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
const m = Math.floor(s / 60);
if (m < 60) return m + " min ago";
const h = Math.floor(m / 60);
if (h < 24) return h + " h ago";
return Math.floor(h/24) + " d ago";
}
function fmtDateTime(d) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) +
" · " + d.toLocaleDateString([], { month: "short", day: "numeric" });
}
function rand(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
// 减少动效开关。上面的 CSS @media 块已经屏蔽了 CSS 动画/过渡,
// 但 tweenText() 会调度 rAF 更新,flash() 会切换临时高亮类——
// 两者都会让选择减少动效的用户感到不适,除非我们在此处短路它们。
function reduceMotion() {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tween(el, from, to, ms = 600) {
if (!el) return;
if (from === to || reduceMotion()) {
el.firstChild ? (el.childNodes[0].nodeValue = to) : (el.textContent = 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);
const v = Math.round(from + (to - from) * eased);
el.firstChild ? (el.childNodes[0].nodeValue = v) : (el.textContent = v);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// ---------- 渲染:KPI ----------
function renderKpi(prev) {
const flash = (el) => {
if (!el || reduceMotion()) return;
el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 700);
};
if (prev) {
tweenText($("kTotal"), prev.total, kpi.total); if (prev.total !== kpi.total) flash($("kTotal"));
tweenText($("kDone"), prev.done, kpi.done); if (prev.done !== kpi.done) flash($("kDone"));
tweenText($("kReview"), prev.review, kpi.review); if (prev.review !== kpi.review) flash($("kReview"));
// people 渲染保留 html——数值是安全的,但为了连接器驱动的载荷
// 也能通过此路径而不需重写标记,仍然通过 e() 处理。
$("kPeople").innerHTML = e(kpi.people) + `<span style="color:var(--ink-3); font-size:18px; font-weight:500"> / ${e(kpi.peopleAll)}</span>`;
if (prev.people !== kpi.people) flash($("kPeople"));
} else {
$("kTotal").textContent = kpi.total;
$("kDone").textContent = kpi.done;
$("kReview").textContent = kpi.review;
$("kPeople").innerHTML = e(kpi.people) + `<span style="color:var(--ink-3); font-size:18px; font-weight:500"> / ${e(kpi.peopleAll)}</span>`;
}
}
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);
const v = Math.round(from + (to - from) * eased);
el.textContent = v;
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// ---------- 渲染:迷你走势图 ----------
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) => {
const x = padL + i * stepX;
const y = padT + (1 - v / (max * 1.15)) * (H - padT - padB);
return [x, y];
});
const linePath = "M " + pts.map(p => p.join(" ")).join(" L ");
const fillPath = linePath + ` L ${pts[pts.length-1][0]} ${H - padB} L ${pts[0][0]} ${H - padB} Z`;
$("sparkLine").setAttribute("d", linePath);
$("sparkFill").setAttribute("d", fillPath);
const dots = pts.map(([x,y]) => `<circle class="spark-dot" cx="${x}" cy="${y}" r="3"/>`).join("");
$("sparkDots").innerHTML = dots;
const labels = dayLabels.map((d, i) => {
const x = padL + i * stepX;
return `<text x="${x}" y="${H - 6}" text-anchor="middle">${d}</text>`;
}).join("");
$("sparkLabels").innerHTML = labels;
$("sparkSum").textContent = series.reduce((a,b)=>a+b,0) + " total";
}
// ---------- 渲染:动态 ----------
let feed = SEED_FEED.map(x => ({...x, when: new Date(Date.now() - x.mins * 60 * 1000), id: cryptoRandomId() }));
function cryptoRandomId() { return "f" + Math.random().toString(36).slice(2, 9); }
function renderFeed(highlightId) {
const el = $("feed");
// 在减少动效模式下丢弃临时的 .new 高亮,以免行背景闪烁
// 给选择减少动效的用户带来不适。
const effectiveHighlight = reduceMotion() ? null : highlightId;
el.innerHTML = feed.slice(0, 6).map(item => {
const initials = String(item.who||"").split(" ").map(s => s[0]).join("").slice(0,2).toUpperCase();
const color = personColor(item.who);
const cls = item.id === effectiveHighlight ? "feed-row new" : "feed-row";
const suffixHtml = item.suffix ? `<span class="what"> ${e(item.suffix)}</span>` : "";
return `
<div class="${cls}" data-id="${e(item.id)}">
<span class="av" style="background:${e(color)}">${e(initials)}</span>
<div class="body">
<span class="who">${e(item.who)}</span>
<span class="what"> ${e(item.action)}</span>
<span class="target"> ${e(item.icon || "")} ${e(item.target)}</span>
${suffixHtml}
</div>
<div class="time" title="${e(item.when.toLocaleString())}">${e(timeAgo(item.when))}</div>
</div>`;
}).join("");
}
function personColor(name) {
const p = PEOPLE.find(x => x.name === name);
return p ? p.color : "#7f8c8d";
}
// ---------- 渲染:数据库行 ----------
let rows = ROW_POOL.slice(0, 6).map((r, i) => ({...r, id: "r"+i, updated: Date.now() - (i+1)*1800000 }));
function renderRows(changedId) {
const head = document.querySelector(".db-head");
const db = $("db");
[...db.querySelectorAll(".db-row")].forEach(n => n.remove());
const sorted = [...rows].sort((a,b) => b.updated - a.updated);
const STATUS_LABEL = { done: "Done", progress: "In progress", blocked: "Blocked", todo: "To do", review: "In review" };
// 与 renderFeed 相同的减少动效开关:跳过 .changed
// 行闪烁类,以免为选择减少动效的用户绘制临时高亮
//(上方的 prefers-reduced-motion @media 块已经屏蔽了 rowflash CSS 动画,
// 但类本身仍会传达意图)。
const effectiveChanged = reduceMotion() ? null : changedId;
sorted.forEach(r => {
const div = document.createElement("div");
div.className = "db-row" + (r.id === effectiveChanged ? " changed" : "");
div.dataset.id = r.id;
const statusKey = STATUS_LABEL[r.status] ? r.status : "todo";
const statusLabel = 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(statusLabel)}</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);
});
}
// ---------- 刷新逻辑(实时部分) ----------
let busy = false;
async function refresh({silent = false} = {}) {
if (busy) return;
busy = true;
refreshIco.classList.add("spin");
livePill.classList.remove("stale");
liveText.textContent = "Syncing…";
// 模拟 Notion API 调用的延迟。
await new Promise(r => setTimeout(r, 700 + Math.random() * 600));
const prev = {...kpi};
const changes = [];
// 以看似合理的方式变更状态。
const newDone = Math.random() < 0.7 ? 1 : 0;
if (newDone) {
kpi.done += 1;
kpi.total += Math.random() < 0.5 ? 1 : 0;
// 将一行翻转为已完成
const candidates = rows.filter(r => r.status !== "done");
if (candidates.length) {
const r = rand(candidates);
r.status = "done";
r.updated = Date.now();
changes.push({ kind: "row", id: r.id, row: r });
}
} else {
// 创建一条全新任务
const rest = ROW_POOL.filter(p => !rows.some(r => r.title === p.title));
const tmpl = rest.length ? rand(rest) : rand(ROW_POOL);
const r = { ...tmpl, id: "r" + Date.now(), status: "todo", updated: Date.now() };
rows = [r, ...rows].slice(0, 8);
kpi.total += 1;
changes.push({ kind: "row", id: r.id, row: r });
}
if (Math.random() < 0.4) {
kpi.review = Math.max(0, kpi.review + (Math.random() < 0.5 ? -1 : 1));
}
if (Math.random() < 0.25) {
kpi.people = Math.min(kpi.peopleAll, Math.max(6, kpi.people + (Math.random() < 0.5 ? -1 : 1)));
}
// 序列移位:今日桶增加 1-3
series = series.slice();
series[series.length - 1] = series[series.length - 1] + 1 + Math.floor(Math.random()*2);
// 添加一条新的动态条目
const lastChange = changes[changes.length - 1];
const person = rand(PEOPLE);
const tmpl = rand(ACTIVITY_TEMPLATES);
const targetTitle = lastChange ? lastChange.row.title : rand(ROW_POOL).title;
const evt = tmpl(person.name, targetTitle);
evt.when = new Date();
evt.id = cryptoRandomId();
feed = [evt, ...feed].slice(0, 8);
lastRefresh = new Date();
busy = false;
refreshIco.classList.remove("spin");
// 仅模拟模式——保持"Sample data"徽章,使制品
// 永远不会虚假声称绑定了真实连接器。
liveText.textContent = "Sample data";
livePill.classList.remove("stale");
livePill.classList.add("sample");
renderKpi(prev);
renderSpark();
renderFeed(evt.id);
renderRows(lastChange ? lastChange.id : null);
updateTimes();
if (!silent) showToast("样本数据已刷新 · 1 个任务已移动,1 条活动已添加");
}
function showToast(msg) {
toast.textContent = msg;
toast.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toast.classList.remove("show"), 1800);
}
function updateTimes() {
updatedAt.textContent = "上次刷新 " + timeAgo(lastRefresh);
footerTime.textContent = "最后同步 · " + fmtDateTime(lastRefresh);
// 样本模式(未绑定连接器)是粘性的——制品永远不会
// 对夹具数据声称"实时 · 已同步"。只有当样本刷新循环
// 一段时间未运行时(例如自动刷新被暂停),徽章才会升级为
// "过时 · …"。
if ((Date.now() - lastRefresh.getTime()) > 90 * 1000) {
livePill.classList.add("stale");
livePill.classList.remove("sample");
liveText.textContent = "Stale · " + timeAgo(lastRefresh);
} else {
livePill.classList.remove("stale");
livePill.classList.add("sample");
liveText.textContent = "Sample data";
}
}
// ---------- role=button 的 div 的键盘激活 ----------
// .ws / .side-search / .nav-item 被样式化为可点击行,但它们
// 是 <div>,所以键盘用户得不到任何默认支持。将 Enter/Space
// 映射为合成点击,并阻止 Space 的默认滚动行为。
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();
});
// ---------- 初始化 ----------
function init() {
renderKpi();
renderSpark();
renderFeed();
renderRows();
updateTimes();
// "打开时刷新"——制品挂载的瞬间。
setTimeout(() => refresh({silent: true}), 600);
}
// ---------- 绑定事件 ----------
refreshBtn.addEventListener("click", () => refresh());
autoBtn.addEventListener("click", () => {
autoOn = !autoOn;
autoLbl.textContent = "Auto · " + (autoOn ? "on" : "off");
if (autoOn) startAuto(); else stopAuto();
});
function startAuto() { stopAuto(); autoTimer = setInterval(() => refresh({silent: true}), 30 * 1000); }
function stopAuto() { if (autoTimer) clearInterval(autoTimer); autoTimer = null; }
setInterval(updateTimes, 5 * 1000);
// 每 15 秒刷新动态中的相对时间戳
setInterval(() => renderFeed(), 15 * 1000);
startAuto();
init();
})();
</script>
</body>
</html>