Dataview 月记/年记统计模板分享

分享之前 vibe coding 出来的几个 dataview 任务管理的代码

  1. 需要配合task,和一个模板管理插件
  2. 可以统计不同时间段内的任务(太多会显得臃肿,没加折叠选项)
  3. 只放置了月度记录和年度记录,可以自己扩展到其他时间段
  4. 时间会强制读取YAML的时间,便于后期调整修改对应文件的显示日期(可以帮助偶尔忘记在规定时间内写完的人)
  5. 觉得还是原始的界面好用,不折腾了,犯不着整复杂的工作流,留点资料一共参考
  6. 可以实现对应任务所在文件的跳转,截止日期,重要程度,计划日期,剩余日期,是否提前完成等的展示
  7. 基本内容:未完成,已完成,已取消(不确定还有没有,按理是有的)三个界面
  8. 以上均属小白观点,仅供参考,如有不同意见,欢迎交流

周记截图,周记,月记都差不多是相同的界面

年度记录稍有不同

还有一个已取消任务的选项,需要配合其他的CSS使用,请自行摸索,这里就不截图了

// --- 0. 用户配置区域:完全接管状态判定 ---
const USER_CONFIG = {
    unfinishedStatuses: [' ', '/'],  // 算作“未完成”的状态 (红榜)
    completedStatuses: ['x', 'X'],   // 算作“已完成”的状态 (绿榜)
    canceledStatuses: ['-']          // 算作“已作废/取消”的状态 (灰榜)
};

// --- 1. 样式注入 ---
const styleId = "monthly-task-style-v4";
if (!document.getElementById(styleId)) {
    const style = document.createElement('style');
    style.id = styleId;
    style.innerHTML = `
        .m-container { display: flex; flex-direction: column; gap: 20px; font-family: var(--font-interface); margin-bottom: 20px; }
        .m-header-stats { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; align-items: center; }
        .m-stat-pill { padding: 6px 16px; background: var(--background-primary-alt); border-radius: 20px; font-size: 0.82em; color: var(--text-muted); border: 1px solid var(--background-modifier-border); box-shadow: var(--shadow-s); }
        .m-section { border: 1.5px solid #ccc; border-radius: 16px; padding: 24px; box-shadow: var(--shadow-s); transition: all 0.3s ease;}
        .m-section h4 { margin: 0 0 18px 0; display: flex; align-items: center; gap: 10px; font-size: 1.1em; font-weight: 600; }
        .m-badge { padding: 2px 10px; border-radius: 12px; font-size: 0.7em; font-weight: bold; color: white; }
        
        .m-card { background: var(--background-primary); border-radius: 10px; padding: 14px 18px; margin-bottom: 12px; display: flex; align-items: flex-start; gap: 12px; border-left: 4px solid #ccc; border-right: 1px solid var(--background-modifier-border); border-top: 1px solid var(--background-modifier-border); border-bottom: 1px solid var(--background-modifier-border); transition: transform 0.2s ease, box-shadow 0.2s ease;}
        .m-card-content { flex: 1; min-width: 0; }
        .m-card-text { margin-bottom: 8px; word-break: break-word; line-height: 1.6; font-size: 0.98em; color: var(--text-normal); }
        .m-card-meta { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; font-size: 0.82em; color: var(--text-muted); }
        .m-card-meta a { color: inherit !important; text-decoration: none !important; }
        .m-card-meta a:hover { color: var(--text-accent) !important; }
        .m-priority { font-size: 1.2em; line-height: 1; }
        .m-footer { text-align: center; padding: 16px; background: var(--background-primary-alt); border-radius: 12px; font-size: 0.88em; border: 1px dashed var(--background-modifier-border); color: var(--text-muted); }
    `;
    document.head.appendChild(style);
}

// --- 2. 基础配置与日期范围 ---
const currentPage = dv.current();
const mStart = window.moment(currentPage.file.name.match(/\d{4}-\d{2}/)?.[0] || currentPage.file.ctime.ts).startOf('month');
const startTs = mStart.valueOf();
const endTs = mStart.clone().endOf('month').valueOf();

// --- 3. 数据采集与处理 ---
// 增强正则:兼容 [x] [ ] [/] [-] 等符号
const CLEAN_REGEX = /^\s*[-*]?\s*\[[xX\s\-\/]\]\s*|([📅⏳🛫🔁🆔⛔✅➕❌🏁⏫🔼🔽🔺].*)/gi;

const unfinished = [];
const completed = [];
const canceled =[];
const seen = new Set();

dv.pages('"Thino"').file.tasks
    .where(t => {
        const d = t.due?.ts, c = t.completion?.ts, cr = t.created?.ts, s = t.scheduled?.ts;
        return (d >= startTs && d <= endTs) || (c >= startTs && c <= endTs) || (cr >= startTs && cr <= endTs) || (s >= startTs && s <= endTs);
    })
    .forEach(t => {
        // [核心修改] 弃用 t.completed,根据白名单确定分类
        let taskCategory = '';
        if (USER_CONFIG.unfinishedStatuses.includes(t.status)) taskCategory = 'unfinished';
        else if (USER_CONFIG.completedStatuses.includes(t.status)) taskCategory = 'completed';
        else if (USER_CONFIG.canceledStatuses.includes(t.status)) taskCategory = 'canceled';
        
        if (!taskCategory) return; // 如果是不认识的符号,直接抛弃不显示

        let priority = '';
        if (t.text.includes('⏫')) priority = '⏫';
        else if (t.text.includes('🔼')) priority = '🔼';
        else if (t.text.includes('🔽')) priority = '🔽';
        else if (t.text.includes('🔺')) priority = '🔺';
        
        const content = t.text.replace(CLEAN_REGEX, '').trim();
        if (!content) return;
        
        const uniqueId = content + taskCategory;
        if (seen.has(uniqueId)) return;
        seen.add(uniqueId);

        const dueStr = t.due ? t.due.toISODate() : null;
        const startStr = t.start ? t.start.toISODate() : null;
        const scheduledStr = t.scheduled ? t.scheduled.toISODate() : null;

        let statusInfo = '';
        const baseDate = t.due || t.scheduled;
        let cardColor = '';
        let textStyle = '';

        // [核心修改] 样式与计算基于自定义的 taskCategory
        if (taskCategory === 'completed') {
            cardColor = '#51cf66';
            textStyle = 'text-decoration: line-through; opacity: 0.6;';
            if (baseDate && t.completion) {
                const dComp = window.moment(t.completion.ts).startOf('day');
                const dBase = window.moment(baseDate.ts).startOf('day');
                const diff = dComp.diff(dBase, 'days');

                if (diff < 0) statusInfo = `<span style="color:var(--text-success);">✓ 提前 ${Math.abs(diff)} 天完成</span>`;
                else if (diff > 0) statusInfo = `<span style="color:var(--text-error);">⚠ 延迟 ${diff} 天完成</span>`;
                else statusInfo = '<span style="color:var(--text-success);">✓ 当天完成</span>';
            } else {
                statusInfo = '✓ 已完成';
            }
        } 
        else if (taskCategory === 'canceled') {
            cardColor = '#868e96'; // 灰色
            textStyle = 'text-decoration: line-through; opacity: 0.45; color: var(--text-muted);';
            statusInfo = `<span style="color: var(--text-muted);">🚫 已作废/取消</span>`;
        } 
        else { // 未完成
            cardColor = '#ff6b6b';
            textStyle = '';
            if (baseDate) {
                const dNow = window.moment().startOf('day');
                const dBase = window.moment(baseDate.ts).startOf('day');
                const diff = dBase.diff(dNow, 'days');

                if (diff === 0) statusInfo = '<span style="color:var(--text-warning); font-weight:bold;">⏰ 今天截止</span>';
                else if (diff < 0) statusInfo = `<span style="color:var(--text-error); font-weight:bold;">⚠ 逾期 ${Math.abs(diff)} 天</span>`;
                else statusInfo = `⏳ 还有 ${diff} 天`;
            }
        }

        const card = `
            <div class="m-card" style="border-left-color: ${cardColor};">
                <span class="m-priority">${priority}</span>
                <div class="m-card-content">
                    <div class="m-card-text" style="${textStyle}">${content}</div>
                    <div class="m-card-meta">
                        <span><a class="internal-link" href="${t.path}">🔗 ${t.path.split('/').pop().replace('.md', '')}</a></span>
                        ${startStr ? '<span>🛫 ' + startStr + '</span>' : ''}
                        ${scheduledStr ? '<span>⏳ ' + scheduledStr + '</span>' : ''}
                        ${dueStr ? '<span>📅 ' + dueStr + '</span>' : ''}
                        ${statusInfo ? '<span>' + statusInfo + '</span>' : ''}
                    </div>
                </div>
            </div>`;
        
        if (taskCategory === 'completed') completed.push(card);
        else if (taskCategory === 'canceled') canceled.push(card);
        else unfinished.push(card);
    });

// --- 4. 组装结果 ---
const totalTasks = unfinished.length + completed.length + canceled.length;

const finalHTML = `
<div class="m-container">
    <div class="m-header-stats">
        <div class="m-stat-pill">📅 ${mStart.format('YYYY年 M月')}</div>
        <div class="m-stat-pill">📋 本月累计拦截 ${totalTasks} 任务</div>
    </div>
    
    <!-- 未完成区域 -->
    <div class="m-section" style="border-color: rgba(255, 107, 107, 0.3); background: rgba(255, 107, 107, 0.03);">
        <h4 style="color: #ff6b6b;">⏳ 未完成事项 <span class="m-badge" style="background: #ff6b6b;">${unfinished.length}</span></h4>
        ${unfinished.length > 0 ? unfinished.join('') : '<div style="text-align: center; color: var(--text-muted); padding: 20px;">本月任务已全部清空 🎉</div>'}
    </div>

    <!-- 已完成区域 -->
    <div class="m-section" style="border-color: rgba(81, 207, 102, 0.3); background: rgba(81, 207, 102, 0.03);">
        <h4 style="color: #51cf66;">✅ 已完成事项 <span class="m-badge" style="background: #51cf66;">${completed.length}</span></h4>
        ${completed.length > 0 ? completed.join('') : '<div style="text-align: center; color: var(--text-muted); padding: 20px;">尚未完成任务</div>'}
    </div>

    <!-- 已取消区域 (仅当有取消任务时显示) -->
    ${canceled.length > 0 ? `
    <div class="m-section" style="border-color: rgba(134, 142, 150, 0.3); background: rgba(134, 142, 150, 0.03);">
        <h4 style="color: #868e96;">🚫 已作废 / 取消事项 <span class="m-badge" style="background: #868e96;">${canceled.length}</span></h4>
        ${canceled.join('')}
    </div>
    ` : ''}

    <div class="m-footer">
        📊 月度执行总结:完成 ${completed.length} 项 | 剩余 ${unfinished.length} 项 | 作废 ${canceled.length} 项
    </div>
</div>`;

dv.container.innerHTML = finalHTML;

// --- 0. 用户配置区域:完全接管状态判定 ---
const USER_CONFIG = {
    unfinishedStatuses: [' ', '/'],  // 算作“未完成”的状态 (红榜)
    completedStatuses: ['x', 'X'],   // 算作“已完成”的状态 (绿榜)
    canceledStatuses: ['-']          // 算作“已取消”的状态 (灰榜)
};

// --- 1. 样式注入 ---
const styleId = "yearly-task-style-v4";
if (!document.getElementById(styleId)) {
    const style = document.createElement('style');
    style.id = styleId;
    style.innerHTML = `
        .y-container { display: flex; flex-direction: column; gap: 16px; font-family: var(--font-interface); color: var(--text-normal); }
        .y-header-nav { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
        .y-btn-group { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
        .y-btn { flex: 1; border: none; border-radius: 8px; cursor: pointer; font-size: 0.82em; padding: 8px 4px; transition: all 0.2s ease; background: var(--background-primary-alt); color: var(--text-muted); border: 1px solid var(--background-modifier-border); }
        .y-btn:hover { background: var(--background-modifier-hover); }
        .y-btn.active { background: var(--interactive-accent); color: white; border-color: var(--interactive-accent); box-shadow: var(--shadow-s); }
        .y-btn.related { background: var(--background-modifier-border); color: var(--text-normal); }

        .y-section { border: 1.5px solid #ccc; border-radius: 16px; padding: 20px; box-shadow: var(--shadow-s); transition: all 0.3s ease;}
        .y-section h4 { margin: 0 0 16px 0; display: flex; align-items: center; gap: 8px; font-size: 1.05em; font-weight: 600; }
        .y-badge { padding: 2px 10px; border-radius: 10px; font-size: 0.75em; font-weight: bold; color: white; }

        .y-card { background: var(--background-primary); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; display: flex; align-items: flex-start; gap: 12px; border-left: 4px solid #ccc; border-right: 1px solid var(--background-modifier-border); border-top: 1px solid var(--background-modifier-border); border-bottom: 1px solid var(--background-modifier-border); transition: transform 0.2s ease; }
        .y-card-content { flex: 1; min-width: 0; }
        .y-card-text { margin-bottom: 8px; word-break: break-word; line-height: 1.6; font-size: 0.98em; color: var(--text-normal); }
        .y-card-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 0.82em; color: var(--text-muted); }
        .y-card-meta a { color: inherit !important; text-decoration: none !important; }
        .y-card-meta a:hover { color: var(--text-accent) !important; }
        .y-priority { font-size: 1.2em; line-height: 1; flex-shrink: 0; }
        .y-footer { text-align: center; padding: 16px; background: var(--background-primary-alt); border-radius: 12px; font-size: 0.88em; color: var(--text-muted); border: 1px dashed var(--background-modifier-border); }
    `;
    document.head.appendChild(style);
}

// --- 2. 基础配置与日期范围 ---
let yStartMoment;
const yearMatchStr = dv.current().file.name.match(/\d{4}/);
if (yearMatchStr) {
    yStartMoment = window.moment([parseInt(yearMatchStr[0]), 0, 1]);
} else {
    yStartMoment = window.moment(dv.current().file.ctime.ts).startOf('year');
}
const startTs = yStartMoment.valueOf();
const yId = "Y_" + startTs; // 年度唯一ID
const monthKeys = Array.from({length: 12}, (_, i) => yStartMoment.clone().add(i, 'months').format('YYYY-MM'));

// --- 3. 数据采集逻辑 ---
const CLEAN_REGEX = /^\s*[-*]?\s*\[[xX\s\-\/]\]\s*|([📅⏳🛫🔁🆔⛔✅➕❌🏁⏫🔼🔽🔺].*)/gi;

// 初始化年度专属存储
window['yData_' + yId] = { monthGroups: {}, filterMode: 'all', filterValue: 'all' };
monthKeys.forEach(k => window['yData_' + yId].monthGroups[k] = { unfinished: [], completed: [], canceled: [] });

const seen = new Set();
dv.pages('"Thino"').file.tasks
    .where(t => {
        const d = t.due?.ts, c = t.completion?.ts, cr = t.created?.ts, s = t.scheduled?.ts;
        const endTs = yStartMoment.clone().endOf('year').valueOf();
        return (d >= startTs && d <= endTs) || (c >= startTs && c <= endTs) || (cr >= startTs && cr <= endTs) || (s >= startTs && s <= endTs);
    }).forEach(t => {
        // [核心修改] 根据白名单判定分类
        let taskCategory = '';
        if (USER_CONFIG.unfinishedStatuses.includes(t.status)) taskCategory = 'unfinished';
        else if (USER_CONFIG.completedStatuses.includes(t.status)) taskCategory = 'completed';
        else if (USER_CONFIG.canceledStatuses.includes(t.status)) taskCategory = 'canceled';
        
        if (!taskCategory) return;

        let priority = '';
        if (t.text.includes('⏫')) priority = '⏫';
        else if (t.text.includes('🔼')) priority = '🔼';
        else if (t.text.includes('🔽')) priority = '🔽';
        else if (t.text.includes('🔺')) priority = '🔺';

        const content = t.text.replace(CLEAN_REGEX, '').trim();
        if (!content) return;
        
        const uniqueId = content + taskCategory;
        if (seen.has(uniqueId)) return;
        seen.add(uniqueId);

        const dueStr = t.due ? t.due.toISODate() : null;
        const startStr = t.start ? t.start.toISODate() : null;
        const scheduledStr = t.scheduled ? t.scheduled.toISODate() : null;
        
        let statusInfo = '';
        const baseDate = t.due || t.scheduled;
        let cardColor = '';
        let textStyle = '';

        if (taskCategory === 'completed') {
            cardColor = '#51cf66';
            textStyle = 'text-decoration:line-through; opacity:0.6;';
            if (baseDate && t.completion) {
                const d1 = window.moment(t.completion.ts).startOf('day');
                const d2 = window.moment(baseDate.ts).startOf('day');
                const diff = d1.diff(d2, 'days');
                if (diff < 0) statusInfo = `<span style="color:var(--text-success);">✓ 提前 ${Math.abs(diff)}天</span>`;
                else if (diff > 0) statusInfo = `<span style="color:var(--text-error);">⚠ 延迟 ${diff}天</span>`;
                else statusInfo = '<span style="color:var(--text-success);">✓ 当天完成</span>';
            } else { statusInfo = '<span style="color:var(--text-success);">✓ 已完成</span>'; }
        } 
        else if (taskCategory === 'canceled') {
            cardColor = '#868e96';
            textStyle = 'text-decoration:line-through; opacity:0.45; color:var(--text-muted);';
            statusInfo = '<span style="color:var(--text-muted);">🚫 已作废/取消</span>';
        }
        else { // unfinished
            cardColor = '#ff6b6b';
            textStyle = '';
            if (baseDate) {
                const dNow = window.moment().startOf('day');
                const dBase = window.moment(baseDate.ts).startOf('day');
                const diff = dBase.diff(dNow, 'days');
                if (diff === 0) statusInfo = '<span style="color:var(--text-warning); font-weight:bold;">⏰ 今天截止</span>';
                else if (diff < 0) statusInfo = `<span style="color:var(--text-error); font-weight:bold;">⚠ 逾期 ${Math.abs(diff)}天</span>`;
                else statusInfo = `⏳ 还有 ${diff}天`;
            }
        }

        const cardDom = `
            <div class="y-card" style="border-left-color: ${cardColor};">
                <span class="y-priority">${priority}</span>
                <div class="y-card-content">
                    <div class="y-card-text" style="${textStyle}">${content}</div>
                    <div class="y-card-meta">
                        <span><a class="internal-link" href="${t.path}">🔗 ${t.path.split('/').pop().replace('.md', '')}</a></span>
                        ${startStr ? '<span>🛫 ' + startStr + '</span>' : ''}
                        ${scheduledStr ? '<span>⏳ ' + scheduledStr + '</span>' : ''}
                        ${dueStr ? '<span>📅 ' + dueStr + '</span>' : ''} 
                        <span>${statusInfo}</span>
                    </div>
                </div>
            </div>`;

        const refD = t.due || t.completion || t.created;
        if (refD) {
            const mKey = refD.toFormat('yyyy-MM');
            if (window['yData_' + yId].monthGroups[mKey]) {
                window['yData_' + yId].monthGroups[mKey][taskCategory].push(cardDom);
            }
        }
    });

// --- 4. 渲染引擎 ---
const containerId = "y-wrap-" + yId;
dv.el("div", "", { attr: { id: containerId } });

window['renderYearly_' + yId] = function(mode, value) {
    if (mode) { window['yData_' + yId].filterMode = mode; window['yData_' + yId].filterValue = value; }
    const { monthGroups, filterMode, filterValue } = window['yData_' + yId];

    let dispUn = [], dispDone = [], dispCan = [];
    
    // 统计季度数据
    const qStats = { Q1: 0, Q2: 0, Q3: 0, Q4: 0 };
    
    monthKeys.forEach((k, i) => {
        const qOfM = `Q${Math.ceil((i+1)/3)}`;
        // 计算每个季度的已完成总数用于显示在按钮上
        qStats[qOfM] += monthGroups[k].completed.length;

        const isMatch = (filterMode === 'all') || (filterMode === 'quarter' && filterValue === qOfM) || (filterMode === 'month' && filterValue === k);
        if (isMatch) {
            dispUn.push(...monthGroups[k].unfinished);
            dispDone.push(...monthGroups[k].completed);
            dispCan.push(...monthGroups[k].canceled);
        }
    });

    const rowTop = `<div class="y-btn-group">
        <button onclick="renderYearly_${yId}('all','all')" class="y-btn ${filterMode==='all'?'active':''}">年度全称</button>
        ${['Q1','Q2','Q3','Q4'].map(q => `<button onclick="renderYearly_${yId}('quarter','${q}')" class="y-btn ${filterMode==='quarter'&&filterValue===q?'active':''}">${q}(${qStats[q]})</button>`).join('')}
    </div>`;

    const rowMonths = `<div class="y-btn-group" style="gap:3px; margin-top:5px;">
        ${monthKeys.map((k, i) => {
            const isSel = filterMode === 'month' && filterValue === k;
            const qOfM = `Q${Math.ceil((i+1)/3)}`;
            const isPartInQ = filterMode === 'quarter' && filterValue === qOfM;
            return `<button onclick="renderYearly_${yId}('month','${k}')" class="y-btn ${isSel?'active':(isPartInQ?'related':'')}" style="padding:10px 0;">${i+1}月</button>`;
        }).join('')}
    </div>`;

    const finalHTML = `
        <div class="y-container">
            <div class="y-header-nav">
                <div style="flex:1; display:flex; flex-direction:column;">${rowTop}${rowMonths}</div>
            </div>
            
            <div class="y-section" style="border-color:rgba(255,107,107,0.3); background:rgba(255,107,107,0.03);">
                <h4 style="color:#ff6b6b;">⏳ 未完成任务 <span class="y-badge" style="background:#ff6b6b;">${dispUn.length}</span></h4>
                ${dispUn.join('') || '<div style="text-align:center;color:var(--text-muted);padding:20px;">暂无待办 🎉</div>'}
            </div>
            
            <div class="y-section" style="border-color:rgba(81,207,102,0.3); background:rgba(81,207,102,0.03);">
                <h4 style="color:#51cf66;">✅ 已完成任务 <span class="y-badge" style="background:#51cf66;">${dispDone.length}</span></h4>
                ${dispDone.join('') || '<div style="text-align:center;color:var(--text-muted);padding:20px;">尚未开始努力 ✍️</div>'}
            </div>

            ${dispCan.length > 0 ? `
            <div class="y-section" style="border-color:rgba(134,142,150,0.3); background:rgba(134,142,150,0.03);">
                <h4 style="color:#868e96;">🚫 已作废 / 取消事项 <span class="y-badge" style="background:#868e96;">${dispCan.length}</span></h4>
                ${dispCan.join('')}
            </div>
            ` : ''}

            <div class="y-footer">📊 年度总结:完成 ${dispDone.length} | 剩余 ${dispUn.length} | 作废 ${dispCan.length}</div>
        </div>`;

    const targetNode = document.getElementById(containerId);
    if (targetNode) targetNode.innerHTML = finalHTML;
};

window['renderYearly_' + yId]();