为了更好地管理个人的观影记录,利用 DataviewJS 搭建了一个可视化的海报墙。相比于简单的文字列表,这个界面不仅更加直观,还具备了类似 App 的交互查询功能。
最终效果展示

摒弃了传统的表格形式,采用网格卡片布局来展示每一部电影。能够清晰展示以下核心信息:
- 视觉封面:自动加载高清电影海报。
- 双重评分:同时展示“豆瓣评分”(大众参考)和“个人推荐”(私人喜好)。
- 剧情与评价:简介和个人短评通过独立的滚动框展示,既保证了内容完整,又维持了界面的整洁统一。
为了方便在庞大的片单中快速找到想看的内容,在顶部设计了四行动态工具栏:
- 排序功能:支持按“添加时间”、“上映年份”或“评分高低”一键排序。
- 状态筛选:快速切换“已阅”库存和“想看”清单。
- 分类检索:自动统计库中的热门标签(如科幻、悬疑),点击即可筛选对应类型的影片。
- 年代回溯:可以按年份精准查找老片。
准备工作
在开始编写代码之前,我们需要先搭建好基础环境,并统一电影笔记的格式。这一步至关重要,它决定了后续的脚本能否正确读取到数据。
核心插件:Dataview
这是整个影视库的引擎。我们需要通过它来索引笔记并渲染复杂的交互界面。
- 安装:在 Obsidian 社区插件市场搜索
Dataview并安装启用。 - 关键设置:打开 Dataview 设置面板,务必开启
Enable JavaScript Queries选项。

⚠️ 注意:因为我们使用的是 dataviewjs 脚本,如果不开启此选项,代码将无法运行。
建立目录
建议为电影笔记建立一个独立的文件夹,例如 Gallery/Movies。 这样做的好处是方便脚本划定索引范围,避免扫描到无关的日常笔记,提高性能。
标准化元数据(YAML)
为了让脚本能识别电影的封面、评分和状态,每部电影的笔记都需要包含特定的 YAML Frontmatter(即笔记开头的属性区域)。
这是我目前使用的标准模板,可以将其保存为 Obsidian 的模板文件,每次新建电影时直接插入:
cover: "[[attachments/Pasted image 20260125144605.png]]"
title: 死无对证
original_title: The Invisible Witness
year: 2018
director: 斯蒂法诺·摩尔蒂尼
rating: 7.3
recommend: 4
status: 已看
tags:
- 悬疑
- 惊悚
- 犯罪
plot: 阿德里亚诺是一名事业有成的企业家,直到那天他在酒店房间醒来,发现情人劳拉死在浴室里,而自己手握凶器。为了洗脱罪名,他请来了从未失手的金牌律师弗吉尼亚,试图在短短几小时内理清真相……
comment: 剧情复刻了《看不见的客人》,细节处理挺好,节奏紧凑,层层剥茧,最后的反转也非常精彩。《消失的她》应该是有借鉴这部影片。
关键字段说明
cover: 封面文件的存放位置rating: 豆瓣评分,满分 10 分recommend: 个人推荐程度,满分 5 分plot: 电影简介comment: 个人评价
注入灵魂样式(CSS)
有了数据之后,默认的 Dataview 输出还只是普通的列表。为了实现“海报墙”的视觉效果,我们需要通过 CSS 对页面元素进行重构。
这一步将决定影视库的“颜值”。
启用 CSS 片段
Obsidian 提供了非常方便的 CSS Snippets(代码片段)功能,允许我们在不修改主题的情况下自定义样式。

- 操作步骤:
- 进入
设置->外观->CSS 代码片段。 - 点击右侧的文件夹图标,打开 snippets 文件夹。
- 新建一个文本文件,重命名为
media-cards.css。 - 将下方的代码粘贴进去并保存。
- 回到 Obsidian 设置页,点击刷新按钮,并启用
media-cards.css。
- 进入
样式代码
/* --- media-cards.css (上下布局+两端对齐版) --- */
.media-gallery {
display: grid;
/* 保持较宽的卡片宽度 */
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px; margin-top: 20px; padding-bottom: 20px;
}
.media-card {
position: relative;
background-color: var(--background-secondary);
border-radius: 12px;
border: 1px solid var(--background-modifier-border);
transition: all 0.3s ease;
display: flex; flex-direction: column;
overflow: hidden;
text-decoration: none !important; color: var(--text-normal) !important;
}
.media-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
border-color: var(--interactive-accent);
}
/* 封面区域 */
.media-card-cover {
width: 100%; aspect-ratio: 2 / 3;
background-size: cover; background-position: center top;
position: relative; border-bottom: 1px solid var(--background-modifier-border);
}
.media-rank {
position: absolute; top: 8px; left: 8px;
background: rgba(0, 0, 0, 0.65); color: rgba(255, 255, 255, 0.9);
padding: 2px 8px; border-radius: 6px; font-size: 0.75em; font-weight: 600;
backdrop-filter: blur(4px);
}
.media-score-badge {
position: absolute; bottom: 8px; right: 8px;
background: var(--interactive-accent); color: var(--text-on-accent);
padding: 2px 6px; border-radius: 4px; font-weight: 800; font-size: 0.9em;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
/* 编辑按钮 */
.media-edit-btn {
position: absolute; top: 8px; right: 8px; opacity: 0;
transition: opacity 0.2s ease; background: rgba(var(--mono-rgb-100), 0.8);
padding: 4px; border-radius: 50%; cursor: pointer; line-height: 1;
}
.media-card:hover .media-edit-btn { opacity: 1; }
/* 内容区域 */
.media-card-content {
padding: 14px;
flex-grow: 1; display: flex; flex-direction: column;
gap: 10px; /* 增加各模块之间的间距 */
}
/* 标题部分 */
.media-title-block {
margin-bottom: 2px;
}
.media-title-cn { font-size: 1.1em; font-weight: 700; line-height: 1.3; color: var(--text-normal); margin-bottom: 2px; }
.media-title-en {
font-size: 0.8em; color: var(--text-muted); line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* --- 1. 评分行 (两端对齐) --- */
.media-rating-row {
display: flex;
justify-content: space-between; /* 关键:左边文字,右边星星 */
align-items: center;
font-size: 0.9em;
height: 24px;
}
.media-label-rating {
color: var(--text-muted);
font-weight: bold;
}
/* 星星/红心容器 */
.media-stars-container {
/* 强制使用 Emoji 字体,确保对齐 */
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
letter-spacing: 2px;
}
/* --- 2. 文本块 (上下布局) --- */
.media-text-block {
display: flex;
flex-direction: column; /* 关键:垂直排列 */
gap: 4px;
}
.media-label-text {
font-size: 0.85em;
font-weight: bold;
color: var(--text-muted);
}
.media-text-content {
background: var(--background-primary);
padding: 8px;
border-radius: 6px;
font-size: 0.85em;
color: var(--text-faint);
line-height: 1.5;
/* 高度控制 */
height: 64px;
overflow-y: auto;
word-wrap: break-word;
}
/* 滚动条 */
.media-text-content::-webkit-scrollbar { width: 3px; }
.media-text-content::-webkit-scrollbar-thumb {
background-color: var(--text-muted); border-radius: 3px; opacity: 0.3;
}
/* 底部标签 */
.media-tags { margin-top: auto; display: flex; gap: 6px; flex-wrap: wrap; }
.media-tag {
background: var(--background-modifier-hover); padding: 1px 6px;
border-radius: 4px; font-size: 0.7em; color: var(--text-muted);
}
.media-meta-row {
display: flex; justify-content: space-between; align-items: center;
border-top: 1px solid var(--background-modifier-border); padding-top: 8px;
font-size: 0.75em; color: var(--text-muted);
}
.media-meta-item {
display: flex; align-items: center; gap: 4px;
max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* --- 交互式工具栏样式 --- */
/* 工具栏容器 */
.media-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
padding: 8px 12px;
background: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
align-items: center;
}
/* 按钮组标签 (例如 "排序: ") */
.media-toolbar-label {
font-size: 0.85em;
font-weight: bold;
color: var(--text-muted);
margin-right: 4px;
}
/* 按钮本体 */
.media-btn {
background: transparent;
border: 1px solid var(--background-modifier-border);
color: var(--text-muted);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8em;
cursor: pointer;
transition: all 0.2s ease;
/* 【新增】强制使用 Emoji 字体,解决按钮上红心变黑的问题 */
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.media-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
/* 激活状态的按钮 (高亮) */
.media-btn.active {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
font-weight: bold;
}
当然这部分代码可以按照个人的喜好进行修改,如果不擅长代码,可以交给现在的大模型来修改,只需提出自己的要求。
构建交互引擎(DataviewJS)
在你的“影音库”笔记中(或者任意你想展示海报墙的笔记里),先添加属性,表明要使用前面启用的 css
cssclasses:
- media-cards.css
然后在添加 Dataview 代码块,并在代码块中粘贴以下代码

// ================= ⚙️ 配置区域 =================
const sourceFolder = '"Gallery/Movies"';
// ===============================================
// 1. 定义全局状态
let state = {
sortKey: 'year', // 排序依据
sortOrder: 'desc', // 排序方向
filterTag: 'all', // 标签筛选
filterYear: 'all', // 年份筛选
filterStatus: 'all' // 【新增】状态筛选 (all | watched | todo)
};
const root = dv.el("div", "", { cls: "media-app-container" });
// --- 图片处理 ---
function getImageUrl(imagePath) {
if (!imagePath) return "";
if (imagePath.path) {
const file = app.metadataCache.getFirstLinkpathDest(imagePath.path, "");
return file ? app.vault.getResourcePath(file) : "";
}
let str = String(imagePath);
if (str.startsWith('![')) {
let urlMatch = str.match(/\((https?:\/\/.*?)\)/);
if (urlMatch) str = urlMatch[1];
else {
let linkMatch = str.match(/\[\[(.*?)(?:\|.*)?\]\]/);
if (linkMatch) str = linkMatch[1];
}
}
if (str.startsWith('http')) {
return `https://images.weserv.nl/?url=${encodeURIComponent(str)}`;
}
let cleanName = str.replace(/[\[\]"]/g, '');
const file = app.metadataCache.getFirstLinkpathDest(cleanName, "");
return file ? app.vault.getResourcePath(file) : "";
}
function getHearts(index) {
if (!index) return "";
let score = Math.min(Math.max(index, 0), 5);
const emojiStyle = "font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;";
return `<span style="letter-spacing:2px; font-size:0.9em; ${emojiStyle}">` + "❤️".repeat(score) + "🤍".repeat(5 - score) + `</span>`;
}
// 统计所有标签
function getTopTags(pages) {
let tagMap = new Map();
pages.forEach(p => {
if (!p.tags) return;
let tags = typeof p.tags === 'string' ? [p.tags] : p.tags;
tags.forEach(t => {
if (t === 'Movie' || t === '电影' || t.includes('Media')) return;
tagMap.set(t, (tagMap.get(t) || 0) + 1);
});
});
return Array.from(tagMap.entries())
.sort((a, b) => b[1] - a[1])
// .slice(0, 8)
.map(entry => entry[0]);
}
// 提取所有存在的年份
function getYears(pages) {
let years = new Set();
pages.forEach(p => {
if (p.year) years.add(p.year);
});
return Array.from(years).sort((a, b) => b - a);
}
// --- 渲染引擎 ---
function render() {
root.innerHTML = "";
// 1. 获取原始数据
let allPages = dv.pages(sourceFolder)
.filter(p => !p.file.name.includes("Template") && p.file.name !== "未命名");
const topTags = getTopTags(allPages);
const allYears = getYears(allPages);
// 2. 执行多重筛选 (Status + Tag + Year)
let displayPages = allPages;
// A. 【新增】状态筛选
if (state.filterStatus === 'watched') {
displayPages = displayPages.filter(p => {
const s = (p.status || p.状态 || "").toLowerCase();
return s.includes('watched') || s.includes('已阅') || s.includes('已看');
});
} else if (state.filterStatus === 'todo') {
displayPages = displayPages.filter(p => {
const s = (p.status || p.状态 || "").toLowerCase();
return s.includes('wish') || s.includes('todo') || s.includes('想看');
});
}
// B. 标签筛选
if (state.filterTag !== 'all') {
displayPages = displayPages.filter(p => {
if (!p.tags) return false;
let tags = typeof p.tags === 'string' ? [p.tags] : p.tags;
return tags.includes(state.filterTag);
});
}
// C. 年份筛选
if (state.filterYear !== 'all') {
displayPages = displayPages.filter(p => p.year == state.filterYear);
}
// 3. 执行排序
if (state.sortKey === 'year') {
displayPages = displayPages.sort(p => p.year, state.sortOrder);
} else if (state.sortKey === 'rating') {
displayPages = displayPages.sort(p => p.rating, state.sortOrder);
} else if (state.sortKey === 'recommend') {
displayPages = displayPages.sort(p => (p.recommend || p["推荐指数"] || 0), state.sortOrder);
} else if (state.sortKey === 'date') {
displayPages = displayPages.sort(p => p.file.ctime, state.sortOrder);
} else {
displayPages = displayPages.sort(p => p.file.name, state.sortOrder);
}
// 4. 绘制工具栏
const toolbar = document.createElement("div");
toolbar.className = "media-toolbar";
toolbar.style.flexDirection = "column";
toolbar.style.alignItems = "flex-start";
// --- 第一行:排序控制 ---
const rowSort = createToolbarRow("排序:");
createBtn(rowSort, "🕒 添加时间", () => { state.sortKey = 'date'; render(); }, state.sortKey === 'date');
createBtn(rowSort, "📅 上映年份", () => { state.sortKey = 'year'; render(); }, state.sortKey === 'year');
createBtn(rowSort, "⭐ 评分", () => { state.sortKey = 'rating'; render(); }, state.sortKey === 'rating');
createBtn(rowSort, "❤️ 推荐", () => { state.sortKey = 'recommend'; render(); }, state.sortKey === 'recommend');
const spacer = document.createElement("span"); spacer.style.flexGrow = "1"; rowSort.appendChild(spacer);
const orderText = state.sortOrder === 'desc' ? "⬇️ 降序" : "⬆️ 升序";
createBtn(rowSort, orderText, () => { state.sortOrder = state.sortOrder === 'desc' ? 'asc' : 'desc'; render(); }, false);
toolbar.appendChild(rowSort);
// --- 第二行:状态筛选 【新增】 ---
const rowStatus = createToolbarRow("状态:");
createBtn(rowStatus, "全部", () => { state.filterStatus = 'all'; render(); }, state.filterStatus === 'all');
createBtn(rowStatus, "✅ 已看", () => { state.filterStatus = 'watched'; render(); }, state.filterStatus === 'watched');
createBtn(rowStatus, "📝 想看", () => { state.filterStatus = 'todo'; render(); }, state.filterStatus === 'todo');
toolbar.appendChild(rowStatus);
// --- 第三行:标签筛选 ---
const rowTag = createToolbarRow("类型:");
createBtn(rowTag, "全部", () => { state.filterTag = 'all'; render(); }, state.filterTag === 'all');
topTags.forEach(tag => {
createBtn(rowTag, tag, () => { state.filterTag = tag; render(); }, state.filterTag === tag);
});
toolbar.appendChild(rowTag);
// --- 第四行:年份筛选 ---
const rowYear = createToolbarRow("年份:");
createBtn(rowYear, "全部", () => { state.filterYear = 'all'; render(); }, state.filterYear === 'all');
allYears.forEach(y => {
createBtn(rowYear, String(y), () => { state.filterYear = y; render(); }, state.filterYear == y);
});
toolbar.appendChild(rowYear);
root.appendChild(toolbar);
// 5. 绘制卡片
if (displayPages.length === 0) {
root.createEl("p", { text: "⚠️ 没有找到符合条件的电影", style: "color:var(--text-muted); padding:20px;" });
return;
}
const container = document.createElement("div");
container.className = "media-gallery";
displayPages.forEach((page) => {
let coverUrl = getImageUrl(page.cover || page["封面"]);
let title = page.title || page["中文名"] || page.file.name;
let subTitle = page.original_title || page["英文名"] || "";
let rating = page.rating || page["评分"] || 0;
let year = page.year || page["年份"] || "-";
let director = page.director || page["导演"] || "未知";
let status = page.status || page["状态"] || "已阅";
let plot = page.plot || page["剧情"] || page["剧情简介"];
let comment = page.comment || page["评价"] || page["个人评价"];
let recommendIndex = page.recommend || page["推荐指数"] || 0;
let displayTags = page.tags || [];
if (typeof displayTags === 'string') displayTags = [displayTags];
const tagsHtml = displayTags
.filter(t => !t.includes("Movie") && !t.includes("电影"))
.slice(0, 3)
.map(t => `<span class="media-tag">${t}</span>`)
.join('');
const card = document.createElement("div");
card.className = "media-card";
card.innerHTML = `
<div class="media-card-cover" style="background-image: url('${coverUrl}');">
<div class="media-rank">${year}</div>
<div class="media-score-badge">${rating}</div>
<div class="media-edit-btn" title="打开笔记">✏️</div>
</div>
<div class="media-card-content">
<div class="media-title-block">
<div class="media-title-cn">${title}</div>
<div class="media-title-en">${subTitle}</div>
</div>
${recommendIndex > 0 ? `
<div class="media-rating-row">
<span class="media-label-rating">推荐指数</span>
<span class="media-stars-container">${getHearts(recommendIndex)}</span>
</div>` : ''}
${plot ? `
<div class="media-text-block">
<div class="media-label-text">剧情简介</div>
<div class="media-text-content">${plot}</div>
</div>` : ''}
${comment ? `
<div class="media-text-block">
<div class="media-label-text">个人评价</div>
<div class="media-text-content">${comment}</div>
</div>` : ''}
<div class="media-tags">${tagsHtml}</div>
<div class="media-meta-row">
<div class="media-meta-item" title="导演">🎬 ${director}</div>
<div class="media-meta-item" title="状态">🏷️ ${status}</div>
</div>
</div>
`;
card.onclick = (e) => {
e.preventDefault();
app.workspace.openLinkText(page.file.path, "", false);
};
container.appendChild(card);
});
root.appendChild(container);
}
// 辅助:创建工具栏的一行
function createToolbarRow(labelText) {
const row = document.createElement("div");
row.style.display = "flex";
row.style.gap = "8px";
row.style.width = "100%";
row.style.marginBottom = "8px";
row.style.borderBottom = "1px solid var(--background-modifier-border)";
row.style.paddingBottom = "8px";
row.style.flexWrap = "wrap";
const label = document.createElement("span");
label.className = "media-toolbar-label";
label.innerText = labelText;
row.appendChild(label);
return row;
}
function createBtn(parent, text, onClick, isActive) {
const btn = document.createElement("button");
btn.className = "media-btn" + (isActive ? " active" : "");
btn.innerText = text;
btn.onclick = onClick;
parent.appendChild(btn);
}
render();
请务必修改代码第一行的 sourceFolder,将其替换为你存放电影笔记的实际文件夹路径(例如 "MyData/Movies")。
如果一切正常,当你退出代码编辑模式,切换到预览模式(Reading View)时,你应该能看到:
- 顶部出现了四行工具栏(排序、状态、类型、年份)。
- 下方的卡片整齐排列,海报图片已经加载出来。
- 点击顶部的按钮,下方的卡片会即时发生变化。
✨ 至此,你的个人交互式影视库就已经搭建完成了!