Obsidian 进阶:手把手教你搭建“交互式”电影海报墙

为了更好地管理个人的观影记录,利用 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(代码片段)功能,允许我们在不修改主题的情况下自定义样式。

  • 操作步骤
    1. 进入 设置 -> 外观 -> CSS 代码片段
    2. 点击右侧的文件夹图标,打开 snippets 文件夹。
    3. 新建一个文本文件,重命名为 media-cards.css
    4. 将下方的代码粘贴进去并保存。
    5. 回到 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)时,你应该能看到:

  1. 顶部出现了四行工具栏(排序、状态、类型、年份)。
  2. 下方的卡片整齐排列,海报图片已经加载出来。
  3. 点击顶部的按钮,下方的卡片会即时发生变化。

✨ 至此,你的个人交互式影视库就已经搭建完成了!

暂无评论

发送评论 编辑评论


|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
B站基础表情
B站节日表情
B站游戏表情
B站活动表情
上一篇