← Obsidian的配置与美化

Obsidian的配置与美化(四)——Dashboard首页看板

这一篇开始进入整套配置里最像“首页”的部分。前一篇你已经把插件装好了,这一篇就直接把 Dashboard 搭起来。目标很简单,打开 Vault 的第一眼,不再是文件列表,而是一个能看任务、看统计、看最近编辑、顺手新建笔记的工作台。整套设计思路参考 Zen Dashboard,不过内容组织更贴近我自己的目录结构。

效果预览

这个 Dashboard 的核心不是炫技,而是把常用信息压到一屏里。页面整体是 Glassmorphism 毛玻璃风格,卡片之间留白比较大,看起来轻一点。布局用的是 12 列网格,所以任务、项目、统计、热力图这些区块可以拼成比较稳定的桌面式排布。页面顶部是一句随时间变化的问候语,再加上当天日期和 5 个统计方块。中间区域是任务面板、项目进度和活动热力图,下面还有最近编辑和快捷入口。鼠标放到卡片上时会有轻微动画,整个首页会更像一个控制台,而不是一张普通笔记。样式部分都交给 dashboard.css,下一篇我再单独拆开讲。

前置依赖

这篇默认你已经装好了 3 个插件,DataviewHomepageHeatmap Calendar

  • Dataview 负责查页面、读任务、生成卡片内容
  • Homepage 负责把 Dashboard.md 设成启动首页
  • Heatmap Calendar 负责渲染活动热力图

如果你已经跟着前一篇配完插件,这里基本可以直接开始。顺带一提,这套 Dashboard 的设计参考了 Zen Dashboard,但脚本和目录源是按这套 Vault 结构写的,所以你后面会看到不少像 10-blog30-projects40-todo 这样的路径。

创建 Dashboard.md

先在 Vault 根目录新建一个文件,名字就叫 Dashboard.md。文件开头先放这段 frontmatter:

---
cssclasses:
  - dashboard
  - max
obsidianUIMode: preview
---

这 3 行别省。

cssclasses 的作用,是给当前页面根节点挂上额外的 CSS class。这样你在 snippet 里就可以只针对 .dashboard 这类页面写样式,不会把普通笔记也一起改掉。这里再加一个 max,通常是为了配合样式把页面宽度放开,让 12 列网格有足够空间。

obsidianUIMode: preview 的作用也很直接,它会强制这个页面用预览模式打开。因为 Dashboard 里核心内容是 DataviewJS 动态生成的,如果你每次点开都停在源码模式,看到的只会是脚本本身,体验会很差。固定到预览模式之后,这一页才真的像首页。

建好文件后,如果你在用 Homepage,记得把首页路径改成 Dashboard

先认识 DataviewJS 的几个接口

正式看代码之前,先把几个会反复出现的 API 认一下:

  • dv.pages(query),按路径或条件取一组页面,比如 dv.pages('"30-projects"')
  • dv.page(path),取单个页面,比如 dv.page("40-todo/inbox")
  • dv.container.createDiv(),在当前 DataviewJS 输出区创建一个 div
  • createDiv()createEl(),继续往下拼 DOM 结构,做卡片、标题、按钮
  • where()sort()limit(),对查询结果继续筛选、排序、截断

你可以把 DataviewJS 理解成一层“拿数据 + 拼页面”的脚本接口。dv.pages 负责拿数据,createDiv 负责把这些数据变成页面里的卡片。

Dashboard 代码解析

1. 辅助函数

Dashboard 一上来先定义了 6 个辅助函数,这一步很关键,因为后面几乎所有卡片都靠它们复用逻辑。

getGreeting() 根据当前小时返回问候语,顶部标题就用它来生成。parseDue() 负责从任务文本里抓 📅 YYYY-MM-DD 这种日期标记,没有这个格式的任务不会进入 Today、This Week、This Month 这几块。getPri()⏫ / 🔼 / 🔽 解析优先级,方便排序和打标签。cleanText() 会把日期和优先级符号从任务文本里去掉,避免显示时太乱。

真正把任务画出来的是 renderTask(),它会创建一行任务 DOM,再根据优先级补一个标签,根据到期日补一个日期。makeCard() 则是通用卡片工厂,标题和 body 结构统一交给它处理,这样后面每种面板都能用同一套壳子。

如果你想改成自己的风格,最值得先动的就是这几处,问候语内容、优先级符号、任务日期格式。

2. Header 区域

Header 只有两部分,问候语和统计方块,但它决定了整页的第一眼信息。

const header = container.createDiv({ cls: "bed-header" });
header.createEl("h1", { text: getGreeting() + ", Bed" });
header.createDiv({ cls: "bed-date", text: today.toFormat("EEEE, MMMM d, yyyy") });

这里的 "Bed" 直接换成你的名字就行。下面 5 个统计方块分别统计 Notes、Blog、Wiki、Projects、Daily,对应的就是整库页数、10-blog20-wiki30-projects50-daily。统计值来自 dv.pages() 的数量,所以你的目录名如果不同,记得一起改掉。

3. 任务面板

任务区是首页里最实用的一块,一共 4 张卡片,TodayThis WeekThis MonthInbox。前三张是从 40-todo30-projects50-daily 里把所有任务抓出来,再按到期日切片,最后一张单独读取 40-todo/inbox

Today 的判断条件是:

const todayTasks = allTasks
  .where(t => {
    if (t.completed) return false;
    const due = parseDue(t.text);
    return due && due.ts <= today.ts;
  })
  .sort(t => getPri(t.text));

注意这里不是“今天到期”,而是“今天及以前到期”。也就是说,逾期任务也会被压到 Today 卡片里,这样你不会漏看。This Week 是 today < due <= 7 天后,This Month 是 7 天后 < due <= 30 天后。排序只看优先级,所以高优任务会先出现。

如果你的任务不放在这些目录,直接改最前面的数据源:

const pages = dv.pages('"40-todo" or "30-projects" or "50-daily"');

4. 项目进度

项目面板会读取 30-projects/ 下所有带任务的页面,然后按“已完成任务数 / 总任务数”计算完成率,再画成进度条。

const total = proj.file.tasks.length;
const done = proj.file.tasks.where(t => t.completed).length;
const pct = total > 0 ? Math.round(done / total * 100) : 0;

这一块很适合做长期项目总览。你打开首页,不用点进项目页,就能先看到哪些项目卡住了。脚本后半段还会额外读取 40-todo/projects,把项目相关的未完成任务继续列出来,所以它既有“总体进度”,也有“马上要做的事”。

5. 热力图

热力图部分调用的是 Heatmap Calendar 提供的全局函数 renderHeatmapCalendar。脚本先把当年创建的笔记按日期聚合,再把每天的数量映射成热力强度。

if (typeof renderHeatmapCalendar === "function") {
  const aNotes = dv.pages("").where(p => p.file.cday && p.file.cday.year === year);
  ...
  renderHeatmapCalendar(heatCard, { year, colors: ..., entries });
}

这里统计的是 file.cday,也就是创建时间,不是修改时间。如果你更在意“最近有没有持续输出”,这个口径比较合适。如果你想看“最近有没有持续回顾和修改”,可以自己改成 mtime。插件没开时,卡片会显示一条提示,不会直接报错。

6. 统计面板

统计面板右上角的大数字,本质上是整库任务完成率:

const totalT = aT.length;
const doneT = aT.where(t => t.completed).length;
const compPct = totalT > 0 ? Math.round(doneT / totalT * 100) : 0;

大字下面又拆成 Done / Open / Total / Notes 四格,这样你不用点进任务系统,也能先看到自己现在大概是什么状态。这个面板的价值不在“绝对精确”,而在“打开首页时有一个立刻能读懂的反馈”。

7. 最近编辑

最近编辑区会排除 _templates_config_private.obsidian 这些不想展示的目录,然后按 mtime 倒序取 10 条。

const recentP = dv.pages('-"_templates" and -"_config" and -"_private" and -".obsidian"')
  .sort(p => p.file.mtime, "desc").limit(10);

这块非常适合做“我刚刚在忙什么”的回顾。列表里既显示文件名,也显示所在目录和最后修改时间。你如果想让首页更偏博客工作台,可以把这里改成只显示 10-blog。如果想让它更像知识库入口,也可以改成只看 20-wiki

8. 快捷入口

最后一块是快捷按钮,分两种,创建型入口和导航型入口。创建型按钮会直接打开一个目标路径,不存在就新建,比如今天的日记、博客草稿、Wiki 草稿、项目草稿。导航型按钮则是跳转到 Inbox、项目清单和几个 MOC 页面。

btn.addEventListener("click", () => { app.workspace.openLinkText(cd[1], ""); });

这句很有用,它让 Dashboard 不只是“看板”,还是“入口”。你可以把 新博客-日期新笔记-日期 这些命名规则换成自己的模板路径,也可以把下面的导航按钮改成你最常去的页面。

小结

到这里,这个 Dashboard 的骨架就齐了。它做的事其实不复杂,核心就两步,先用 DataviewJS 从你的 Vault 里把任务、页面和统计数据捞出来,再用 createDiv 一层层拼成卡片布局。真正让它看起来像成品的,是统一的数据源命名和后面的 CSS 样式。

你完全可以先照着这篇把它跑起来,再慢慢改成自己的版本。最先推荐改的只有 4 个地方,Header 里的名字、任务来源目录、项目目录、快捷入口路径。等这些都顺手了,再去调样式会舒服很多。样式部分由 dashboard.css 控制,下一篇我会把毛玻璃、网格、卡片动画和细节状态全部拆开讲。

完整代码

下面这段就是 Dashboard.md 的完整内容,直接复制过去就能用。

---
cssclasses:
  - dashboard
  - max
obsidianUIMode: preview
---

```dataviewjs
try {
  const today = dv.date("today");
  const todayStr = today.toFormat("yyyy-MM-dd");

  function getGreeting() {
    const h = new Date().getHours();
    if (h < 6) return "Deep Night";
    if (h < 12) return "Good Morning";
    if (h < 14) return "Good Afternoon";
    if (h < 18) return "Good Evening";
    return "Good Night";
  }

  function parseDue(text) {
    const m = text.match(/📅\s*(\d{4}-\d{2}-\d{2})/u);
    return m ? dv.date(m[1]) : null;
  }

  function getPri(text) {
    if (text.includes("⏫")) return 0;
    if (text.includes("🔼")) return 1;
    if (text.includes("🔽")) return 3;
    return 2;
  }

  function cleanText(text) {
    return text
      .replace(/📅\s*\d{4}-\d{2}-\d{2}/gu, "")
      .replace(/[⏫🔼🔽]/gu, "")
      .trim();
  }

  function renderTask(parent, t) {
    const row = parent.createDiv({ cls: "bed-task-row" });
    row.createDiv({ cls: "bed-task-check" });
    const td = row.createDiv({ cls: "bed-task-text" });
    td.createEl("a", { text: cleanText(t.text), cls: "internal-link", attr: { "data-href": t.path } });
    const pri = getPri(t.text);
    if (pri !== 2) {
      const cls = "bed-task-tag " + (pri === 0 ? "tag-high" : pri === 1 ? "tag-med" : "tag-low");
      row.createDiv({ cls: cls, text: pri === 0 ? "HIGH" : pri === 1 ? "MED" : "LOW" });
    }
    const due = parseDue(t.text);
    if (due) row.createDiv({ cls: "bed-task-date", text: due.toFormat("MM/dd") });
  }

  function makeCard(parent, title, span) {
    const card = parent.createDiv({ cls: "bed-card " + span });
    card.createDiv({ cls: "bed-card-title", text: title });
    return card.createDiv({ cls: "bed-card-body" });
  }

  const pages = dv.pages('"40-todo" or "30-projects" or "50-daily"');
  const allTasks = pages.file.tasks;
  const weekEnd = today.plus({ days: 7 });
  const monthEnd = today.plus({ days: 30 });

  const container = dv.container.createDiv({ cls: "bed-dashboard" });

  // Header
  const header = container.createDiv({ cls: "bed-header" });
  header.createEl("h1", { text: getGreeting() + ", Bed" });
  header.createDiv({ cls: "bed-date", text: today.toFormat("EEEE, MMMM d, yyyy") });

  const allPages = dv.pages("");
  const sTotal = allPages.length;
  const sBlog = dv.pages('"10-blog"').length;
  const sWiki = dv.pages('"20-wiki"').length;
  const sProj = dv.pages('"30-projects"').length;
  const sDaily = dv.pages('"50-daily"').length;

  const statRow = header.createDiv({ cls: "bed-stat-row" });
  const statData = [
    ["\u{1F4DD}", String(sTotal), "Notes"],
    ["\u{1F4F0}", String(sBlog), "Blog"],
    ["\u{1F4DA}", String(sWiki), "Wiki"],
    ["\u{1F680}", String(sProj), "Projects"],
    ["\u{1F4C5}", String(sDaily), "Daily"],
  ];
  for (const sd of statData) {
    const cube = statRow.createDiv({ cls: "bed-stat-cube" });
    cube.createDiv({ cls: "bed-stat-icon", text: sd[0] });
    cube.createDiv({ cls: "bed-stat-value", text: sd[1] });
    cube.createDiv({ cls: "bed-stat-label", text: sd[2] });
  }

  // Grid
  const grid = container.createDiv({ cls: "bed-grid" });

  // Today Tasks
  const todayBody = makeCard(grid, "✅ TODAY", "span-4");
  const todayTasks = allTasks
    .where(t => {
      if (t.completed) return false;
      const due = parseDue(t.text);
      return due && due.ts <= today.ts;
    })
    .sort(t => getPri(t.text));
  if (todayTasks.length > 0) {
    for (const t of todayTasks) renderTask(todayBody, t);
  } else {
    todayBody.createDiv({ cls: "bed-empty", text: "All clear ✨" });
  }

  // Week Tasks
  const weekBody = makeCard(grid, "📋 THIS WEEK", "span-4");
  const weekTasks = allTasks
    .where(t => {
      if (t.completed) return false;
      const due = parseDue(t.text);
      return due && due.ts > today.ts && due.ts <= weekEnd.ts;
    })
    .sort(t => getPri(t.text));
  if (weekTasks.length > 0) {
    for (const t of weekTasks) renderTask(weekBody, t);
  } else {
    weekBody.createDiv({ cls: "bed-empty", text: "Week is clear ✨" });
  }

  // Month Tasks
  const monthBody = makeCard(grid, "📆 THIS MONTH", "span-4");
  const monthTasks = allTasks
    .where(t => {
      if (t.completed) return false;
      const due = parseDue(t.text);
      return due && due.ts > weekEnd.ts && due.ts <= monthEnd.ts;
    })
    .sort(t => getPri(t.text));
  if (monthTasks.length > 0) {
    for (const t of monthTasks) renderTask(monthBody, t);
  } else {
    monthBody.createDiv({ cls: "bed-empty", text: "Month is clear 🌟" });
  }

  // Inbox
  const inboxBody = makeCard(grid, "📥 INBOX", "span-4");
  const inboxPage = dv.page("40-todo/inbox");
  if (inboxPage) {
    const inboxTasks = inboxPage.file.tasks.where(t => !t.completed);
    if (inboxTasks.length > 0) {
      for (const t of inboxTasks) renderTask(inboxBody, t);
    } else {
      inboxBody.createDiv({ cls: "bed-empty", text: "Inbox empty 🧹" });
    }
  } else {
    inboxBody.createDiv({ cls: "bed-empty", text: "Create 40-todo/inbox.md" });
  }

  // Projects
  const projBody = makeCard(grid, "🚀 PROJECTS", "span-8");
  const projPages = dv.pages('"30-projects"').where(p => p.file.tasks.length > 0);
  if (projPages.length > 0) {
    for (const proj of projPages) {
      const total = proj.file.tasks.length;
      const done = proj.file.tasks.where(t => t.completed).length;
      const pct = total > 0 ? Math.round(done / total * 100) : 0;
      const projRow = projBody.createDiv({ cls: "bed-project-row" });
      const nameRow = projRow.createDiv({ cls: "bed-project-name" });
      nameRow.createEl("a", { text: (proj.title || proj.file.name), cls: "internal-link", attr: { "data-href": proj.file.path } });
      nameRow.createSpan({ cls: "bed-project-pct", text: pct + "%" });
      const track = projRow.createDiv({ cls: "bed-progress-track" });
      const fill = track.createDiv({ cls: "bed-progress-fill" });
      fill.style.width = pct + "%";
    }
  }
  const projTodo = dv.page("40-todo/projects");
  if (projTodo) {
    const rem = projTodo.file.tasks.where(t => !t.completed);
    if (rem.length > 0) {
      for (const t of rem) renderTask(projBody, t);
    }
  }
  if (projPages.length === 0 && (!projTodo || projTodo.file.tasks.where(t => !t.completed).length === 0)) {
    projBody.createDiv({ cls: "bed-empty", text: "No active projects" });
  }

  // Heatmap
  const heatCard = grid.createDiv({ cls: "bed-card span-12" });
  heatCard.createDiv({ cls: "bed-card-title", text: "🔥 ACTIVITY" });
  if (typeof renderHeatmapCalendar === "function") {
    const year = today.year;
    const aNotes = dv.pages("").where(p => p.file.cday && p.file.cday.year === year);
    const dateMap = {};
    for (const n of aNotes) {
      const d = n.file.cday.toFormat("yyyy-MM-dd");
      dateMap[d] = (dateMap[d] || 0) + 1;
    }
    const entries = Object.entries(dateMap).map(([date, count]) => ({
      date, intensity: Math.min(count, 5), content: ""
    }));
    renderHeatmapCalendar(heatCard, {
      year: year,
      colors: { green: ["#ebedf0","#9be9a8","#40c463","#30a14e","#216e39"] },
      entries: entries,
    });
  } else {
    heatCard.createDiv({ cls: "bed-empty", text: "Enable Heatmap Calendar plugin" });
  }

  // Statistics
  const statsBody = makeCard(grid, "📊 STATISTICS", "span-5");
  const aT = dv.pages("").file.tasks;
  const totalT = aT.length;
  const doneT = aT.where(t => t.completed).length;
  const openT = totalT - doneT;
  const compPct = totalT > 0 ? Math.round(doneT / totalT * 100) : 0;
  const compDiv = statsBody.createDiv({ cls: "bed-completion" });
  compDiv.createDiv({ cls: "bed-completion-pct", text: compPct + "%" });
  compDiv.createDiv({ cls: "bed-completion-label", text: "Tasks Completed" });
  const sGrid = statsBody.createDiv({ cls: "bed-stats-grid" });
  [String(doneT),"Done"],[String(openT),"Open"],[String(totalT),"Total"],[String(sTotal),"Notes"](/wiki/String(doneT),"Done"],[String(openT),"Open"],[String(totalT),"Total"],[String(sTotal),"Notes").forEach(([v,l]) => {
    const item = sGrid.createDiv({ cls: "bed-stats-item" });
    item.createDiv({ cls: "bed-stats-item-value", text: v });
    item.createDiv({ cls: "bed-stats-item-label", text: l });
  });

  // Recent Edits
  const recentBody = makeCard(grid, "📝 RECENT", "span-7");
  const recentP = dv.pages('-"_templates" and -"_config" and -"_private" and -".obsidian"')
    .sort(p => p.file.mtime, "desc").limit(10);
  for (const p of recentP) {
    const row = recentBody.createDiv({ cls: "bed-recent-row" });
    const nd = row.createDiv({ cls: "bed-recent-name" });
    nd.createEl("a", { text: p.file.name, cls: "internal-link", attr: { "data-href": p.file.path } });
    row.createDiv({ cls: "bed-recent-folder", text: p.file.folder || "/" });
    row.createDiv({ cls: "bed-recent-time", text: p.file.mtime.toFormat("MM-dd HH:mm") });
  }

  // Quick Links
  const linksCard = grid.createDiv({ cls: "bed-card span-12" });
  linksCard.createDiv({ cls: "bed-card-title", text: "🏷️ QUICK LINKS" });
  const linksDiv = linksCard.createDiv({ cls: "bed-links" });
  const createData = [
    ["📅 New Daily", "50-daily/" + today.toFormat("yyyy") + "/" + today.toFormat("MM") + "/" + todayStr],
    ["✍️ Write Blog", "10-blog/drafts/新博客-" + todayStr],
    ["📝 New Wiki", "20-wiki/drafts/新笔记-" + todayStr],
    ["🚀 New Project", "30-projects/drafts/新项目-" + todayStr],
  ];
  for (const cd of createData) {
    const btn = linksDiv.createDiv({ cls: "bed-link-btn bed-link-daily" });
    btn.textContent = cd[0];
    btn.addEventListener("click", () => { app.workspace.openLinkText(cd[1], ""); });
  }
  const navData = [
    ["📥 Inbox","40-todo/inbox"],
    ["📌 Projects","40-todo/projects"],
    ["🔧 IC Design","20-wiki/MOCs/IC设计"],
    ["🛠️ Tools","20-wiki/MOCs/工具"],
  ];
  for (const nd of navData) {
    const btn = linksDiv.createDiv({ cls: "bed-link-btn" });
    btn.createEl("a", { text: nd[0], cls: "internal-link", attr: { "data-href": nd[1] } });
  }

  // Footer
  container.createDiv({ cls: "bed-footer", text: "Bed Dashboard · Powered by Dataview" });

} catch (e) {
  dv.paragraph("⚠️ Dashboard Error: " + e.message);
}
```
目录

Comments (0)

No comments yet. Be the first!