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

记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布

目前为 2025-03-08 提交的版本。查看 最新版本

// ==UserScript==
// @name         微信读书30天阅读挑战打卡记录(本地版)
// @namespace    http://tampermonkey.net/
// @version      0.15
// @description  记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布
// @icon         https://i.miji.bid/2025/03/08/990e81d6e8ebc90d181e091cc0c99699.jpeg
// @author       Charlie
// @match        https://weread.qq.com/web/reader/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ===== 常量定义 =====
    const TOTAL_DAYS = 30;          // 挑战总天数
    const TOTAL_GOAL_HOURS = 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 = Date.now();     // 记录阅读开始时间

    // 获取下拉隐藏开关状态,默认隐藏
    const hideOnScrollDown = GM_getValue('hideOnScrollDown', true);

    // ===== 时间记录相关函数 =====
    /**
     * 记录阅读时长并保存到本地存储
     */
    function recordReadingTime() {
        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));
            console.log(`记录阅读时长:本次${sessionTime.toFixed(2)}分钟,今日${challengeData.dailyReadingTimes[todayIndex].toFixed(0)}分钟`);
        } catch (e) {
            console.error('记录阅读时长失败:', e);
        }
    }

    // ===== 工具函数 =====
    /**
     * 格式化日期为 YYYY-MM-DD
     */
    function formatDate(date) {
        return date.toISOString().split('T')[0];
    }

    /**
     * 计算总阅读时间、剩余时间和进度
     */
    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 progress = Math.min((totalMinutes / goalMinutes) * 100, 100);
            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}分钟`,
                progress: progress,
                average: `${Math.floor(avgMinutes / 60)}小时${Math.floor(avgMinutes % 60)}分钟`
            };
        } catch (e) {
            console.error('计算总时长失败:', e);
            return { total: '0小时0分钟', remaining: '30小时0分钟', progress: 0, 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(formatDate(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(''), total: '0小时0分钟' };
        }
    }

    // ===== UI 创建函数 =====
    /**
     * 创建并更新挑战 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: 2147483648;
                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, height 0.3s ease; /* 确保过渡动画生效 */
                overflow: hidden; /* 防止内容溢出 */
                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);

            container.innerHTML = `
                <h1 style="font-size: 1.2em; margin: 0 0 6px 0; color: #333;">30天阅读挑战</h1>
                <div style="font-size: 1em; color: #666; margin-bottom: 10px;">
                    <div>🏅 挑战时间:</div>
                    <div>\u00A0\u00A0\u00A0\u00A0 ${formatDate(startDate)} 至 ${formatDate(endDate)}</div>
                </div>
                <div style="font-size: 1em; color: #666; text-align: left;">
                    <div>⌚ 本周期目标时长:</div>
                    <div>\u00A0\u00A0\u00A0\u00A0 ${totalTime.total} / 还需${totalTime.remaining}</div>
                </div>
                <div style="margin-left: 22px; margin-top: 10px; width: 86%; height: 10px; background: #ebedf0; border-radius: 4px; overflow: hidden;">
                    <div style="width: ${totalTime.progress}%; height: 100%; background: #30AAFD; border-radius: 4px; transition: width 0.3s ease;"></div>
                </div>
                <div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; margin-top: 10px;">
                    ${Array.from({ length: TOTAL_DAYS }, (_, i) => {
                        const date = new Date(startDate);
                        date.setDate(date.getDate() + i);
                        return `<div style="width: 28px; height: 28px; background-color: ${challengeData.completedDays[i] ? '#30AAFD' : '#ebedf0'}; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: ${challengeData.completedDays[i] ? '#fff' : '#666'};">${date.getDate()}</div>`;
                    }).join('')}
                </div>
                <div style="font-size: 1em; color: #666; margin-top: 12px; 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; flex-direction: column; gap: 8px; height: 160px; width: 100%; padding: 5px;">
                        ${weeklyData.times.map((minutes, i) => `
                            <div style="display: flex; align-items: center; width: 100%;">
                                <div style="font-size: 0.8em; color: #666; width: 50px; text-align: left; flex-shrink: 0;">${weeklyData.dates[i].slice(-5)}</div>
                                <div style="flex-grow: 1; height: 10px; background: #ebedf0; border-radius: 2px; overflow: hidden;">
                                    <div style="width: ${(minutes / maxWeeklyMinutes) * 100}%; height: 100%; background: #30AAFD; border-radius: 2px; transition: width 0.3s ease;"></div>
                                </div>
                                <div style="font-size: 0.8em; color: #666; margin-left: 5px; flex-shrink: 0;">${Math.floor(minutes)}分</div>
                            </div>
                        `).join('')}
                    </div>
                </div>
            `;

            // 动态设置初始高度
            container.style.height = `${container.scrollHeight}px`;

            document.body.appendChild(container);
            console.log('UI 创建完成');
        } 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();
            console.log('挑战已重置');
        }
    }

    // ===== 初始化和事件监听 =====
    /**
     * 初始化脚本
     */
    function initialize() {
        console.log('脚本开始初始化...');
        if (!document.body) {
            console.log('等待 DOM 加载...');
            const observer = new MutationObserver(() => {
                if (document.body) {
                    observer.disconnect();
                    setup();
                }
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
            return;
        }
        setup();
    }

    /**
     * 设置脚本功能
     */
    function setup() {
        console.log('DOM 已加载,开始设置...');
        createChallengeUI();

        window.addEventListener('beforeunload', recordReadingTime);

        setInterval(() => {
            recordReadingTime();
            startTime = Date.now();
            createChallengeUI();
        }, 60 * 1000);

        const observer = new MutationObserver(() => {
            if (!document.getElementById('challenge-container')) {
                console.log('UI 丢失,重新创建...');
                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;
        window.addEventListener('scroll', () => {
            let scrollS = window.scrollY;
            let container = document.getElementById('challenge-container');

            if (scrollS > windowTop && scrollS > 50) {
                // 下拉时根据开关决定是否隐藏
                if (hideOnScrollDown) {
                    container.style.height = '0';
                    container.style.opacity = '0';
                }
            } else {
                // 向上滚动时始终显示
                if (container) {
                    container.style.height = `${container.scrollHeight}px`; // 动态恢复高度
                    container.style.opacity = '1';
                }
            }
            windowTop = scrollS;
        });
    }

    console.log('30天阅读挑战脚本加载中...');
    initialize();
})();

QingJ © 2025

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