Obsidian的配置与美化——Dashboard首页看板
这一篇开始进入整套配置里最像“首页”的部分。前一篇你已经把插件装好了,这一篇就直接把 Dashboard 搭起来。目标很简单,打开 Vault 的第一眼,不再是文件列表,而是一个能看任务、看统计、看最近编辑、顺手新建笔记的工作台。整套设计思路参考 Zen Dashboard,不过内容组织更贴近我自己的目录结构。
效果预览
这个 Dashboard 的核心不是炫技,而是把常用信息压到一屏里。页面整体是 Glassmorphism 毛玻璃风格,卡片之间留白比较大,看起来轻一点。布局用的是 12 列网格,所以任务、项目、统计、热力图这些区块可以拼成比较稳定的桌面式排布。页面顶部是一句随时间变化的问候语,再加上当天日期和 5 个统计方块。中间区域是任务面板、项目进度和活动热力图,下面还有最近编辑和快捷入口。鼠标放到卡片上时会有轻微动画,整个首页会更像一个控制台,而不是一张普通笔记。样式部分都交给 dashboard.css,下一篇我再单独拆开讲。
前置依赖
这篇默认你已经装好了 3 个插件,Dataview、Homepage、Heatmap Calendar。
Dataview负责查页面、读任务、生成卡片内容Homepage负责把Dashboard.md设成启动首页Heatmap Calendar负责渲染活动热力图
如果你已经跟着前一篇配完插件,这里基本可以直接开始。顺带一提,这套 Dashboard 的设计参考了 Zen Dashboard,但脚本和目录源是按这套 Vault 结构写的,所以你后面会看到不少像 10-blog、30-projects、40-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 输出区创建一个divcreateDiv()和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-blog、20-wiki、30-projects、50-daily。统计值来自 dv.pages() 的数量,所以你的目录名如果不同,记得一起改掉。
3. 任务面板
任务区是首页里最实用的一块,一共 4 张卡片,Today、This Week、This Month、Inbox。前三张是从 40-todo、30-projects、50-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!