微信读书30天阅读挑战打卡记录(本地版)

记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布,仅在页面激活时计时,每分钟更新一次,无需刷新

// ==UserScript==
// @name         微信读书30天阅读挑战打卡记录(本地版)
// @version      0.24
// @description  记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布,仅在页面激活时计时,每分钟更新一次,无需刷新
// @icon         https://i.miji.bid/2025/03/15/560664f99070e139e28703cf92975c73.jpeg
// @author       Grok
// @match        https://weread.qq.com/web/reader/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// @license      MIT
// @namespace http://tampermonkey.net/
// ==/UserScript==

(function() {
    'use strict';

    // ===== 常量定义 =====
    const TOTAL_DAYS = 30;
    const TOTAL_GOAL_HOURS = 30;
    const CHART_BLUE = '#30AAFD';
    const CALENDAR_DAYS = 30;

    // ===== 数据初始化 =====
    let challengeData = JSON.parse(localStorage.getItem('challengeData')) || {
        startDate: new Date().toISOString().split('T')[0],
        completedDays: Array(TOTAL_DAYS).fill(false),
        dailyReadingTimes: Array(TOTAL_DAYS).fill(0)
    };
    let startTime = null;
    let isPageActive = document.hasFocus();
    const hideOnScrollDown = GM_getValue('hideOnScrollDown', true);
    let globalTooltip = null;
    let eventListeners = [];
    let intervalId = null;
    let todayReadingElement = null; // 保存“今日阅读”元素的引用

    // ===== 时间记录相关函数 =====
    function recordReadingTime() {
        if (!startTime || !isPageActive) return;
        console.log('recordReadingTime triggered'); // 调试日志
        try {
            const endTime = Date.now();
            const sessionTime = (endTime - startTime) / 1000 / 60;
            const todayIndex = Math.min(
                Math.floor((new Date() - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24)),
                TOTAL_DAYS - 1
            );

            if (todayIndex < 0) return;

            challengeData.dailyReadingTimes[todayIndex] += sessionTime;
            challengeData.completedDays[todayIndex] = challengeData.dailyReadingTimes[todayIndex] >= 30;
            localStorage.setItem('challengeData', JSON.stringify(challengeData));
            startTime = Date.now();
            updateTodayReadingTime(todayIndex);
        } catch (e) {
            console.error('记录阅读时长失败:', e);
        }
    }

    // ===== 更新“今日阅读”时间显示 =====
    function updateTodayReadingTime(todayIndex) {
        console.log('updateTodayReadingTime called'); // 调试日志
        try {
            const todayReadingMinutes = challengeData.dailyReadingTimes[todayIndex];
            const todayReadingHours = Math.floor(todayReadingMinutes / 60);
            const todayReadingMins = Math.floor(todayReadingMinutes % 60);
            const todayReadingTime = `📖 今日阅读:${todayReadingHours}小时${todayReadingMins}分钟`;

            if (todayReadingElement) {
                todayReadingElement.textContent = todayReadingTime;
            } else {
                console.warn('todayReadingElement 未找到,重建 UI');
                createChallengeUI();
            }
        } catch (e) {
            console.error('更新今日阅读时间失败:', e);
            createChallengeUI();
        }
    }

    // ===== 页面激活状态监听 =====
    function handlePageActive() {
        if (document.hasFocus() && document.visibilityState === 'visible') {
            if (!isPageActive) {
                console.log('页面激活,开始计时');
                startTime = Date.now();
                isPageActive = true;
                if (!intervalId) {
                    intervalId = setInterval(recordReadingTime, 60 * 1000);
                    console.log('定时器已启动,ID:', intervalId);
                }
            }
        }
    }

    function handlePageInactive() {
        if (!document.hasFocus() || document.visibilityState === 'hidden') {
            if (isPageActive) {
                console.log('页面失活,暂停计时');
                recordReadingTime();
                startTime = null;
                isPageActive = false;
                if (intervalId) {
                    clearInterval(intervalId);
                    console.log('定时器已清除,ID:', intervalId);
                    intervalId = null;
                }
            }
        }
    }

    // ===== 工具函数 =====
    function formatDate(date) {
        return date.toISOString().split('T')[0].replace(/-/g, '/');
    }

    function formatFullDateWithDay(date) {
        const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
        const formattedDate = formatDate(date);
        const dayOfWeek = weekdays[date.getDay()];
        return `${formattedDate} ${dayOfWeek}`;
    }

    function formatTime(minutes) {
        const hours = Math.floor(minutes / 60);
        const mins = Math.floor(minutes % 60);
        return `${hours}小时${mins}分钟`;
    }

    function calculateTotalTime() {
        try {
            const totalMinutes = challengeData.dailyReadingTimes.reduce((sum, time) => sum + (time || 0), 0);
            const goalMinutes = TOTAL_GOAL_HOURS * 60;
            const totalHours = Math.floor(totalMinutes / 60);
            const remainingMinutes = totalMinutes % 60;

            const remainingTotalMinutes = Math.max(0, goalMinutes - totalMinutes);
            const remainingHours = Math.floor(remainingTotalMinutes / 60);
            const remainingMins = Math.floor(remainingTotalMinutes % 60);

            const daysPassed = Math.min(
                Math.floor((new Date() - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24)) + 1,
                TOTAL_DAYS
            );
            const avgMinutes = daysPassed > 0 ? totalMinutes / daysPassed : 0;

            return {
                total: `${totalHours}小时${Math.floor(remainingMinutes)}分钟`,
                remaining: `${remainingHours}小时${remainingMins}分钟`,
                isGoalReached: remainingTotalMinutes === 0,
                average: `${Math.floor(avgMinutes / 60)}小时${Math.floor(avgMinutes % 60)}分钟`
            };
        } catch (e) {
            console.error('计算总时长失败:', e);
            return { total: '0小时0分钟', remaining: '30小时0分钟', isGoalReached: false, average: '0小时0分钟' };
        }
    }

    function getWeeklyReadingTimes() {
        try {
            const today = new Date();
            const currentDay = today.getDay();
            const startOfWeek = new Date(today);
            startOfWeek.setDate(today.getDate() - (currentDay === 0 ? 6 : currentDay - 1));

            const weeklyTimes = Array(7).fill(0);
            const weeklyDates = [];
            let weeklyTotalMinutes = 0;

            for (let i = 0; i < 7; i++) {
                const day = new Date(startOfWeek);
                day.setDate(startOfWeek.getDate() + i);
                const dayIndex = Math.floor((day - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24));
                weeklyDates.push(day);
                if (dayIndex >= 0 && dayIndex < TOTAL_DAYS) {
                    weeklyTimes[i] = challengeData.dailyReadingTimes[dayIndex] || 0;
                    weeklyTotalMinutes += weeklyTimes[i];
                }
            }

            return {
                times: weeklyTimes,
                dates: weeklyDates,
                total: `${Math.floor(weeklyTotalMinutes / 60)}小时${Math.floor(weeklyTotalMinutes % 60)}分钟`
            };
        } catch (e) {
            console.error('获取周数据失败:', e);
            return { times: Array(7).fill(0), dates: Array(7).fill(new Date()), total: '0小时0分钟' };
        }
    }

    // ===== UI 创建函数 =====
    function createChallengeUI() {
        try {
            const existingUI = document.getElementById('challenge-container');
            if (existingUI) existingUI.remove();

            if (!document.body) {
                console.warn('document.body 未加载,跳过 UI 创建');
                return;
            }

            const container = document.createElement('div');
            container.id = 'challenge-container';
            container.style.cssText = `
                position: fixed; top: 50px; left: 70px;
                background: rgba(255, 255, 255, 0.5);
                backdrop-filter: blur(10px);
                color: #333; padding: 15px; z-index: 10000;
                width: 250px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                border: 1px solid rgba(221, 221, 221, 0.5); border-radius: 8px; font-size: 14px;
                transition: opacity 0.3s ease;
                overflow: visible;
                opacity: 1;
            `;

            const totalTime = calculateTotalTime();
            const weeklyData = getWeeklyReadingTimes();
            const startDate = new Date(challengeData.startDate);
            const endDate = new Date(startDate);
            endDate.setDate(startDate.getDate() + TOTAL_DAYS - 1);
            const todayIndex = Math.min(
                Math.floor((new Date() - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24)),
                TOTAL_DAYS - 1
            );
            const todayReadingMinutes = todayIndex >= 0 ? challengeData.dailyReadingTimes[todayIndex] : 0;
            const todayReadingHours = Math.floor(todayReadingMinutes / 60);
            const todayReadingMins = Math.floor(todayReadingMinutes % 60);
            const todayReadingTime = `${todayReadingHours}小时${todayReadingMins}分钟`;
            const maxWeeklyMinutes = Math.max(...weeklyData.times, 1);
            const maxDailyMinutes = Math.max(...challengeData.dailyReadingTimes, 1);

            const calendarRows = Math.ceil(CALENDAR_DAYS / 6);
            const calendarHTML = Array.from({ length: CALENDAR_DAYS }, (_, i) => {
                const date = new Date(startDate);
                date.setDate(date.getDate() + i);
                const day = date.getDate();
                const isWithinChallenge = i < TOTAL_DAYS;
                const fullDateWithDay = formatFullDateWithDay(date);
                return `
                    <div class="calendar-cell" data-date="${fullDateWithDay}" style="width: 28px; height: 28px; background-color: ${isWithinChallenge && challengeData.completedDays[i] ? '#30AAFD' : '#ebedf0'}; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: ${isWithinChallenge && challengeData.completedDays[i] ? '#fff' : '#666'};">
                        ${day}
                    </div>`;
            }).join('');

            const dailyChartHTML = Array.from({ length: TOTAL_DAYS }, (_, i) => {
                const date = new Date(startDate);
                date.setDate(date.getDate() + i);
                const minutes = challengeData.dailyReadingTimes[i] || 0;
                const heightPercentage = (minutes / maxDailyMinutes) * 100;
                const fullDateWithDay = formatFullDateWithDay(date);
                return `
                    <div style="flex: 1; background: #ebedf0; border-radius: 2px; display: flex; flex-direction: column; justify-content: flex-end; position: relative;" class="chart-bar" data-minutes="${minutes}" data-date="${fullDateWithDay}">
                        <div style="width: 100%; height: ${heightPercentage}%; background: ${CHART_BLUE}; border-radius: 2px; transition: height 0.3s ease;"></div>
                    </div>`;
            }).join('');

            const weeklyChartHTML = weeklyData.times.map((minutes, i) => {
                const date = weeklyData.dates[i];
                const heightPercentage = (minutes / maxWeeklyMinutes) * 100;
                const fullDateWithDay = formatFullDateWithDay(date);
                return `
                    <div style="flex: 1; background: #ebedf0; border-radius: 2px; display: flex; flex-direction: column; justify-content: flex-end; position: relative;" class="chart-bar" data-minutes="${minutes}" data-date="${fullDateWithDay}">
                        <div style="width: 100%; height: ${heightPercentage}%; background: ${CHART_BLUE}; border-radius: 2px; transition: height 0.3s ease;"></div>
                    </div>`;
            }).join('');

            container.innerHTML = `
                <div style="display: flex; align-items: center; justify-content: space-between;">
                    <h1 style="font-size: 1.2em; margin: 0; color: #333;">30天阅读挑战</h1>
                    <div style="position: relative; display: inline-block;">
                        <button style="background: none; border: none; font-size: 1em; color: ${CHART_BLUE}; cursor: pointer; padding: 0;">ℹ️</button>
                        <div class="info-tooltip" style="display: none; position: absolute; top: 100%; right: 0; background: rgba(51, 51, 51, 0.9); color: #fff; padding: 6px 10px; font-size: 0.85em; border-radius: 4px; z-index: 2147483647; box-shadow: 0 2px 4px rgba(0,0,0,0.2); line-height: 1.4; width: 220px; text-align: left;">
                            <div>【挑战时间】:根据每次重置时日期计算</div>
                            <div>【时长更新】:激活阅读页面时开始计时,每分钟更新一次(60秒内切出页面则重新计时)</div>
                            <div>【状态更新】:当天完成30min更新状态(官方5min)</div>
                            <div>【本周期目标时长】:30天总时长需达30小时</div>
                            <div>【日均阅读】:计算挑战周期内的日平均时长</div>
                        </div>
                    </div>
                </div>
                <div style="font-size: 1em; color: #666; margin-top: 10px;">
                    <div>🏅 挑战时间:</div>
                    <div>\u00A0\u00A0\u00A0\u00A0 ${formatDate(startDate)} 至 ${formatDate(endDate)}</div>
                </div>
                <div style="font-size: 1em; color: #666; margin-top: 10px; text-align: left;">
                    <div>⌚ 本周期目标时长:</div>
                    <div>\u00A0\u00A0\u00A0\u00A0 ${totalTime.total} / 还需${totalTime.remaining}</div>
                </div>
                ${totalTime.isGoalReached ? `
                    <div style="font-size: 1em; color: ${CHART_BLUE}; margin-top: 10px; text-align: left;">
                        🎉 已达成目标时长
                    </div>
                ` : ''}
                <div style="display: grid; grid-template-columns: repeat(6, 1fr); grid-template-rows: repeat(${calendarRows}, 1fr); gap: 4px; margin-top: 10px; width: 100%;">
                    ${calendarHTML}
                </div>
                <div id="today-reading" style="font-size: 1em; color: ${CHART_BLUE}; margin-top: 10px; text-align: left;">
                    📖 今日阅读:${todayReadingTime}
                </div>
                <div style="font-size: 1em; color: #666; margin-top: 10px; text-align: left;">
                    📚 日均阅读:${totalTime.average}
                </div>
                <div style="margin-top: 10px;">
                    <div style="font-size: 0.9em; color: #666; margin-bottom: 6px; text-align: left;">
                        📊 本周阅读总时长:${weeklyData.total}
                    </div>
                    <div style="display: flex; gap: 2px; height: 100px; width: 100%; padding: 5px; background: #fff; border-radius: 4px; position: relative;" id="weeklyChart">
                        ${weeklyChartHTML}
                    </div>
                </div>
                <div style="margin-top: 5px;">
                    <div style="font-size: 0.9em; color: #666; margin-bottom: 6px; text-align: left;">
                        📈 本周期阅读分布
                    </div>
                    <div style="display: flex; gap: 2px; height: 100px; width: 100%; padding: 5px; background: #fff; border-radius: 4px; position: relative;" id="dailyChart">
                        ${dailyChartHTML}
                    </div>
                </div>
            `;

            eventListeners.forEach(({ element, type, listener }) => {
                element.removeEventListener(type, listener);
            });
            eventListeners = [];

            if (!globalTooltip) {
                globalTooltip = document.createElement('div');
                globalTooltip.className = 'tooltip';
                globalTooltip.style.cssText = `
                    display: none; position: fixed;
                    background: rgba(51, 51, 51, 0.9); color: #fff;
                    padding: 6px 10px; font-size: 0.9em; border-radius: 4px;
                    white-space: pre-wrap; z-index: 2147483647;
                    pointer-events: none; transform: translateX(-50%);
                    box-shadow: 0 2px 4px rgba(0,0,0,0.2);
                    line-height: 1.4;
                `;
                document.body.appendChild(globalTooltip);
            } else {
                globalTooltip.style.display = 'none';
            }

            document.body.appendChild(container);

            // 保存“今日阅读”元素的引用
            todayReadingElement = document.getElementById('today-reading');
            console.log('todayReadingElement 初始化:', todayReadingElement);

            const dailyChart = container.querySelector('#dailyChart');
            const weeklyChart = container.querySelector('#weeklyChart');
            const calendarCells = container.querySelectorAll('.calendar-cell');
            const infoButton = container.querySelector('button');
            const infoTooltip = container.querySelector('.info-tooltip');

            const showInfoListener = () => infoTooltip.style.display = 'block';
            const hideInfoListener = () => infoTooltip.style.display = 'none';
            infoButton.addEventListener('mouseover', showInfoListener);
            infoButton.addEventListener('mouseout', hideInfoListener);
            eventListeners.push({ element: infoButton, type: 'mouseover', listener: showInfoListener });
            eventListeners.push({ element: infoButton, type: 'mouseout', listener: hideInfoListener });

            function setupChartBars(chart, bars) {
                if (!chart) return;
                bars.forEach((bar) => {
                    const mouseoverListener = (e) => {
                        const minutes = parseFloat(bar.getAttribute('data-minutes')) || 0;
                        const dateWithDay = bar.getAttribute('data-date');
                        globalTooltip.textContent = `${dateWithDay}\n${formatTime(minutes)}`;
                        globalTooltip.style.display = 'block';
                        const rect = bar.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    const mouseoutListener = () => {
                        globalTooltip.style.display = 'none';
                    };
                    const mousemoveListener = (e) => {
                        const rect = bar.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    bar.addEventListener('mouseover', mouseoverListener);
                    bar.addEventListener('mouseout', mouseoutListener);
                    bar.addEventListener('mousemove', mousemoveListener);
                    eventListeners.push({ element: bar, type: 'mouseover', listener: mouseoverListener });
                    eventListeners.push({ element: bar, type: 'mouseout', listener: mouseoutListener });
                    eventListeners.push({ element: bar, type: 'mousemove', listener: mousemoveListener });
                });
            }

            function setupCalendarCells(cells) {
                cells.forEach((cell) => {
                    const mouseoverListener = (e) => {
                        const fullDateWithDay = cell.getAttribute('data-date');
                        globalTooltip.textContent = fullDateWithDay;
                        globalTooltip.style.display = 'block';
                        const rect = cell.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    const mouseoutListener = () => {
                        globalTooltip.style.display = 'none';
                    };
                    const mousemoveListener = (e) => {
                        const rect = cell.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    cell.addEventListener('mouseover', mouseoverListener);
                    cell.addEventListener('mouseout', mouseoutListener);
                    cell.addEventListener('mousemove', mousemoveListener);
                    eventListeners.push({ element: cell, type: 'mouseover', listener: mouseoverListener });
                    eventListeners.push({ element: cell, type: 'mouseout', listener: mouseoutListener });
                    eventListeners.push({ element: cell, type: 'mousemove', listener: mousemoveListener });
                });
            }

            setupChartBars(dailyChart, dailyChart?.querySelectorAll('.chart-bar') || []);
            setupChartBars(weeklyChart, weeklyChart?.querySelectorAll('.chart-bar') || []);
            setupCalendarCells(calendarCells);

            requestAnimationFrame(() => {
                container.style.height = `${container.scrollHeight}px`;
            });

        } catch (e) {
            console.error('创建 UI 失败:', e);
        }
    }

    // ===== 重置功能 =====
    function resetChallenge() {
        if (confirm('确定要重置挑战吗?所有打卡记录将清空!')) {
            challengeData = {
                startDate: new Date().toISOString().split('T')[0],
                completedDays: Array(TOTAL_DAYS).fill(false),
                dailyReadingTimes: Array(TOTAL_DAYS).fill(0)
            };
            localStorage.setItem('challengeData', JSON.stringify(challengeData));
            createChallengeUI();
        }
    }

    // ===== 初始化和事件监听 =====
    function initialize() {
        if (!document.body) {
            const observer = new MutationObserver(() => {
                if (document.body) {
                    observer.disconnect();
                    setup();
                }
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
            return;
        }
        setup();
    }

    function setup() {
        let attempts = 0;
        const maxAttempts = 5;

        function tryCreateUI() {
            createChallengeUI();
            if (!document.getElementById('challenge-container') && attempts < maxAttempts) {
                attempts++;
                setTimeout(tryCreateUI, 100 * attempts);
            }
        }

        tryCreateUI();

        window.addEventListener('focus', handlePageActive);
        window.addEventListener('blur', handlePageInactive);
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState === 'visible') {
                handlePageActive();
            } else {
                handlePageInactive();
            }
        });

        handlePageActive();

        window.addEventListener('beforeunload', recordReadingTime);

        const observer = new MutationObserver(() => {
            if (!document.getElementById('challenge-container')) {
                createChallengeUI();
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });

        GM_registerMenuCommand('重置挑战', resetChallenge);

        GM_registerMenuCommand(`下拉时UI: ${hideOnScrollDown ? '🙈 隐藏' : '👁️ 显示'}`, () => {
            GM_setValue('hideOnScrollDown', !hideOnScrollDown);
            location.reload();
        });

        let windowTop = 0;
        let isVisible = true;
        window.addEventListener('scroll', () => {
            let scrollS = window.scrollY;
            let container = document.getElementById('challenge-container');

            if (!container) return;

            if (scrollS > windowTop && scrollS > 50 && hideOnScrollDown) {
                if (isVisible) {
                    container.style.opacity = '0';
                    isVisible = false;
                    if (globalTooltip) globalTooltip.style.display = 'none';
                }
            } else {
                if (!isVisible) {
                    container.style.opacity = '1';
                    isVisible = true;
                }
            }
            windowTop = scrollS;
        });
    }

    initialize();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址