您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
一个带有番茄钟的任务列表,浮动在所有页面。使用 Ctrl+T 切换管理面板。
当前为
// ==UserScript== // @name ToDo List + Pomodoro Timer (Ctrl+T) // @name:zh-CN 任务列表 + 番茄钟 (Ctrl+T) // @namespace http://tampermonkey.net/ // @version 1.4 // @description A ToDo List with a Pomodoro Timer that floats on all pages. Use Ctrl+T to toggle manager. // @description:zh-CN 一个带有番茄钟的任务列表,浮动在所有页面。使用 Ctrl+T 切换管理面板。 // @author kq // @license MIT // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_notification // ==/UserScript== (function() { 'use strict'; const STORAGE_KEY_TASKS = 'todoPomodoroTasks_v1_kq'; // Added suffix to avoid conflict with old versions const STORAGE_KEY_ACTIVE_TIMER = 'todoPomodoroActiveTimer_v1_kq'; let tasks = []; let activeTimerInterval = null; let activeTimerChangeListenerId = null; let audioContext; // --- HTML Elements --- let taskManagerPanel, taskNameInput, taskDurationInput, taskListContainer, showExpiredCheckbox; let floatingDisplay, floatingTaskName, floatingTimerText, countdownCircle; // --- Sound --- function getAudioContext() { if (!audioContext && (window.AudioContext || window.webkitAudioContext)) { audioContext = new (window.AudioContext || window.webkitAudioContext)(); } return audioContext; } function playAlarm() { const ctx = getAudioContext(); if (!ctx) { GM_notification({ text: "时间到!任务已完成或需要休息。", title: "番茄钟提醒", silent: false, timeout: 7000 }); console.warn("AudioContext not supported. Using GM_notification."); return; } const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(660, ctx.currentTime); // E5 gainNode.gain.setValueAtTime(0.3, ctx.currentTime); oscillator.start(); setTimeout(() => { oscillator.stop(); }, 700); GM_notification({ text: "时间到!任务已完成或需要休息。", title: "番茄钟提醒", silent: true, timeout: 7000 }); } // --- Data Management --- async function loadTasks() { tasks = await GM_getValue(STORAGE_KEY_TASKS, []); await renderTaskList(); // Ensure list is rendered after loading } async function saveTasks() { await GM_setValue(STORAGE_KEY_TASKS, tasks); await renderTaskList(); // Re-render after saving } function generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2); } async function addTask() { const name = taskNameInput.value.trim(); const duration = parseInt(taskDurationInput.value, 10); if (name && duration > 0) { tasks.push({ id: generateId(), name, duration, done: false, expired: false }); taskNameInput.value = ''; taskDurationInput.value = '25'; await saveTasks(); } else { alert('请输入有效的任务名称和时长(分钟)!'); } } async function deleteTask(taskId) { tasks = tasks.filter(task => task.id !== taskId); const activeTimer = await GM_getValue(STORAGE_KEY_ACTIVE_TIMER); if (activeTimer && activeTimer.taskId === taskId) { await stopCurrentTimer(false); // Stop but don't mark as expired if deleted task was active } await saveTasks(); } async function toggleTaskDone(taskId) { const task = tasks.find(t => t.id === taskId); if (task) { task.done = !task.done; if (task.done) { // If marking as done task.expired = false; // A completed task is not considered expired for display purposes } await saveTasks(); } } // --- UI Rendering --- function createTaskManagerUI() { taskManagerPanel = document.createElement('div'); taskManagerPanel.id = 'todo-pomodoro-manager'; taskManagerPanel.innerHTML = ` <div class="tp-header"> <h2>任务管理 & 番茄钟</h2> <button id="tp-close-panel" class="tp-close-btn">×</button> </div> <div class="tp-input-group"> <input type="text" id="tp-task-name" placeholder="任务名称"> <input type="number" id="tp-task-duration" value="25" min="1" placeholder="时长 (分钟)"> <button id="tp-add-task-btn">添加任务</button> </div> <div class="tp-task-list-controls"> <label><input type="checkbox" id="tp-show-expired-checkbox"> 显示已过期任务</label> </div> <div id="tp-task-list"></div> <p class="tp-shortcut-info">快捷键: Ctrl + T</p> `; document.body.appendChild(taskManagerPanel); taskManagerPanel.style.display = 'none'; taskNameInput = document.getElementById('tp-task-name'); taskDurationInput = document.getElementById('tp-task-duration'); taskListContainer = document.getElementById('tp-task-list'); showExpiredCheckbox = document.getElementById('tp-show-expired-checkbox'); document.getElementById('tp-add-task-btn').addEventListener('click', addTask); document.getElementById('tp-close-panel').addEventListener('click', toggleTaskManager); showExpiredCheckbox.addEventListener('change', renderTaskList); makeDraggable(taskManagerPanel, taskManagerPanel.querySelector('.tp-header')); // Event delegation for task actions taskListContainer.addEventListener('click', async (e) => { const target = e.target.closest('button'); // Get the button element if (!target || !target.dataset.id) return; const taskId = target.dataset.id; if (target.classList.contains('tp-start-pomodoro-btn')) { await startPomodoroForTask(taskId); } else if (target.classList.contains('tp-stop-pomodoro-btn')) { await stopCurrentTimer(true); // Mark as expired if stopped manually } else if (target.classList.contains('tp-done-btn')) { await toggleTaskDone(taskId); } else if (target.classList.contains('tp-delete-btn')) { await deleteTask(taskId); } }); } function makeDraggable(element, handle) { let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; handle.onmousedown = dragMouseDown; function dragMouseDown(e) { e = e || window.event; e.preventDefault(); pos3 = e.clientX; pos4 = e.clientY; document.onmouseup = closeDragElement; document.onmousemove = elementDrag; } function elementDrag(e) { e = e || window.event; e.preventDefault(); pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; element.style.top = (element.offsetTop - pos2) + "px"; element.style.left = (element.offsetLeft - pos1) + "px"; } function closeDragElement() { document.onmouseup = null; document.onmousemove = null; } } async function renderTaskList() { if (!taskListContainer) return; const shouldShowExpired = showExpiredCheckbox && showExpiredCheckbox.checked; const tasksToDisplay = tasks.filter(task => { if (task.done) return true; if (!task.expired) return true; return shouldShowExpired; // If expired and not done, show only if checkbox is checked }); taskListContainer.innerHTML = ''; if (tasksToDisplay.length === 0) { let message = '暂无任务'; if (tasks.length > 0 && !shouldShowExpired) { // Has tasks, but they are all expired and hidden message = '暂无进行中或已完成任务 (可勾选"显示已过期任务")'; } else if (tasks.length > 0 && shouldShowExpired) { // Has tasks, showing expired, but still list is empty (should not happen if tasks.length > 0) message = '没有符合条件的任务'; } taskListContainer.innerHTML = `<p class="tp-no-tasks">${message}</p>`; return; } const ul = document.createElement('ul'); const activeTimer = await GM_getValue(STORAGE_KEY_ACTIVE_TIMER); tasksToDisplay.forEach(task => { const li = document.createElement('li'); let liClass = ''; if (task.done) liClass = 'tp-task-done'; else if (task.expired) liClass = 'tp-task-expired'; li.className = liClass; let timerButtonHTML; if (activeTimer && activeTimer.taskId === task.id) { timerButtonHTML = `<button class="tp-control-btn tp-stop-pomodoro-btn" data-id="${task.id}">停止计时</button>`; } else { timerButtonHTML = `<button class="tp-control-btn tp-start-pomodoro-btn" data-id="${task.id}" ${task.done ? 'disabled' : ''}>开始番茄钟</button>`; } li.innerHTML = ` <span class="tp-task-name-display">${task.name} (${task.duration} 分钟)</span> <div class="tp-task-actions"> ${timerButtonHTML} <button class="tp-done-btn" data-id="${task.id}">${task.done ? '撤销完成' : '标记完成'}</button> <button class="tp-delete-btn" data-id="${task.id}">删除</button> </div> `; ul.appendChild(li); }); taskListContainer.appendChild(ul); } function createFloatingDisplay() { floatingDisplay = document.createElement('div'); floatingDisplay.id = 'tp-floating-display'; floatingDisplay.innerHTML = ` <div id="tp-countdown-circle-container"> <div id="tp-countdown-circle"> <span id="tp-floating-timer-text">--:--</span> </div> </div> <div id="tp-floating-task-details"> <p id="tp-floating-task-name">无任务</p> </div> `; document.body.appendChild(floatingDisplay); floatingTaskName = document.getElementById('tp-floating-task-name'); floatingTimerText = document.getElementById('tp-floating-timer-text'); countdownCircle = document.getElementById('tp-countdown-circle'); updateFloatingDisplayFromStorage(); } function updateFloatingDisplay(taskName, timeLeftFormatted, progressPercent, isRunning) { if (!floatingDisplay) return; floatingTaskName.textContent = taskName || '无任务'; floatingTimerText.textContent = timeLeftFormatted || '--:--'; if (countdownCircle) { if (isRunning && progressPercent !== undefined) { countdownCircle.style.background = `conic-gradient(#4CAF50 ${progressPercent}%, #ddd ${progressPercent}% 100%)`; } else { countdownCircle.style.background = `conic-gradient(#ddd 0% 100%)`; } } floatingDisplay.style.display = 'flex'; // Always visible to show "No Task" or timer } // --- Timer Logic --- async function startPomodoroForTask(taskId) { const task = tasks.find(t => t.id === taskId); if (!task || task.done) return; await stopCurrentTimer(false); // Stop any existing timer, don't mark as expired const durationSeconds = task.duration * 60; const startTimeEpoch = Date.now(); const activeTimerData = { taskId: task.id, taskName: task.name, startTimeEpoch: startTimeEpoch, durationSeconds: durationSeconds, originalDurationSeconds: durationSeconds }; await GM_setValue(STORAGE_KEY_ACTIVE_TIMER, activeTimerData); // Value change listener will handle UI updates } function runTimerInterval(timerData) { if (activeTimerInterval) clearInterval(activeTimerInterval); const { taskId, taskName, startTimeEpoch, durationSeconds, originalDurationSeconds } = timerData; activeTimerInterval = setInterval(async () => { const now = Date.now(); const elapsedSeconds = Math.floor((now - startTimeEpoch) / 1000); const remainingSeconds = durationSeconds - elapsedSeconds; if (remainingSeconds <= 0) { clearInterval(activeTimerInterval); activeTimerInterval = null; playAlarm(); const task = tasks.find(t => t.id === taskId); if (task && !task.done) { // If not already marked done task.expired = true; await saveTasks(); // This will re-render list } await GM_deleteValue(STORAGE_KEY_ACTIVE_TIMER); // updateFloatingDisplay will be handled by GM_addValueChangeListener } else { const minutes = Math.floor(remainingSeconds / 60); const seconds = remainingSeconds % 60; const timeLeftFormatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; const progressPercent = ((originalDurationSeconds - remainingSeconds) / originalDurationSeconds) * 100; updateFloatingDisplay(taskName, timeLeftFormatted, progressPercent, true); } }, 1000); // Initial display for the new timer updateFloatingDisplay(taskName, formatTime(durationSeconds), 0, true); } async function stopCurrentTimer(markAsExpired = false) { if (activeTimerInterval) { clearInterval(activeTimerInterval); activeTimerInterval = null; } const activeTimer = await GM_getValue(STORAGE_KEY_ACTIVE_TIMER); if (activeTimer) { if (markAsExpired) { const task = tasks.find(t => t.id === activeTimer.taskId); if (task && !task.done) { task.expired = true; // Don't call saveTasks here directly, GM_deleteValue will trigger update cycle // which will re-render task list with the current 'tasks' array state. // So, ensure 'tasks' array modification is picked up. // We need to save the 'tasks' array state if we modify it here. await GM_setValue(STORAGE_KEY_TASKS, tasks); // Save the modified tasks array } } await GM_deleteValue(STORAGE_KEY_ACTIVE_TIMER); // The change listener on STORAGE_KEY_ACTIVE_TIMER will handle UI updates. // If task was marked expired and tasks saved, ensure renderTaskList picks it up. // The GM_deleteValue will trigger handleActiveTimerChange, which calls renderTaskList. // renderTaskList uses the global 'tasks' array, which has been updated if expired. } } function formatTime(totalSeconds) { const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } async function updateFloatingDisplayFromStorage() { const activeTimer = await GM_getValue(STORAGE_KEY_ACTIVE_TIMER); if (activeTimer && activeTimer.startTimeEpoch && activeTimer.durationSeconds) { const now = Date.now(); const elapsedSeconds = Math.floor((now - activeTimer.startTimeEpoch) / 1000); const remainingSeconds = activeTimer.durationSeconds - elapsedSeconds; if (remainingSeconds > 0) { const progressPercent = ((activeTimer.originalDurationSeconds - remainingSeconds) / activeTimer.originalDurationSeconds) * 100; updateFloatingDisplay(activeTimer.taskName, formatTime(remainingSeconds), progressPercent, true); if (!activeTimerInterval) { runTimerInterval(activeTimer); } } else { // Timer expired while tab might have been inactive updateFloatingDisplay(activeTimer.taskName, '00:00', 100, false); const task = tasks.find(t => t.id === activeTimer.taskId); if (task && !task.done && !task.expired) { task.expired = true; await saveTasks(); // Save and re-render } await GM_deleteValue(STORAGE_KEY_ACTIVE_TIMER); // Clean up } } else { updateFloatingDisplay(null, null, 0, false); if (activeTimerInterval) { clearInterval(activeTimerInterval); activeTimerInterval = null; } } } // --- Event Handlers & Initialization --- function toggleTaskManager() { if (!taskManagerPanel) createTaskManagerUI(); const isVisible = taskManagerPanel.style.display === 'block'; taskManagerPanel.style.display = isVisible ? 'none' : 'block'; if (!isVisible) { loadTasks(); // Refresh task list when opening } } function handleKeyDown(e) { // Check if focused on an input field or contentEditable element, // but allow if it's our own panel's input fields. const activeEl = document.activeElement; const isTypingInExternalField = activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable) && activeEl !== taskNameInput && activeEl !== taskDurationInput; if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 't') { if (isTypingInExternalField && taskManagerPanel && taskManagerPanel.style.display === 'none') { // If panel is hidden and user is typing in a general field, // they might not intend to open our panel. // However, for simplicity and specific request for Ctrl+T, we'll allow it. // Could add a setting later to disable shortcut in input fields. } e.preventDefault(); toggleTaskManager(); } } async function handleActiveTimerChange(name, oldValue, newValue) { if (name === STORAGE_KEY_ACTIVE_TIMER) { if (activeTimerInterval) { clearInterval(activeTimerInterval); activeTimerInterval = null; } if (newValue && newValue.startTimeEpoch && newValue.durationSeconds) { runTimerInterval(newValue); } else { updateFloatingDisplay(null, null, 0, false); // Clear floating display } if (taskManagerPanel && taskManagerPanel.style.display === 'block') { await renderTaskList(); // Update buttons in manager panel if open } // Also ensure floating display is up-to-date based on the new value (or lack thereof) await updateFloatingDisplayFromStorage(); } } async function init() { addStyles(); createFloatingDisplay(); // Create floating UI first // Create task manager UI but keep it hidden initially. // This ensures its elements (like showExpiredCheckbox) exist before loadTasks tries to access them for filtering. if (!taskManagerPanel) createTaskManagerUI(); await loadTasks(); // Load tasks, which will also call renderTaskList document.addEventListener('keydown', handleKeyDown); GM_registerMenuCommand("打开/关闭任务番茄钟面板 (Ctrl+T)", toggleTaskManager, "t"); activeTimerChangeListenerId = GM_addValueChangeListener(STORAGE_KEY_ACTIVE_TIMER, handleActiveTimerChange); await updateFloatingDisplayFromStorage(); // Initial sync of floating display window.addEventListener('unload', () => { if (activeTimerChangeListenerId) { GM_removeValueChangeListener(activeTimerChangeListenerId); } if (activeTimerInterval) { clearInterval(activeTimerInterval); } }); } function addStyles() { GM_addStyle(` #todo-pomodoro-manager { position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: 480px; /* Slightly wider for new checkbox area */ max-height: 80vh; background-color: #f9f9f9; border: 1px solid #ccc; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 2147483640; /* High z-index */ font-family: Arial, sans-serif; color: #333; border-radius: 8px; display: flex; flex-direction: column; } #todo-pomodoro-manager .tp-header { padding: 10px 15px; background-color: #4CAF50; color: white; border-top-left-radius: 8px; border-top-right-radius: 8px; display: flex; justify-content: space-between; align-items: center; cursor: move; } #todo-pomodoro-manager .tp-header h2 { margin: 0; font-size: 1.2em; } #todo-pomodoro-manager .tp-close-btn { background: none; border: none; color: white; font-size: 1.5em; cursor: pointer; } #todo-pomodoro-manager .tp-input-group { padding: 15px; border-bottom: 1px solid #eee; display: flex; gap: 10px; } #todo-pomodoro-manager .tp-input-group input[type="text"] { flex-grow: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; } #todo-pomodoro-manager .tp-input-group input[type="number"] { width: 80px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; } #todo-pomodoro-manager .tp-input-group button { padding: 8px 12px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; } #todo-pomodoro-manager .tp-input-group button:hover { background-color: #45a049; } .tp-task-list-controls { padding: 10px 15px 5px; border-bottom: 1px solid #eee; font-size: 0.9em; } .tp-task-list-controls label { display: flex; align-items: center; gap: 5px; cursor: pointer; } #tp-task-list { padding: 0px 15px 15px 15px; overflow-y: auto; flex-grow: 1; min-height: 100px; } /* Added min-height */ #tp-task-list ul { list-style: none; padding: 0; margin: 0; } #tp-task-list li { padding: 10px 5px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; } #tp-task-list li:last-child { border-bottom: none; } #tp-task-list .tp-task-name-display { flex-grow: 1; font-size: 0.95em; word-break: break-all; } #tp-task-list .tp-task-actions button { margin-left: 5px; padding: 5px 8px; font-size: 0.8em; border: 1px solid #ddd; border-radius: 3px; cursor: pointer; white-space: nowrap;} #tp-task-list .tp-task-actions .tp-start-pomodoro-btn { background-color: #2196F3; color: white; border-color: #2196F3;} #tp-task-list .tp-task-actions .tp-start-pomodoro-btn:disabled { background-color: #aaa; border-color: #aaa; cursor: not-allowed; } #tp-task-list .tp-task-actions .tp-stop-pomodoro-btn { background-color: #ff9800; color: white; border-color: #ff9800;} #tp-task-list .tp-task-actions .tp-done-btn { background-color: #67c23a; color: white; border-color: #67c23a;} #tp-task-list .tp-task-actions .tp-delete-btn { background-color: #f44336; color: white; border-color: #f44336;} #tp-task-list .tp-task-done .tp-task-name-display { text-decoration: line-through; color: #888; } #tp-task-list .tp-task-expired .tp-task-name-display { color: #e74c3c; /*font-style: italic;*/ } #tp-task-list .tp-task-expired .tp-task-name-display::after { content: " (已超时)"; color: #e74c3c; font-size: 0.9em; margin-left: 4px;} #tp-task-list .tp-no-tasks { text-align: center; color: #777; padding: 20px; font-size: 0.9em; } .tp-shortcut-info { font-size: 0.8em; color: #777; text-align: center; padding: 5px 15px 10px; border-top: 1px solid #eee; margin-top: auto; } #tp-floating-display { position: fixed; bottom: 20px; right: 20px; background-color: rgba(255, 255, 255, 0.95); border: 1px solid #ccc; box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: 8px; padding: 10px 15px; z-index: 2147483639; /* Slightly lower than panel */ display: flex; align-items: center; gap: 15px; font-family: Arial, sans-serif; color: #333; } #tp-countdown-circle { width: 50px; height: 50px; border-radius: 50%; background: conic-gradient(#ddd 0% 100%); display: flex; justify-content: center; align-items: center; font-size: 0.9em; font-weight: bold; color: #333; transition: background 0.2s linear; } #tp-floating-task-details { display: flex; flex-direction: column; align-items: flex-start; } #tp-floating-task-name { margin: 0; font-size: 0.95em; font-weight: bold; max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } `); } // --- Start Script --- if (window.self === window.top) { // Attempt to run only in top frame to avoid multiple instances in iframes init(); } else { // For iframes, potentially only show the floating display if a timer is active globally. // For simplicity, current script runs fully in all frames. // A more complex setup would be needed for selective iframe operation. // The @match *://*/* will run it in all frames. // This basic check is a common pattern, though not foolproof. // Let's assume user wants it on all pages including iframes for now. // If you want to restrict to top frame only for the *main* logic: // if (window.self === window.top) { init(); } else { createFloatingDisplay(); /* and sync logic */ } // For now, keep it simple as the prompt implies "all pages". init(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址