PTA平台题目计时和倒计时工具 - GreasyFork/Tampermonkey 版
// ==UserScript==
// @name PTA Timer
// @namespace https://github.com/Withnoidea/PATTimer
// @version 2.0.0
// @description PTA平台题目计时和倒计时工具 - GreasyFork/Tampermonkey 版
// @author Withnoidea
// @license MIT
// @match https://pintia.cn/*
// @icon https://cdn.jsdelivr.net/gh/Withnoidea/images/icon.png
// @icon16 https://cdn.jsdelivr.net/gh/Withnoidea/images/icon16.png
// @icon48 https://cdn.jsdelivr.net/gh/Withnoidea/images/icon48.png
// @icon128 https://cdn.jsdelivr.net/gh/Withnoidea/images/icon128.png
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_notification
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const ICONS = {
main: 'https://cdn.jsdelivr.net/gh/Withnoidea/images/icon.png',
icon16: 'https://cdn.jsdelivr.net/gh/Withnoidea/images/icon16.png',
icon48: 'https://cdn.jsdelivr.net/gh/Withnoidea/images/icon48.png',
icon128: 'https://cdn.jsdelivr.net/gh/Withnoidea/images/icon128.png'
};
const CONTENT_CSS = "/* ============================================\n PTA Timer - Content Script Styles\n Ultra-Compact Floating Timer Widget\n Focus Precision Design System\n ============================================ */\n\n@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap');\n\n/* Timer Widget Container */\n#pta-timer-display {\n position: fixed;\n top: 16px;\n right: 16px;\n width: auto;\n min-width: 340px;\n height: 44px;\n background: rgba(255, 255, 255, 0.85);\n backdrop-filter: blur(20px);\n -webkit-backdrop-filter: blur(20px);\n border: 1px solid rgba(255, 255, 255, 0.5);\n border-radius: 10px;\n box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);\n font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;\n z-index: 9999;\n display: flex;\n align-items: center;\n overflow: hidden;\n animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n transition: all 0.2s ease;\n}\n\n#pta-timer-display:hover {\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);\n}\n\n/* Dragging states */\n#pta-timer-display:not(.fixed) {\n cursor: move;\n user-select: none;\n}\n\n#pta-timer-display:not(.fixed):active {\n opacity: 0.85;\n transform: scale(1.01);\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);\n}\n\n/* Fixed state */\n#pta-timer-display.fixed {\n cursor: default;\n user-select: auto;\n border: 1.5px solid rgba(37, 99, 235, 0.3);\n}\n\n@keyframes slideIn {\n from {\n transform: translateY(-10px);\n opacity: 0;\n }\n to {\n transform: translateY(0);\n opacity: 1;\n }\n}\n\n/* Left Branding Section */\n.timer-brand {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 0 12px;\n height: 100%;\n border-right: 1px solid rgba(0, 0, 0, 0.05);\n}\n\n.timer-brand-info {\n display: flex;\n flex-direction: column;\n}\n\n.timer-brand-row {\n display: flex;\n align-items: center;\n gap: 4px;\n}\n\n.timer-status-dot {\n width: 5px;\n height: 5px;\n border-radius: 50%;\n background: #006c49;\n box-shadow: 0 0 4px rgba(0, 108, 73, 0.4);\n}\n\n.timer-status-dot.paused {\n background: #d97706;\n box-shadow: 0 0 4px rgba(217, 119, 6, 0.4);\n}\n\n.timer-brand-label {\n font-size: 10px;\n font-weight: 700;\n color: rgba(21, 28, 39, 0.8);\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n\n.timer-exam-badge {\n font-size: 8px;\n font-weight: 700;\n background: rgba(207, 44, 48, 0.85);\n color: #fff;\n padding: 1px 4px;\n border-radius: 3px;\n text-transform: uppercase;\n letter-spacing: 0.02em;\n line-height: 1;\n}\n\n.timer-problem-id {\n display: flex;\n align-items: center;\n gap: 4px;\n opacity: 0.5;\n}\n\n.timer-problem-id span {\n font-size: 8px;\n text-transform: uppercase;\n letter-spacing: -0.02em;\n color: #434655;\n}\n\n.timer-problem-mode {\n font-size: 8px;\n text-transform: uppercase;\n letter-spacing: -0.02em;\n color: #006c49;\n font-weight: 500;\n}\n\n/* Central Display */\n.timer-center {\n display: flex;\n align-items: center;\n flex: 1;\n padding: 0 12px;\n gap: 12px;\n height: 100%;\n position: relative;\n}\n\n.timer-time-display {\n font-family: 'JetBrains Mono', monospace;\n font-size: 18px;\n font-weight: 600;\n color: #004ac6;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.01em;\n line-height: 1;\n}\n\n.timer-time-display.warning {\n color: #d97706;\n}\n\n.timer-time-display.danger {\n color: #ab0b1c;\n animation: timerPulse 1.5s ease-in-out infinite;\n}\n\n@keyframes timerPulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.5; }\n}\n\n/* Divider */\n.timer-divider {\n width: 1px;\n height: 20px;\n background: rgba(0, 0, 0, 0.05);\n}\n\n/* Controls Cluster */\n.timer-controls-cluster {\n display: flex;\n align-items: center;\n gap: 4px;\n}\n\n.timer-ctrl-btn {\n width: 28px;\n height: 28px;\n display: flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: 6px;\n cursor: pointer;\n transition: all 0.15s ease;\n padding: 0;\n}\n\n.timer-ctrl-btn:active {\n transform: scale(0.9);\n}\n\n.timer-ctrl-btn .material-symbols-outlined {\n font-size: 16px;\n font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;\n}\n\n/* Play/Pause - Primary */\n.timer-ctrl-btn.btn-play-pause {\n background: #004ac6;\n color: white;\n box-shadow: 0 1px 3px rgba(0, 74, 198, 0.3);\n}\n\n.timer-ctrl-btn.btn-play-pause:hover {\n background: #2563eb;\n}\n\n.timer-ctrl-btn.btn-play-pause .material-symbols-outlined {\n font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;\n}\n\n/* Reset */\n.timer-ctrl-btn.btn-reset {\n background: transparent;\n color: #434655;\n}\n\n.timer-ctrl-btn.btn-reset:hover {\n background: rgba(0, 0, 0, 0.05);\n}\n\n/* Stop/Countdown */\n.timer-ctrl-btn.btn-countdown {\n background: transparent;\n color: #434655;\n}\n\n.timer-ctrl-btn.btn-countdown:hover {\n color: #006c49;\n background: rgba(108, 248, 187, 0.1);\n}\n\n/* Progress Bar */\n.timer-progress-bar {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 2px;\n background: rgba(0, 0, 0, 0.05);\n}\n\n.timer-progress-fill {\n height: 100%;\n background: #4edea3;\n box-shadow: 0 0 4px rgba(78, 222, 163, 0.3);\n transition: width 1s linear;\n width: 0%;\n}\n\n.timer-progress-fill.warning {\n background: #d97706;\n box-shadow: 0 0 4px rgba(217, 119, 6, 0.3);\n}\n\n.timer-progress-fill.danger {\n background: #ab0b1c;\n box-shadow: 0 0 4px rgba(171, 11, 28, 0.3);\n}\n\n/* Right Actions */\n.timer-actions {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 8px;\n height: 100%;\n border-left: 1px solid rgba(0, 0, 0, 0.05);\n}\n\n.timer-action-btn {\n width: 28px;\n height: 28px;\n display: flex;\n align-items: center;\n justify-content: center;\n border: none;\n background: transparent;\n border-radius: 6px;\n cursor: pointer;\n color: rgba(67, 70, 85, 0.5);\n transition: all 0.15s ease;\n padding: 0;\n}\n\n.timer-action-btn:hover {\n color: #151c27;\n background: rgba(0, 0, 0, 0.05);\n}\n\n.timer-action-btn:active {\n transform: scale(0.9);\n}\n\n.timer-action-btn .material-symbols-outlined {\n font-size: 14px;\n font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;\n}\n\n.timer-action-btn.pinned {\n color: #004ac6;\n}\n\n.timer-action-btn.pinned .material-symbols-outlined {\n font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;\n}\n\n/* Countdown Dialog Overlay */\n.timer-countdown-dialog {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n backdrop-filter: blur(4px);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 10001;\n animation: fadeIn 0.2s ease;\n}\n\n@keyframes fadeIn {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n.timer-countdown-panel {\n background: white;\n border-radius: 12px;\n padding: 20px;\n width: 280px;\n box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);\n animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@keyframes scaleIn {\n from { transform: scale(0.95); opacity: 0; }\n to { transform: scale(1); opacity: 1; }\n}\n\n.timer-countdown-panel h3 {\n font-size: 14px;\n font-weight: 600;\n color: #151c27;\n margin-bottom: 12px;\n}\n\n.timer-countdown-panel input {\n width: 100%;\n padding: 10px 12px;\n border: 1.5px solid #c3c6d7;\n border-radius: 8px;\n font-family: 'JetBrains Mono', monospace;\n font-size: 16px;\n font-weight: 500;\n color: #151c27;\n outline: none;\n margin-bottom: 12px;\n transition: border-color 0.2s;\n}\n\n.timer-countdown-panel input:focus {\n border-color: #004ac6;\n box-shadow: 0 0 0 3px rgba(0, 74, 198, 0.1);\n}\n\n.timer-countdown-panel input::placeholder {\n color: #737686;\n font-family: 'Inter', sans-serif;\n font-weight: 400;\n font-size: 13px;\n}\n\n.timer-countdown-actions {\n display: flex;\n gap: 8px;\n}\n\n.timer-countdown-actions button {\n flex: 1;\n padding: 10px;\n border-radius: 8px;\n font-family: 'Inter', sans-serif;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n transition: all 0.15s ease;\n border: none;\n}\n\n.timer-countdown-actions button:active {\n transform: scale(0.97);\n}\n\n.timer-countdown-actions .btn-confirm {\n background: #004ac6;\n color: white;\n}\n\n.timer-countdown-actions .btn-confirm:hover {\n background: #2563eb;\n}\n\n.timer-countdown-actions .btn-cancel {\n background: #f0f3ff;\n color: #434655;\n border: 1px solid #c3c6d7;\n}\n\n.timer-countdown-actions .btn-cancel:hover {\n background: #e2e8f8;\n}\n\n.timer-finished-toast {\n position: fixed;\n top: 72px;\n right: 16px;\n z-index: 10002;\n animation: timerFinishedSlideIn 0.28s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.timer-finished-toast.closing {\n animation: timerFinishedSlideOut 0.18s ease forwards;\n}\n\n.timer-finished-card {\n min-width: 300px;\n max-width: 360px;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 14px;\n background: rgba(255, 255, 255, 0.94);\n border: 1px solid rgba(195, 198, 215, 0.55);\n border-left: 4px solid #ab0b1c;\n border-radius: 14px;\n box-shadow: 0 18px 48px rgba(21, 28, 39, 0.18);\n backdrop-filter: blur(18px);\n -webkit-backdrop-filter: blur(18px);\n}\n\n.timer-finished-card.warning {\n border-left-color: #d97706;\n}\n\n.timer-finished-icon {\n width: 42px;\n height: 42px;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n border-radius: 12px;\n background: linear-gradient(135deg, rgba(171, 11, 28, 0.14), rgba(207, 44, 48, 0.22));\n color: #ab0b1c;\n}\n\n.timer-finished-icon .material-symbols-outlined {\n font-size: 24px;\n font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 24;\n}\n\n.timer-finished-copy {\n min-width: 0;\n flex: 1;\n}\n\n.timer-finished-title {\n font-size: 14px;\n font-weight: 800;\n color: #151c27;\n letter-spacing: -0.02em;\n}\n\n.timer-finished-message {\n margin-top: 3px;\n font-size: 12px;\n line-height: 1.45;\n color: #434655;\n}\n\n.timer-finished-close {\n width: 28px;\n height: 28px;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n border: none;\n border-radius: 8px;\n background: transparent;\n color: rgba(67, 70, 85, 0.65);\n cursor: pointer;\n transition: all 0.15s ease;\n}\n\n.timer-finished-close:hover {\n background: rgba(171, 11, 28, 0.08);\n color: #ab0b1c;\n}\n\n.timer-finished-close:active {\n transform: scale(0.92);\n}\n\n.timer-finished-close .material-symbols-outlined {\n font-size: 18px;\n}\n\n@keyframes timerFinishedSlideIn {\n from {\n transform: translateX(24px) scale(0.98);\n opacity: 0;\n }\n to {\n transform: translateX(0) scale(1);\n opacity: 1;\n }\n}\n\n@keyframes timerFinishedSlideOut {\n to {\n transform: translateX(24px) scale(0.98);\n opacity: 0;\n }\n}\n\n.pta-problem-highlight-pending {\n background: rgba(124, 58, 237, 0.22) !important;\n border-color: rgba(124, 58, 237, 0.58) !important;\n box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.34), 0 8px 20px rgba(88, 28, 135, 0.14) !important;\n outline: none !important;\n border-radius: 8px;\n color: #5b21b6 !important;\n transition: background 0.2s ease, box-shadow 0.2s ease;\n}\n\n.pta-problem-highlight-pending :is(a, button, div, span, svg, path) {\n border-color: rgba(124, 58, 237, 0.58) !important;\n color: #5b21b6 !important;\n stroke: #7c3aed !important;\n}\n\n.pta-problem-highlight-pending :is(a, button, div)[class*=\"success\"],\n.pta-problem-highlight-pending :is(a, button, div)[class*=\"accepted\"],\n.pta-problem-highlight-pending :is(a, button, div)[class*=\"checked\"] {\n background: rgba(124, 58, 237, 0.12) !important;\n}\n\n.pta-problem-highlight-solved {\n background: rgba(245, 158, 11, 0.30) !important;\n border-color: rgba(217, 119, 6, 0.58) !important;\n box-shadow: 0 0 0 2px rgba(217, 119, 6, 0.42), 0 8px 20px rgba(146, 64, 14, 0.16) !important;\n outline: none !important;\n border-radius: 8px;\n color: #92400e !important;\n transition: background 0.2s ease, box-shadow 0.2s ease;\n}\n\n.pta-problem-highlight-solved :is(a, button, div, span, svg, path) {\n border-color: rgba(217, 119, 6, 0.58) !important;\n color: #92400e !important;\n stroke: #d97706 !important;\n}\n\n.pta-problem-highlight-solved :is(a, button, div)[class*=\"success\"],\n.pta-problem-highlight-solved :is(a, button, div)[class*=\"accepted\"],\n.pta-problem-highlight-solved :is(a, button, div)[class*=\"checked\"] {\n background: rgba(245, 158, 11, 0.16) !important;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n #pta-timer-display {\n min-width: 300px;\n top: 8px;\n right: 8px;\n height: 40px;\n }\n\n .timer-time-display {\n font-size: 16px;\n }\n\n .timer-ctrl-btn {\n width: 26px;\n height: 26px;\n }\n}\n\n.timer-ctrl-btn.btn-panel {\n background: transparent;\n color: #434655;\n}\n.timer-ctrl-btn.btn-panel:hover {\n color: #004ac6;\n background: rgba(0, 74, 198, 0.08);\n}\n";
const PANEL_CSS = "\n@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&display=swap');\n:host {\n position: fixed;\n inset: 0;\n z-index: 2147483646;\n display: none;\n font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;\n}\n:host(.visible) { display: block; }\n.pta-panel-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(15, 23, 42, 0.28);\n backdrop-filter: blur(2px);\n -webkit-backdrop-filter: blur(2px);\n}\n.pta-panel-shell {\n position: absolute;\n top: 72px;\n right: 24px;\n width: 360px;\n height: 580px;\n max-width: calc(100vw - 32px);\n max-height: calc(100vh - 96px);\n border-radius: 18px;\n overflow: hidden;\n background: #fff;\n box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);\n}\n.pta-panel-close {\n position: absolute;\n top: 7px;\n right: 8px;\n z-index: 60;\n width: 30px;\n height: 30px;\n display: flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: 10px;\n background: rgba(240, 243, 255, 0.88);\n color: #434655;\n cursor: pointer;\n transition: all 0.15s ease;\n}\n.pta-panel-close:hover { background: rgba(226, 232, 248, 1); color: #004ac6; }\n.pta-panel-close .material-symbols-outlined { font-size: 18px; }\n@media (max-width: 480px) {\n .pta-panel-shell { top: 56px; right: 8px; left: 8px; width: auto; height: min(580px, calc(100vh - 72px)); }\n}\n/* PTA Timer - Focus Precision Design System - All Views */\n:host {\n --surface: #f9f9ff;\n --surface-dim: #d3daea;\n --surface-container-lowest: #ffffff;\n --surface-container-low: #f0f3ff;\n --surface-container: #e7eefe;\n --surface-container-high: #e2e8f8;\n --surface-container-highest: #dce2f3;\n --on-surface: #151c27;\n --on-surface-variant: #434655;\n --outline: #737686;\n --outline-variant: #c3c6d7;\n --primary: #004ac6;\n --primary-container: #2563eb;\n --on-primary: #ffffff;\n --on-primary-container: #eeefff;\n --secondary: #006c49;\n --secondary-container: #6cf8bb;\n --on-secondary-container: #00714d;\n --tertiary: #ab0b1c;\n --tertiary-container: #cf2c30;\n --on-tertiary-container: #ffecea;\n --error: #ba1a1a;\n --error-container: #ffdad6;\n --on-error-container: #93000a;\n --radius-sm: 0.25rem;\n --radius-default: 0.5rem;\n --radius-md: 0.75rem;\n --radius-lg: 1rem;\n --radius-xl: 1.5rem;\n --radius-full: 9999px;\n --container-padding: 16px;\n --element-gap: 8px;\n --section-margin: 12px;\n}\n\n* { margin: 0; padding: 0; box-sizing: border-box; }\n.plugin-container {\n width: 360px;\n height: 580px;\n margin: 0;\n overflow: hidden;\n font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;\n background: transparent;\n color: var(--on-surface);\n -webkit-font-smoothing: antialiased;\n}\n.material-symbols-outlined {\n font-family: 'Material Symbols Outlined';\n font-weight: normal;\n font-style: normal;\n font-size: 20px;\n line-height: 1;\n letter-spacing: normal;\n text-transform: none;\n display: inline-block;\n white-space: nowrap;\n word-wrap: normal;\n direction: ltr;\n font-feature-settings: 'liga';\n -webkit-font-feature-settings: 'liga';\n -webkit-font-smoothing: antialiased;\n font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;\n vertical-align: middle;\n width: 1em;\n overflow: hidden;\n flex-shrink: 0;\n}\n.plugin-container {\n width: 100%;\n height: 100%;\n position: relative;\n}\n.view {\n display: flex;\n flex-direction: column;\n height: 100%;\n background: var(--surface-container-lowest);\n border: 1px solid rgba(195, 198, 215, 0.3);\n overflow: hidden;\n position: absolute;\n top: 0; left: 0; right: 0; bottom: 0;\n}\n\n/* TopAppBar */\n.top-app-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 56px 8px var(--container-padding);\n background: rgba(255, 255, 255, 0.8);\n backdrop-filter: blur(12px);\n border-bottom: 1px solid rgba(195, 198, 215, 0.2);\n z-index: 10;\n flex-shrink: 0;\n}\n.top-app-bar.compact {\n padding: 6px 12px;\n border-bottom: 1px solid rgba(195, 198, 215, 0.1);\n}\n.app-bar-left {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.app-bar-right {\n display: flex;\n align-items: center;\n gap: 4px;\n}\n.app-title {\n font-size: 14px;\n font-weight: 700;\n color: var(--on-surface);\n}\n.app-title-sm {\n font-size: 13px;\n font-weight: 700;\n color: var(--on-surface);\n}\n.exam-badge {\n background: rgba(207, 44, 48, 0.8);\n color: #fff;\n font-size: 9px;\n font-weight: 700;\n padding: 2px 5px;\n border-radius: var(--radius-sm);\n text-transform: uppercase;\n}\n.icon-btn {\n width: 32px; height: 32px;\n display: flex; align-items: center; justify-content: center;\n border: none; background: transparent;\n border-radius: var(--radius-default);\n cursor: pointer; color: var(--on-surface-variant);\n transition: all 0.15s ease;\n}\n.icon-btn:hover { background: rgba(226, 232, 248, 0.5); color: var(--primary); }\n.icon-btn:active { transform: scale(0.95); }\n.icon-btn .material-symbols-outlined { font-size: 20px; }\n.icon-btn-sm {\n width: 26px; height: 26px;\n display: flex; align-items: center; justify-content: center;\n border: none; background: transparent;\n border-radius: var(--radius-sm);\n cursor: pointer; color: var(--on-surface-variant);\n transition: all 0.15s ease;\n}\n.icon-btn-sm:hover { background: rgba(226, 232, 248, 0.5); }\n\n/* View Content */\n.view-content {\n flex: 1;\n display: flex;\n flex-direction: column;\n overflow-y: auto;\n padding: var(--container-padding);\n gap: var(--section-margin);\n}\n.view-content::-webkit-scrollbar { width: 4px; }\n.view-content::-webkit-scrollbar-track { background: transparent; }\n.view-content::-webkit-scrollbar-thumb { background: var(--surface-container-highest); border-radius: 4px; }\n\n/* Problem Header */\n.problem-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n}\n.problem-info { min-width: 0; flex: 1; }\n.problem-subtitle {\n font-size: 11px; font-weight: 500;\n color: var(--on-surface-variant); margin-bottom: 2px;\n}\n.problem-title {\n font-size: 14px; font-weight: 600;\n color: var(--on-surface);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n\n/* Timer Display */\n.timer-display-section {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px 16px;\n background: var(--surface-container-low);\n border: 1px solid rgba(0, 74, 198, 0.05);\n border-radius: var(--radius-md);\n}\n.timer-display-wrapper { display: flex; flex-direction: column; }\n.timer-label {\n font-size: 10px; font-weight: 500;\n color: var(--on-surface-variant);\n text-transform: uppercase; letter-spacing: 0.03em;\n margin-bottom: 2px;\n}\n.timer-value {\n font-family: 'JetBrains Mono', monospace;\n font-size: 28px; font-weight: 600;\n color: var(--on-surface);\n line-height: 1.2; letter-spacing: -0.02em;\n font-variant-numeric: tabular-nums;\n transition: color 0.3s ease;\n}\n.timer-value.warning { color: #d97706; }\n.timer-value.danger { color: var(--tertiary); animation: pulse 1.5s ease-in-out infinite; }\n@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }\n.timer-controls { display: flex; gap: 6px; }\n.control-btn {\n width: 36px; height: 36px;\n display: flex; align-items: center; justify-content: center;\n border: 1px solid rgba(195, 198, 215, 0.3);\n background: rgba(255, 255, 255, 0.8);\n border-radius: var(--radius-default);\n cursor: pointer; color: var(--on-surface-variant);\n transition: all 0.15s ease;\n}\n.control-btn:hover { background: var(--surface-container-high); }\n.control-btn:active { transform: scale(0.9); }\n.control-btn .material-symbols-outlined { font-size: 18px; }\n.countdown-btn { color: var(--secondary); }\n.countdown-btn:hover { background: rgba(108, 248, 187, 0.15); }\n\n/* Countdown Input */\n.countdown-input-section {\n padding: 12px;\n background: var(--surface-container-low);\n border: 1px solid rgba(195, 198, 215, 0.3);\n border-radius: var(--radius-default);\n}\n.countdown-input-row { display: flex; gap: 8px; align-items: center; }\n.countdown-input {\n flex: 1; padding: 8px 12px;\n border: 1px solid var(--outline-variant);\n border-radius: var(--radius-default);\n background: var(--surface-container-lowest);\n color: var(--on-surface);\n font-family: 'JetBrains Mono', monospace;\n font-size: 14px; font-weight: 500; outline: none;\n transition: border-color 0.2s ease;\n}\n.countdown-input:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(0, 74, 198, 0.1); }\n.countdown-input::placeholder { color: var(--outline); font-family: 'Inter', sans-serif; font-weight: 400; }\n.btn-primary-sm {\n padding: 8px 14px;\n background: var(--primary); color: var(--on-primary);\n border: none; border-radius: var(--radius-default);\n font-family: 'Inter', sans-serif; font-size: 12px; font-weight: 600;\n cursor: pointer; transition: all 0.15s ease; white-space: nowrap;\n}\n.btn-primary-sm:hover { background: var(--primary-container); }\n.btn-primary-sm:active { transform: scale(0.95); }\n.btn-cancel-sm {\n padding: 8px 12px;\n background: transparent; color: var(--on-surface-variant);\n border: 1px solid var(--outline-variant);\n border-radius: var(--radius-default);\n font-family: 'Inter', sans-serif; font-size: 12px; font-weight: 500;\n cursor: pointer; transition: all 0.15s ease; white-space: nowrap;\n}\n.btn-cancel-sm:hover { background: var(--surface-container-high); }\n\n/* Sessions List */\n.quick-stats { flex: 1; display: flex; flex-direction: column; min-height: 0; }\n.stats-header { margin-bottom: 8px; }\n.stats-label {\n font-size: 10px; font-weight: 700;\n color: var(--on-surface-variant);\n text-transform: uppercase; letter-spacing: 0.05em;\n}\n.sessions-list {\n flex: 1; overflow-y: auto;\n display: flex; flex-direction: column; gap: 6px;\n}\n.sessions-list::-webkit-scrollbar { width: 3px; }\n.sessions-list::-webkit-scrollbar-thumb { background: var(--surface-container-highest); border-radius: 3px; }\n.session-item {\n display: flex; align-items: center; justify-content: space-between;\n padding: 10px 12px;\n background: var(--surface-container-low);\n border: 1px solid transparent;\n border-radius: var(--radius-default);\n transition: all 0.15s ease;\n}\n.session-item:hover { border-color: rgba(0, 74, 198, 0.15); background: var(--surface-container-high); }\n.session-item-left { display: flex; align-items: center; gap: 10px; min-width: 0; }\n.session-number {\n width: 28px; height: 28px;\n border-radius: var(--radius-default);\n background: var(--surface-container-high);\n display: flex; align-items: center; justify-content: center;\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px; font-weight: 600; color: var(--primary); flex-shrink: 0;\n}\n.session-info { min-width: 0; }\n.session-problem-id {\n font-size: 12px; font-weight: 600; color: var(--on-surface);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.session-date { font-size: 10px; color: var(--on-surface-variant); margin-top: 1px; }\n.session-item-right {\n display: flex; align-items: center; gap: 8px; flex-shrink: 0;\n}\n.session-time {\n font-family: 'JetBrains Mono', monospace;\n font-size: 12px; font-weight: 500; color: var(--primary); white-space: nowrap;\n}\n.session-delete-btn {\n width: 26px; height: 26px;\n display: flex; align-items: center; justify-content: center;\n border: none; border-radius: var(--radius-sm);\n background: transparent; color: var(--on-surface-variant);\n cursor: pointer; opacity: 0;\n transition: all 0.15s ease;\n}\n.session-item:hover .session-delete-btn { opacity: 1; }\n.session-delete-btn:hover { background: var(--error-container); color: var(--on-error-container); }\n.session-delete-btn .material-symbols-outlined { font-size: 16px; }\n.empty-state {\n display: flex; flex-direction: column;\n align-items: center; justify-content: center;\n padding: 24px; gap: 8px;\n}\n.icon-empty { font-size: 32px !important; color: var(--outline-variant); }\n.empty-text { font-size: 12px; color: var(--outline); }\n\n.history-content {\n gap: 14px;\n background:\n radial-gradient(circle at top right, rgba(37, 99, 235, 0.12), transparent 34%),\n var(--surface-container-lowest);\n}\n.history-hero {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 18px;\n background: linear-gradient(135deg, var(--primary), var(--primary-container));\n border-radius: var(--radius-xl);\n color: var(--on-primary);\n box-shadow: 0 12px 28px rgba(0, 74, 198, 0.18);\n}\n.history-eyebrow {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n opacity: 0.82;\n}\n.history-title {\n margin-top: 4px;\n font-size: 24px;\n font-weight: 800;\n letter-spacing: -0.04em;\n}\n.history-hero-icon {\n width: 44px;\n height: 44px;\n display: flex !important;\n align-items: center;\n justify-content: center;\n border-radius: var(--radius-lg);\n background: rgba(255, 255, 255, 0.16);\n font-size: 26px !important;\n}\n.history-panel {\n padding: 12px;\n background: rgba(255, 255, 255, 0.72);\n border: 1px solid rgba(195, 198, 215, 0.28);\n border-radius: var(--radius-xl);\n box-shadow: 0 10px 30px rgba(21, 28, 39, 0.05);\n}\n.history-stats-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 10px;\n}\n.stats-hint {\n font-size: 10px;\n color: rgba(67, 70, 85, 0.66);\n}\n.history-panel .sessions-list {\n gap: 8px;\n}\n.history-panel .session-item {\n padding: 12px;\n background: rgba(240, 243, 255, 0.72);\n border-color: rgba(195, 198, 215, 0.22);\n border-radius: var(--radius-lg);\n}\n.history-panel .session-item:hover {\n transform: translateY(-1px);\n box-shadow: 0 8px 18px rgba(21, 28, 39, 0.06);\n}\n.history-panel .session-delete-btn {\n opacity: 1;\n background: rgba(255, 255, 255, 0.64);\n}\n.confirm-overlay {\n position: absolute;\n inset: 0;\n z-index: 50;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n background: rgba(21, 28, 39, 0.36);\n backdrop-filter: blur(8px);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.18s ease;\n}\n.confirm-overlay.visible {\n opacity: 1;\n pointer-events: auto;\n}\n.confirm-dialog {\n width: 100%;\n padding: 22px;\n background: var(--surface-container-lowest);\n border: 1px solid rgba(195, 198, 215, 0.55);\n border-radius: var(--radius-xl);\n box-shadow: 0 24px 60px rgba(21, 28, 39, 0.22);\n transform: translateY(8px) scale(0.98);\n transition: transform 0.18s ease;\n}\n.confirm-overlay.visible .confirm-dialog {\n transform: translateY(0) scale(1);\n}\n.confirm-icon {\n width: 48px;\n height: 48px;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-bottom: 14px;\n border-radius: var(--radius-lg);\n background: var(--error-container);\n color: var(--on-error-container);\n}\n.confirm-icon .material-symbols-outlined {\n font-size: 26px;\n}\n.confirm-title {\n font-size: 18px;\n font-weight: 800;\n color: var(--on-surface);\n letter-spacing: -0.03em;\n}\n.confirm-message {\n margin-top: 6px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--on-surface-variant);\n}\n.confirm-actions {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 10px;\n margin-top: 18px;\n}\n.confirm-btn {\n height: 40px;\n border: none;\n border-radius: var(--radius-lg);\n font-family: 'Inter', sans-serif;\n font-size: 13px;\n font-weight: 700;\n cursor: pointer;\n transition: all 0.15s ease;\n}\n.confirm-btn:active {\n transform: scale(0.97);\n}\n.confirm-cancel {\n background: var(--surface-container-low);\n color: var(--on-surface-variant);\n}\n.confirm-cancel:hover {\n background: var(--surface-container-high);\n}\n.confirm-delete {\n background: var(--error);\n color: #fff;\n box-shadow: 0 8px 16px rgba(186, 26, 26, 0.18);\n}\n.confirm-delete:hover {\n filter: brightness(1.06);\n}\n\n/* Footer */\n.app-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px var(--container-padding);\n background: rgba(231, 238, 254, 0.3);\n border-top: 1px solid rgba(195, 198, 215, 0.15);\n flex-shrink: 0;\n}\n.app-footer.compact { padding: 4px 12px; }\n.footer-status { display: flex; align-items: center; gap: 6px; }\n.status-dot {\n width: 6px; height: 6px; border-radius: 50%;\n background: var(--outline-variant); transition: background 0.3s ease;\n}\n.status-dot.connected { background: var(--secondary); box-shadow: 0 0 4px rgba(0, 108, 73, 0.4); }\n.status-text { font-size: 10px; color: var(--on-surface-variant); }\n.version-text { font-size: 10px; color: rgba(67, 70, 85, 0.6); font-style: italic; }\n\n/* ============================================\n VIEW: Exam List\n ============================================ */\n.exam-list-content { padding-bottom: 0; }\n.context-header { margin-bottom: 12px; }\n.context-title {\n font-size: 11px; font-weight: 800;\n color: var(--on-surface-variant);\n text-transform: uppercase; letter-spacing: 0.08em;\n margin-bottom: 8px;\n}\n.search-bar {\n display: flex; align-items: center;\n background: var(--surface-container-low);\n border: 1px solid rgba(195, 198, 215, 0.5);\n border-radius: var(--radius-default);\n padding: 8px 12px; gap: 8px;\n}\n.search-icon { font-size: 18px !important; color: var(--on-surface-variant); }\n.search-input {\n flex: 1; border: none; background: transparent;\n font-size: 12px; color: var(--on-surface); outline: none;\n font-family: 'Inter', sans-serif;\n}\n.search-input::placeholder { color: var(--outline); }\n.btn-current-page-problems {\n width: 100%;\n margin-top: 10px;\n padding: 10px 12px;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n border: 1px solid rgba(0, 74, 198, 0.18);\n border-radius: var(--radius-lg);\n background: rgba(0, 74, 198, 0.08);\n color: var(--primary);\n font-family: 'Inter', sans-serif;\n font-size: 13px;\n font-weight: 800;\n cursor: pointer;\n transition: all 0.15s ease;\n}\n.btn-current-page-problems:hover {\n background: rgba(0, 74, 198, 0.13);\n border-color: rgba(0, 74, 198, 0.32);\n}\n.btn-current-page-problems:active {\n transform: scale(0.98);\n}\n.btn-current-page-problems .material-symbols-outlined {\n font-size: 18px;\n}\n.exam-list-scroll {\n flex: 1; overflow-y: auto;\n display: flex; flex-direction: column; gap: 8px;\n padding-bottom: 8px;\n}\n.exam-list-scroll::-webkit-scrollbar { width: 4px; }\n.exam-list-scroll::-webkit-scrollbar-thumb { background: var(--surface-container-highest); border-radius: 4px; }\n.exam-list-item {\n display: flex; align-items: center; justify-content: space-between;\n padding: 12px;\n background: var(--surface-container-low);\n border: 1px solid transparent;\n border-radius: var(--radius-default);\n cursor: pointer;\n transition: all 0.2s ease;\n}\n.exam-list-item:hover {\n border-color: rgba(0, 74, 198, 0.2);\n background: rgba(226, 232, 248, 0.5);\n}\n.exam-list-item.active-exam {\n border-color: rgba(0, 74, 198, 0.34);\n background: rgba(0, 74, 198, 0.08);\n}\n.exam-list-item-left { display: flex; align-items: center; gap: 12px; min-width: 0; }\n.exam-list-icon {\n width: 32px; height: 32px;\n border-radius: var(--radius-sm);\n background: rgba(0, 74, 198, 0.1);\n display: flex; align-items: center; justify-content: center;\n color: var(--primary);\n flex-shrink: 0;\n}\n.exam-list-icon .material-symbols-outlined { font-size: 20px; }\n.exam-list-copy { display: flex; flex-direction: column; min-width: 0; }\n.exam-list-name { font-size: 14px; font-weight: 500; color: var(--on-surface); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.exam-list-subtitle { margin-top: 2px; font-size: 10px; font-weight: 700; color: var(--primary); }\n.exam-list-item-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }\n.exam-list-count {\n background: var(--secondary-container);\n color: var(--on-secondary-container);\n padding: 2px 8px; border-radius: var(--radius-full);\n font-size: 11px; font-weight: 600; white-space: nowrap;\n}\n.exam-list-delete-btn {\n width: 28px;\n height: 28px;\n display: flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: var(--radius-full);\n background: var(--error-container);\n color: var(--on-error-container);\n cursor: pointer;\n transition: all 0.15s ease;\n}\n.exam-list-delete-btn:hover { filter: brightness(0.98); }\n.exam-list-delete-btn:active { transform: scale(0.92); }\n.exam-list-delete-btn .material-symbols-outlined { font-size: 17px; }\n.exam-list-chevron { font-size: 18px !important; color: var(--outline); transition: color 0.2s; }\n.exam-list-item:hover .exam-list-chevron { color: var(--primary); }\n.exam-list-footer {\n padding: 16px;\n background: var(--surface-container-lowest);\n border-top: 1px solid rgba(195, 198, 215, 0.2);\n flex-shrink: 0;\n}\n.btn-return-timer {\n width: 100%; padding: 12px 16px;\n background: var(--primary); color: var(--on-primary);\n border: none; border-radius: var(--radius-lg);\n font-family: 'Inter', sans-serif; font-size: 14px; font-weight: 600;\n cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px;\n transition: all 0.15s ease; box-shadow: 0 2px 8px rgba(0, 74, 198, 0.2);\n}\n.btn-return-timer:hover { background: var(--primary-container); }\n.btn-return-timer:active { transform: scale(0.98); }\n.btn-return-timer .material-symbols-outlined { font-size: 20px; }\n\n/* ============================================\n VIEW: Exam Problem List\n ============================================ */\n.exam-problems-layout {\n display: flex; flex: 1; overflow: hidden;\n}\n.exam-problems-main {\n flex: 1; display: flex; flex-direction: column;\n padding: var(--container-padding);\n overflow-y: auto;\n background:\n radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 36%),\n var(--surface-container-lowest);\n}\n.exam-problems-main::-webkit-scrollbar { width: 4px; }\n.exam-problems-main::-webkit-scrollbar-thumb { background: var(--surface-container-highest); border-radius: 4px; }\n.exam-problems-back { margin-bottom: var(--section-margin); }\n.btn-text-primary {\n display: flex; align-items: center; gap: 4px;\n background: none; border: none;\n color: var(--primary); font-size: 14px; font-weight: 600;\n font-family: 'Inter', sans-serif;\n cursor: pointer; padding: 0;\n}\n.btn-text-primary:hover { text-decoration: underline; }\n.btn-text-primary .material-symbols-outlined { font-size: 18px; }\n.exam-title-section { margin-bottom: 20px; }\n.exam-title {\n font-size: 20px; font-weight: 700;\n color: var(--on-surface); line-height: 1.2; letter-spacing: -0.01em;\n}\n.exam-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; }\n.exam-status-badge {\n background: var(--secondary-container);\n color: var(--on-secondary-container);\n padding: 2px 8px; border-radius: var(--radius-full);\n font-size: 11px; font-weight: 500;\n}\n.exam-duration {\n display: flex; align-items: center; gap: 4px;\n font-size: 11px; color: var(--on-surface-variant);\n}\n.problem-list {\n display: flex; flex-direction: column; gap: 10px;\n flex: 1;\n}\n.problem-list-item {\n display: flex; align-items: center; justify-content: space-between;\n padding: 12px;\n background: var(--surface-container-low);\n border: 1px solid rgba(195, 198, 215, 0.3);\n border-radius: var(--radius-lg);\n transition: all 0.2s ease;\n}\n.problem-list-item:hover { border-color: rgba(0, 74, 198, 0.5); }\n.problem-list-item-left { display: flex; align-items: center; gap: 12px; min-width: 0; }\n.problem-list-number {\n width: 40px; height: 40px;\n border-radius: var(--radius-default);\n background: var(--surface-container-high);\n display: flex; align-items: center; justify-content: center;\n font-family: 'JetBrains Mono', monospace;\n font-size: 13px; font-weight: 600; color: var(--primary); flex-shrink: 0;\n}\n.problem-list-info { min-width: 0; }\n.problem-list-name {\n font-size: 13px; font-weight: 600; color: var(--on-surface);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.problem-list-meta { font-size: 11px; color: var(--on-surface-variant); margin-top: 3px; }\n.problem-list-start-btn {\n padding: 6px 16px;\n background: var(--primary); color: var(--on-primary);\n border: none; border-radius: var(--radius-full);\n font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 600;\n cursor: pointer; transition: all 0.15s ease;\n box-shadow: 0 1px 3px rgba(0, 74, 198, 0.2);\n white-space: nowrap;\n}\n.problem-list-start-btn:hover { background: var(--primary-container); }\n.problem-list-start-btn:active { transform: scale(0.95); }\n.problem-list-start-btn:disabled {\n opacity: 0.45;\n cursor: not-allowed;\n box-shadow: none;\n}\n.btn-danger-sm {\n padding: 6px 12px;\n background: var(--error-container);\n color: var(--on-error-container);\n border: none;\n border-radius: var(--radius-full);\n font-family: 'Inter', sans-serif;\n font-size: 12px;\n font-weight: 800;\n cursor: pointer;\n transition: all 0.15s ease;\n white-space: nowrap;\n}\n.btn-danger-sm:hover {\n filter: brightness(0.98);\n}\n.btn-danger-sm:active {\n transform: scale(0.95);\n}\n.custom-exam-toolbar {\n flex-direction: column;\n align-items: stretch;\n gap: 8px;\n margin-bottom: 12px;\n padding: 10px 12px;\n background: rgba(240, 243, 255, 0.78);\n border: 1px solid rgba(0, 74, 198, 0.16);\n border-radius: var(--radius-lg);\n}\n.custom-exam-count {\n font-size: 12px;\n font-weight: 800;\n color: var(--primary);\n white-space: nowrap;\n}\n.custom-exam-actions {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n width: 100%;\n}\n.custom-exam-actions .btn-text-primary,\n.custom-exam-actions .problem-list-start-btn,\n.custom-exam-actions .btn-danger-sm {\n min-height: 30px;\n}\n.custom-exam-actions .problem-list-start-btn,\n.custom-exam-actions .btn-danger-sm {\n flex: 1 1 76px;\n padding-inline: 10px;\n}\n.custom-exam-duration-input {\n flex: 1 1 82px;\n min-width: 0;\n height: 30px;\n padding: 0 9px;\n border: 1px solid rgba(124, 58, 237, 0.32);\n border-radius: var(--radius-full);\n background: rgba(255, 255, 255, 0.78);\n color: var(--on-surface);\n font-family: 'JetBrains Mono', monospace;\n font-size: 12px;\n font-weight: 700;\n outline: none;\n transition: all 0.15s ease;\n}\n.custom-exam-duration-input:focus {\n border-color: rgba(124, 58, 237, 0.72);\n box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);\n}\n.custom-exam-duration-input::placeholder {\n color: rgba(67, 70, 85, 0.62);\n font-family: 'Inter', sans-serif;\n font-weight: 700;\n}\n.problem-list-item.selected,\n.problem-list-item.pending {\n background: rgba(124, 58, 237, 0.14);\n border-color: rgba(124, 58, 237, 0.42);\n box-shadow: 0 8px 18px rgba(88, 28, 135, 0.10);\n}\n.problem-list-item.solved {\n background: rgba(245, 158, 11, 0.18);\n border-color: rgba(217, 119, 6, 0.42);\n box-shadow: 0 8px 18px rgba(146, 64, 14, 0.10);\n}\n.problem-status-badge {\n display: inline-flex;\n align-items: center;\n padding: 2px 7px;\n border-radius: var(--radius-full);\n font-size: 10px;\n font-weight: 800;\n line-height: 1.2;\n white-space: nowrap;\n}\n.problem-status-badge.blue {\n background: rgba(124, 58, 237, 0.16);\n color: #5b21b6;\n}\n.problem-status-badge.green {\n background: rgba(245, 158, 11, 0.22);\n color: #92400e;\n}\n.progress-stats {\n display: flex; align-items: center;\n padding: 12px;\n background: rgba(226, 232, 248, 0.5);\n border-radius: var(--radius-default);\n margin-top: auto; padding-top: 20px;\n gap: 12px;\n}\n.progress-info { display: flex; flex-direction: column; }\n.progress-label {\n font-size: 10px; font-weight: 500;\n color: var(--on-surface-variant);\n text-transform: uppercase; letter-spacing: 0.05em;\n}\n.progress-value {\n font-family: 'JetBrains Mono', monospace;\n font-size: 13px; font-weight: 500; color: var(--primary);\n}\n.progress-bar-container {\n flex: 1; height: 4px;\n background: var(--outline-variant);\n border-radius: var(--radius-full); overflow: hidden;\n}\n.progress-bar-fill {\n height: 100%; background: var(--secondary);\n border-radius: var(--radius-full);\n transition: width 0.3s ease; width: 0%;\n}\n.progress-percent { font-size: 12px; color: var(--on-surface-variant); font-weight: 500; }\n\n/* ============================================\n VIEW: Exam Mode (Minimalist Compact)\n ============================================ */\n.global-timer-bar {\n display: flex; align-items: center; justify-content: space-between;\n padding: 4px 12px;\n background: rgba(0, 74, 198, 0.05);\n border-bottom: 1px solid rgba(195, 198, 215, 0.2);\n flex-shrink: 0;\n}\n.global-timer-left { display: flex; align-items: center; gap: 6px; }\n.global-timer-label {\n font-size: 11px; font-weight: 500;\n color: var(--on-surface-variant);\n text-transform: uppercase; letter-spacing: 0.05em;\n}\n.global-timer-right { display: flex; align-items: center; gap: 12px; }\n.global-timer-value {\n font-family: 'JetBrains Mono', monospace;\n font-size: 13px; font-weight: 700;\n color: var(--primary); font-variant-numeric: tabular-nums;\n}\n.global-progress-bar {\n width: 64px; height: 4px;\n background: var(--surface-container-highest);\n border-radius: var(--radius-full); overflow: hidden;\n}\n.global-progress-fill {\n height: 100%; background: var(--primary);\n border-radius: var(--radius-full);\n transition: width 1s linear; width: 0%;\n}\n.exam-mode-content {\n flex: 1; display: flex; flex-direction: column;\n padding: 12px; gap: 12px;\n}\n.exam-problem-header {\n display: flex; align-items: center; justify-content: space-between;\n}\n.exam-problem-info { min-width: 0; }\n.exam-problem-index {\n font-size: 11px; font-weight: 500;\n color: var(--on-surface-variant);\n}\n.exam-problem-name {\n font-size: 13px; font-weight: 600;\n color: var(--on-surface);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n line-height: 1.3;\n}\n.exam-problem-difficulty {\n background: rgba(108, 248, 187, 0.6);\n color: var(--on-secondary-container);\n padding: 3px 8px; border-radius: var(--radius-sm);\n font-size: 10px; font-weight: 600; white-space: nowrap;\n}\n.exam-timer-display {\n display: flex; align-items: center; justify-content: space-between;\n padding: 10px 12px;\n background: rgba(240, 243, 255, 0.5);\n border: 1px solid rgba(0, 74, 198, 0.05);\n border-radius: var(--radius-default);\n}\n.exam-timer-info { display: flex; flex-direction: column; }\n.exam-timer-label {\n font-size: 10px; font-weight: 500;\n color: var(--on-surface-variant);\n text-transform: uppercase; letter-spacing: -0.02em;\n}\n.exam-timer-value {\n font-family: 'JetBrains Mono', monospace;\n font-size: 24px; font-weight: 600;\n color: var(--on-surface); line-height: 1;\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.05em;\n}\n.exam-timer-value.warning { color: #d97706; }\n.exam-timer-value.danger { color: var(--tertiary); animation: pulse 1.5s ease-in-out infinite; }\n.exam-timer-controls { display: flex; gap: 6px; }\n.exam-ctrl-btn {\n width: 32px; height: 32px;\n border: 1px solid rgba(195, 198, 215, 0.3);\n background: rgba(255, 255, 255, 0.8);\n border-radius: var(--radius-default);\n display: flex; align-items: center; justify-content: center;\n cursor: pointer; color: var(--on-surface-variant);\n transition: all 0.15s ease;\n}\n.exam-ctrl-btn:hover { background: var(--surface-container-high); }\n.exam-ctrl-btn:active { transform: scale(0.9); }\n.exam-ctrl-btn .material-symbols-outlined { font-size: 18px; }\n.exam-nav-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.exam-nav-btn {\n display: flex; align-items: center; justify-content: center; gap: 4px;\n padding: 8px;\n border: 1px solid rgba(195, 198, 215, 0.3);\n background: rgba(255, 255, 255, 0.4);\n border-radius: var(--radius-default);\n font-family: 'Inter', sans-serif; font-size: 12px; font-weight: 600;\n color: var(--on-surface-variant);\n cursor: pointer; transition: all 0.15s ease;\n}\n.exam-nav-btn:hover { background: rgba(226, 232, 248, 0.6); }\n.exam-nav-btn:active { transform: scale(0.95); }\n.exam-submit-btn {\n width: 100%; padding: 10px;\n background: rgba(0, 74, 198, 0.9); color: var(--on-primary);\n border: none; border-radius: var(--radius-default);\n font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 600;\n cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px;\n transition: all 0.15s ease;\n box-shadow: 0 1px 4px rgba(0, 74, 198, 0.2);\n}\n.exam-submit-btn:hover { filter: brightness(1.1); }\n.exam-submit-btn:active { transform: scale(0.98); }\n.exam-cancel-btn {\n width: 100%; padding: 10px;\n background: var(--error-container); color: var(--on-error-container);\n border: none; border-radius: var(--radius-default);\n font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 700;\n cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px;\n transition: all 0.15s ease;\n}\n.exam-cancel-btn:hover { filter: brightness(0.98); }\n.exam-cancel-btn:active { transform: scale(0.98); }\n\n/* ============================================\n VIEW: Settings\n ============================================ */\n.settings-content {\n display: flex; flex-direction: column; gap: 20px;\n}\n.settings-group {\n display: flex; flex-direction: column; gap: 4px;\n}\n.setting-item {\n display: flex; align-items: center; justify-content: space-between;\n padding: 12px;\n background: var(--surface-container-low);\n border-radius: var(--radius-default);\n border: 1px solid rgba(195, 198, 215, 0.2);\n}\n.setting-info { display: flex; flex-direction: column; gap: 2px; }\n.setting-label { font-size: 13px; font-weight: 500; color: var(--on-surface); }\n.setting-desc { font-size: 11px; color: var(--on-surface-variant); }\n.toggle {\n position: relative; display: inline-block;\n width: 40px; height: 22px;\n}\n.toggle input { opacity: 0; width: 0; height: 0; }\n.toggle-slider {\n position: absolute; cursor: pointer;\n top: 0; left: 0; right: 0; bottom: 0;\n background: var(--outline-variant);\n border-radius: var(--radius-full);\n transition: 0.3s;\n}\n.toggle-slider::before {\n content: ''; position: absolute;\n height: 16px; width: 16px;\n left: 3px; bottom: 3px;\n background: white; border-radius: 50%;\n transition: 0.3s;\n}\n.toggle input:checked + .toggle-slider { background: var(--primary); }\n.toggle input:checked + .toggle-slider::before { transform: translateX(18px); }\n.btn-danger {\n display: flex; align-items: center; justify-content: center; gap: 8px;\n padding: 12px;\n background: var(--error-container); color: var(--on-error-container);\n border: none; border-radius: var(--radius-default);\n font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 600;\n cursor: pointer; transition: all 0.15s ease;\n}\n.btn-danger:hover { opacity: 0.9; }\n.btn-danger:active { transform: scale(0.98); }\n.btn-danger .material-symbols-outlined { font-size: 18px; }\n";
const PANEL_HTML = "<div class=\"plugin-container\">\n\n <!-- ============================================\n VIEW: History (Default)\n ============================================ -->\n <div class=\"view\" id=\"view-timer\">\n <!-- TopAppBar -->\n <header class=\"top-app-bar\">\n <div class=\"app-bar-left\">\n <span class=\"app-title\">PTA Timer</span>\n </div>\n <div class=\"app-bar-right\">\n <button class=\"icon-btn\" id=\"nav-exams-btn\" title=\"考试列表\">\n <span class=\"material-symbols-outlined\">assignment</span>\n </button>\n <button class=\"icon-btn\" id=\"pin-btn\" title=\"固定\">\n <span class=\"material-symbols-outlined\">push_pin</span>\n </button>\n <button class=\"icon-btn\" id=\"settings-btn\" title=\"设置\">\n <span class=\"material-symbols-outlined\">settings</span>\n </button>\n </div>\n </header>\n\n <!-- Main Content -->\n <main class=\"view-content history-content\">\n <section class=\"history-hero\">\n <div>\n <p class=\"history-eyebrow\">学习历史</p>\n <h1 class=\"history-title\">历史记录</h1>\n </div>\n <span class=\"history-hero-icon material-symbols-outlined\">history</span>\n </section>\n\n <!-- Sessions List -->\n <div class=\"quick-stats history-panel\">\n <div class=\"stats-header history-stats-header\">\n <span class=\"stats-label\">做题记录</span>\n <span class=\"stats-hint\">按时间倒序排列</span>\n </div>\n <div class=\"sessions-list\" id=\"sessions-list\">\n <div class=\"empty-state\">\n <span class=\"material-symbols-outlined icon-empty\">history</span>\n <span class=\"empty-text\">暂无记录</span>\n </div>\n </div>\n </div>\n </main>\n\n <!-- Footer -->\n <footer class=\"app-footer\">\n <div class=\"footer-status\">\n <div class=\"status-dot\" id=\"status-dot\"></div>\n <span class=\"status-text\" id=\"status-text\">未连接</span>\n </div>\n <span class=\"version-text\">v2.0.0</span>\n </footer>\n </div>\n\n <!-- ============================================\n VIEW: Exam List\n ============================================ -->\n <div class=\"view\" id=\"view-exam-list\" style=\"display: none;\">\n <header class=\"top-app-bar\">\n <div class=\"app-bar-left\">\n <button class=\"icon-btn\" id=\"exam-list-back-btn\">\n <span class=\"material-symbols-outlined\">arrow_back</span>\n </button>\n <span class=\"app-title\">PTA Timer</span>\n </div>\n <div class=\"app-bar-right\">\n <button class=\"icon-btn\" title=\"固定\">\n <span class=\"material-symbols-outlined\">push_pin</span>\n </button>\n </div>\n </header>\n\n <main class=\"view-content exam-list-content\">\n <!-- Contextual Header -->\n <div class=\"context-header\">\n <h1 class=\"context-title\">考试列表</h1>\n <div class=\"search-bar\">\n <span class=\"material-symbols-outlined search-icon\">search</span>\n <input type=\"text\" class=\"search-input\" id=\"exam-search-input\" placeholder=\"搜索考试...\">\n </div>\n <button class=\"btn-current-page-problems\" id=\"load-current-page-problems-btn\">\n <span class=\"material-symbols-outlined\">playlist_add_check</span>\n 从当前页面选择题目\n </button>\n </div>\n\n <!-- Scrollable Exam List -->\n <div class=\"exam-list-scroll\" id=\"exam-list-scroll\">\n <!-- Items will be rendered by JS -->\n </div>\n </main>\n\n <!-- Footer: Return to Timer -->\n <footer class=\"exam-list-footer\">\n <button class=\"btn-return-timer\" id=\"return-timer-btn\">\n <span class=\"material-symbols-outlined\">history</span>\n 返回历史记录\n </button>\n </footer>\n </div>\n\n <!-- ============================================\n VIEW: Exam Problem List\n ============================================ -->\n <div class=\"view\" id=\"view-exam-problems\" style=\"display: none;\">\n <header class=\"top-app-bar\">\n <div class=\"app-bar-left\">\n <span class=\"app-title\">PTA Timer</span>\n </div>\n <div class=\"app-bar-right\">\n <button class=\"icon-btn\" title=\"固定\">\n <span class=\"material-symbols-outlined\">push_pin</span>\n </button>\n </div>\n </header>\n\n <div class=\"exam-problems-layout\">\n <!-- Main Canvas -->\n <main class=\"exam-problems-main\">\n <!-- Back to Exams -->\n <div class=\"exam-problems-back\">\n <button class=\"btn-text-primary\" id=\"back-to-exams-btn\">\n <span class=\"material-symbols-outlined\">arrow_back</span>\n 返回考试列表\n </button>\n </div>\n\n <!-- Exam Title -->\n <div class=\"exam-title-section\">\n <h1 class=\"exam-title\" id=\"exam-title\">考试名称</h1>\n <div class=\"exam-meta\">\n <span class=\"exam-status-badge\" id=\"exam-status-badge\">进行中</span>\n <span class=\"exam-duration\">\n <span class=\"material-symbols-outlined\" style=\"font-size: 14px;\">schedule</span>\n <span id=\"exam-duration-text\">180 min</span>\n </span>\n </div>\n </div>\n\n <div class=\"custom-exam-toolbar\" id=\"custom-exam-toolbar\" style=\"display: none;\">\n <span class=\"custom-exam-count\" id=\"custom-exam-count\">已选择 0 题</span>\n <div class=\"custom-exam-actions\">\n <input class=\"custom-exam-duration-input\" id=\"custom-exam-duration-input\" type=\"number\" min=\"1\" max=\"999\" placeholder=\"限时/分钟\">\n <button class=\"btn-text-primary\" id=\"select-all-problems-btn\">全选</button>\n <button class=\"problem-list-start-btn\" id=\"create-custom-exam-btn\">创建考试</button>\n <button class=\"btn-danger-sm\" id=\"delete-custom-exam-btn\" style=\"display: none;\">删除考试</button>\n </div>\n </div>\n\n <!-- Problem List -->\n <div class=\"problem-list\" id=\"problem-list\">\n <!-- Items rendered by JS -->\n </div>\n\n <!-- Progress Stats -->\n <div class=\"progress-stats\">\n <div class=\"progress-info\">\n <span class=\"progress-label\">总进度</span>\n <span class=\"progress-value\" id=\"progress-value\">0 / 0 pts</span>\n </div>\n <div class=\"progress-bar-container\">\n <div class=\"progress-bar-fill\" id=\"progress-bar-fill\"></div>\n </div>\n <span class=\"progress-percent\" id=\"progress-percent\">0%</span>\n </div>\n </main>\n </div>\n </div>\n\n <!-- ============================================\n VIEW: Exam Mode (Minimalist Compact)\n ============================================ -->\n <div class=\"view\" id=\"view-exam-mode\" style=\"display: none;\">\n <!-- Global Exam Timer Bar -->\n <div class=\"global-timer-bar\">\n <div class=\"global-timer-left\">\n <span class=\"material-symbols-outlined\" style=\"font-size: 14px; color: var(--primary);\">schedule</span>\n <span class=\"global-timer-label\">全局</span>\n </div>\n <div class=\"global-timer-right\">\n <span class=\"global-timer-value\" id=\"exam-global-timer\">00:00:00</span>\n <div class=\"global-progress-bar\">\n <div class=\"global-progress-fill\" id=\"exam-global-progress\"></div>\n </div>\n </div>\n </div>\n\n <!-- TopAppBar -->\n <header class=\"top-app-bar compact\">\n <div class=\"app-bar-left\">\n <span class=\"app-title-sm\">PTA</span>\n <span class=\"exam-badge\">Exam</span>\n </div>\n <div class=\"app-bar-right\">\n <button class=\"icon-btn-sm\" title=\"固定\">\n <span class=\"material-symbols-outlined\" style=\"font-size: 16px;\">push_pin</span>\n </button>\n <button class=\"icon-btn-sm\" id=\"exam-mode-close-btn\" title=\"关闭\">\n <span class=\"material-symbols-outlined\" style=\"font-size: 16px;\">close</span>\n </button>\n </div>\n </header>\n\n <!-- Main Content -->\n <main class=\"exam-mode-content\">\n <!-- Current Problem Header -->\n <div class=\"exam-problem-header\">\n <div class=\"exam-problem-info\">\n <p class=\"exam-problem-index\" id=\"exam-problem-index\">题目 1/5</p>\n <h3 class=\"exam-problem-name\" id=\"exam-problem-name\">当前题目</h3>\n </div>\n <div class=\"exam-problem-difficulty\" id=\"exam-problem-difficulty\">Med</div>\n </div>\n\n <!-- Problem Timer -->\n <div class=\"exam-timer-display\">\n <div class=\"exam-timer-info\">\n <p class=\"exam-timer-label\">考试剩余</p>\n <div class=\"exam-timer-value\" id=\"exam-timer-value\">00:00:00</div>\n </div>\n <div class=\"exam-timer-controls\">\n <button class=\"exam-ctrl-btn\" id=\"exam-play-pause-btn\" title=\"暂停\">\n <span class=\"material-symbols-outlined\">pause</span>\n </button>\n <button class=\"exam-ctrl-btn\" id=\"exam-reset-btn\" title=\"重置\">\n <span class=\"material-symbols-outlined\">history</span>\n </button>\n </div>\n </div>\n\n <!-- Navigation Controls -->\n <div class=\"exam-nav-controls\">\n <button class=\"exam-nav-btn\" id=\"exam-prev-btn\">\n <span class=\"material-symbols-outlined\" style=\"font-size: 16px;\">chevron_left</span>\n 上一题\n </button>\n <button class=\"exam-nav-btn\" id=\"exam-next-btn\">\n 下一题\n <span class=\"material-symbols-outlined\" style=\"font-size: 16px;\">chevron_right</span>\n </button>\n </div>\n\n <!-- Submit Button -->\n <button class=\"exam-submit-btn\" id=\"exam-submit-btn\">\n <span class=\"material-symbols-outlined\" style=\"font-size: 18px; font-variation-settings: 'FILL' 1;\">task_alt</span>\n 提交考试\n </button>\n <button class=\"exam-cancel-btn\" id=\"exam-cancel-btn\">\n <span class=\"material-symbols-outlined\" style=\"font-size: 18px;\">close</span>\n 取消考试\n </button>\n </main>\n\n <!-- Footer -->\n <footer class=\"app-footer compact\">\n <div class=\"footer-status\">\n <div class=\"status-dot connected\"></div>\n <span class=\"status-text\">已连接</span>\n </div>\n <span class=\"version-text\">v2.0.0</span>\n </footer>\n </div>\n\n <!-- ============================================\n VIEW: Settings\n ============================================ -->\n <div class=\"view\" id=\"view-settings\" style=\"display: none;\">\n <header class=\"top-app-bar\">\n <div class=\"app-bar-left\">\n <button class=\"icon-btn\" id=\"settings-back-btn\">\n <span class=\"material-symbols-outlined\">arrow_back</span>\n </button>\n <span class=\"app-title\">设置</span>\n </div>\n <div class=\"app-bar-right\"></div>\n </header>\n\n <main class=\"view-content\">\n <div class=\"settings-content\">\n <div class=\"settings-group\">\n <div class=\"setting-item\">\n <div class=\"setting-info\">\n <span class=\"setting-label\">自动开始计时</span>\n <span class=\"setting-desc\">进入题目页面时自动计时</span>\n </div>\n <label class=\"toggle\">\n <input type=\"checkbox\" id=\"auto-start-toggle\" checked>\n <span class=\"toggle-slider\"></span>\n </label>\n </div>\n <div class=\"setting-item\">\n <div class=\"setting-info\">\n <span class=\"setting-label\">倒计时提醒</span>\n <span class=\"setting-desc\">剩余时间不足时发出提醒</span>\n </div>\n <label class=\"toggle\">\n <input type=\"checkbox\" id=\"notification-toggle\" checked>\n <span class=\"toggle-slider\"></span>\n </label>\n </div>\n </div>\n <div class=\"settings-group\">\n <button class=\"btn-danger\" id=\"clear-data-btn\">\n <span class=\"material-symbols-outlined\">delete</span>\n 清除所有数据\n </button>\n </div>\n </div>\n </main>\n </div>\n\n <div class=\"confirm-overlay\" id=\"delete-confirm-overlay\" aria-hidden=\"true\">\n <div class=\"confirm-dialog\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"delete-confirm-title\">\n <div class=\"confirm-icon\">\n <span class=\"material-symbols-outlined\">delete</span>\n </div>\n <div class=\"confirm-copy\">\n <h2 class=\"confirm-title\" id=\"delete-confirm-title\">删除这条记录?</h2>\n <p class=\"confirm-message\">删除后无法恢复,确定要继续吗?</p>\n </div>\n <div class=\"confirm-actions\">\n <button class=\"confirm-btn confirm-cancel\" id=\"delete-confirm-cancel\">取消</button>\n <button class=\"confirm-btn confirm-delete\" id=\"delete-confirm-ok\">删除</button>\n </div>\n </div>\n </div>\n </div>";
let activeContext = 'runtime';
let panelHost = null;
let panelShadow = null;
let panelInitialized = false;
let panelDocument = null;
let initializePanelApp = null;
function callSoon(callback, value) {
if (typeof callback !== 'function') return;
setTimeout(() => callback(value), 0);
}
function cloneValue(value) {
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value));
} catch (error) {
return value;
}
}
function hasGM(name) {
return typeof globalThis[name] === 'function';
}
function storageKey(key) {
return `ptaTimer.${key}`;
}
function readValue(key) {
if (hasGM('GM_getValue')) return cloneValue(GM_getValue(key));
const raw = localStorage.getItem(storageKey(key));
if (raw === null) return undefined;
try { return JSON.parse(raw); } catch (error) { return raw; }
}
function writeValue(key, value) {
if (hasGM('GM_setValue')) {
GM_setValue(key, cloneValue(value));
return;
}
localStorage.setItem(storageKey(key), JSON.stringify(value));
}
function deleteValue(key) {
if (hasGM('GM_deleteValue')) {
GM_deleteValue(key);
return;
}
localStorage.removeItem(storageKey(key));
}
function listValues() {
if (hasGM('GM_listValues')) return GM_listValues();
const prefix = 'ptaTimer.';
return Object.keys(localStorage)
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
}
function normalizeKeys(keys) {
if (Array.isArray(keys)) return keys;
if (typeof keys === 'string') return [keys];
if (keys && typeof keys === 'object') return Object.keys(keys);
return listValues();
}
const storageListeners = new Set();
const runtimeListeners = [];
const contentListeners = [];
function emitStorageChanges(changes) {
if (!Object.keys(changes).length) return;
storageListeners.forEach((listener) => {
try { listener(changes, 'local'); } catch (error) { console.error('[PTA Timer] storage listener failed', error); }
});
}
function dispatchMessage(listeners, request, callback) {
if (!listeners.length) {
compat.runtime.lastError = { message: 'No receiving end' };
callSoon(callback);
setTimeout(() => { compat.runtime.lastError = null; }, 0);
return;
}
let responded = false;
let asyncResponse = false;
compat.runtime.lastError = null;
const sendResponse = (response) => {
if (responded) return;
responded = true;
compat.runtime.lastError = null;
callSoon(callback, response);
};
for (const listener of [...listeners]) {
try {
const result = listener(request, { tab: { id: 1, url: location.href } }, sendResponse);
if (result === true) asyncResponse = true;
if (responded) return;
} catch (error) {
compat.runtime.lastError = { message: error.message };
console.error('[PTA Timer] message listener failed', error);
}
}
if (!asyncResponse && !responded) callSoon(callback);
}
const compat = {
storage: {
local: {
get(keys, callback) {
const result = {};
if (keys == null) {
listValues().forEach((key) => { result[key] = readValue(key); });
} else if (typeof keys === 'string') {
result[keys] = readValue(keys);
} else if (Array.isArray(keys)) {
keys.forEach((key) => { result[key] = readValue(key); });
} else if (typeof keys === 'object') {
Object.keys(keys).forEach((key) => {
const value = readValue(key);
result[key] = value === undefined ? keys[key] : value;
});
}
callSoon(callback, result);
},
set(items, callback) {
const changes = {};
Object.entries(items || {}).forEach(([key, value]) => {
const oldValue = readValue(key);
writeValue(key, value);
changes[key] = { oldValue, newValue: cloneValue(value) };
});
callSoon(callback);
setTimeout(() => emitStorageChanges(changes), 0);
},
remove(keys, callback) {
const changes = {};
normalizeKeys(keys).forEach((key) => {
const oldValue = readValue(key);
deleteValue(key);
changes[key] = { oldValue, newValue: undefined };
});
callSoon(callback);
setTimeout(() => emitStorageChanges(changes), 0);
},
clear(callback) {
const changes = {};
listValues().forEach((key) => {
const oldValue = readValue(key);
deleteValue(key);
changes[key] = { oldValue, newValue: undefined };
});
callSoon(callback);
setTimeout(() => emitStorageChanges(changes), 0);
}
},
onChanged: {
addListener(listener) { storageListeners.add(listener); },
removeListener(listener) { storageListeners.delete(listener); }
}
},
runtime: {
lastError: null,
onMessage: {
addListener(listener) {
if (activeContext === 'content') contentListeners.push(listener);
else runtimeListeners.push(listener);
}
},
onStartup: { addListener() {} },
onInstalled: { addListener() {} },
sendMessage(request, callback) { dispatchMessage(runtimeListeners, request, callback); },
getURL(path) {
if (/icon48\.png$/.test(path)) return ICONS.icon48;
if (/icon128\.png$/.test(path)) return ICONS.icon128;
if (/icon16\.png$/.test(path)) return ICONS.icon16;
return ICONS.main;
}
},
tabs: {
query(_queryInfo, callback) { callSoon(callback, [{ id: 1, active: true, url: location.href }]); },
sendMessage(_tabId, request, callback) { dispatchMessage(contentListeners, request, callback); },
update(_tabId, updateProperties, callback) {
if (updateProperties && updateProperties.url) location.assign(updateProperties.url);
callSoon(callback, { id: 1, url: updateProperties?.url || location.href });
}
},
notifications: {
create(options, callback) {
notify(options?.title || 'PTA Timer', options?.message || options?.text || '');
callSoon(callback, String(Date.now()));
}
}
};
function notify(title, text) {
if (hasGM('GM_notification')) {
GM_notification({ title, text, image: ICONS.icon48, timeout: 6000 });
return;
}
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body: text, icon: ICONS.icon48 });
}
}
function injectFonts() {
if (document.querySelector('link[data-pta-timer-fonts]')) return;
const link = document.createElement('link');
link.dataset.ptaTimerFonts = 'true';
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&family=Material+Symbols+Outlined:wght,[email protected],0..1&display=swap';
document.head.appendChild(link);
}
function addStyle(css) {
if (hasGM('GM_addStyle')) {
GM_addStyle(css);
return;
}
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
const ICON_FALLBACKS = {
assignment: '▣',
history: '↺',
close: '×',
push_pin: '⌖',
settings: '⚙',
arrow_back: '←',
playlist_add_check: '☑',
search: '⌕',
schedule: '◷',
pause: 'Ⅱ',
play_arrow: '▶',
replay: '↻',
timer: '◴',
dashboard: '▦',
chevron_left: '‹',
chevron_right: '›',
task_alt: '✓',
check: '✓',
delete: '×',
calendar_today: '□',
timer_off: '◼'
};
function renderMaterialIcon(icon) {
const current = icon.textContent.trim();
const previousName = icon.dataset.icon;
const previousGlyph = previousName ? ICON_FALLBACKS[previousName] || previousName : '';
const name = current && current !== previousGlyph ? current : previousName;
if (!name) return;
const glyph = ICON_FALLBACKS[name] || name;
icon.dataset.icon = name;
icon.setAttribute('aria-hidden', 'true');
if (icon.textContent !== glyph) icon.textContent = glyph;
}
function withIconFallbacks(html) {
return html.replace(/<span([^>]*class="[^"]*\bmaterial-symbols-outlined\b[^"]*"[^>]*)>([a-z0-9_]+)<\/span>/g, (match, attrs, name) => {
const glyph = ICON_FALLBACKS[name];
if (!glyph) return match;
const nextAttrs = /\sdata-icon=/.test(attrs) ? attrs : `${attrs} data-icon="${name}"`;
return `<span${nextAttrs}>${glyph}</span>`;
});
}
function renderMaterialIcons(root = document) {
root.querySelectorAll?.('.material-symbols-outlined').forEach(renderMaterialIcon);
}
function watchMaterialIcons(root) {
renderMaterialIcons(root);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'characterData') {
const parent = mutation.target.parentElement;
const icon = parent?.classList?.contains('material-symbols-outlined') ? parent : parent?.closest?.('.material-symbols-outlined');
if (icon) renderMaterialIcon(icon);
return;
}
const target = mutation.target;
if (target?.classList?.contains?.('material-symbols-outlined')) {
renderMaterialIcon(target);
}
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
if (node.classList?.contains('material-symbols-outlined')) renderMaterialIcon(node);
renderMaterialIcons(node);
});
});
});
observer.observe(root, { childList: true, subtree: true, characterData: true });
}
function refreshIconFallbacks() {
renderMaterialIcons(document);
if (panelShadow) renderMaterialIcons(panelShadow);
}
function refreshIconFallbacksSoon() {
refreshIconFallbacks();
[50, 150, 350, 800, 1500].forEach((delay) => setTimeout(refreshIconFallbacks, delay));
}
function runModule(context, factory) {
const previous = activeContext;
activeContext = context;
try { factory(); } finally { activeContext = previous; }
}
function ensurePanel() {
if (panelHost) return panelHost;
panelHost = document.createElement('div');
panelHost.id = 'pta-timer-panel-host';
panelShadow = panelHost.attachShadow({ mode: 'open' });
panelShadow.innerHTML = withIconFallbacks(`
<style>${PANEL_CSS}</style>
<div class="pta-panel-backdrop" data-panel-close></div>
<div class="pta-panel-shell" role="dialog" aria-modal="true" aria-label="PTA Timer">
<button class="pta-panel-close" id="pta-panel-close" title="关闭">
<span class="material-symbols-outlined">close</span>
</button>
${PANEL_HTML}
</div>
`);
document.body.appendChild(panelHost);
panelDocument = {
getElementById: (id) => panelShadow.getElementById(id),
querySelector: (selector) => panelShadow.querySelector(selector),
querySelectorAll: (selector) => panelShadow.querySelectorAll(selector),
createElement: (...args) => document.createElement(...args),
addEventListener: (...args) => document.addEventListener(...args),
removeEventListener: (...args) => document.removeEventListener(...args)
};
panelShadow.querySelector('[data-panel-close]').addEventListener('click', closePanel);
panelShadow.getElementById('pta-panel-close').addEventListener('click', closePanel);
panelShadow.querySelectorAll('[title="固定"]').forEach((button) => { button.style.display = 'none'; });
watchMaterialIcons(panelShadow);
if (!panelInitialized && typeof initializePanelApp === 'function') {
panelInitialized = true;
initializePanelApp();
refreshIconFallbacksSoon();
}
return panelHost;
}
function openPanel() {
ensurePanel();
panelHost.classList.add('visible');
refreshIconFallbacksSoon();
}
function closePanel() {
if (panelHost) panelHost.classList.remove('visible');
}
function installPanelLauncher() {
if (!document.body || document.getElementById('pta-timer-launcher')) return;
const launcher = document.createElement('button');
launcher.id = 'pta-timer-launcher';
launcher.type = 'button';
launcher.title = '打开 PTA Timer 面板';
launcher.textContent = 'PTA';
launcher.addEventListener('click', openPanel);
document.body.appendChild(launcher);
}
function registerMenuCommand() {
if (hasGM('GM_registerMenuCommand')) {
GM_registerMenuCommand('打开 PTA Timer 面板', openPanel);
}
}
window.PTATimerUserscript = { openPanel, closePanel };
injectFonts();
addStyle(CONTENT_CSS + `
#pta-timer-launcher {
position: fixed;
right: 16px;
bottom: 18px;
z-index: 9998;
width: 44px;
height: 44px;
border: 1px solid rgba(255,255,255,0.55);
border-radius: 14px;
background: rgba(0, 74, 198, 0.92);
color: #fff;
box-shadow: 0 12px 30px rgba(0, 74, 198, 0.24);
font: 800 11px 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
letter-spacing: 0.08em;
cursor: pointer;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
#pta-timer-launcher:hover { background: #2563eb; transform: translateY(-1px); }
`);
registerMenuCommand();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', installPanelLauncher, { once: true });
} else {
installPanelLauncher();
}
runModule('background', function () {
// PTA Timer - Background Service Worker
// Manages timer state and session persistence
let timerData = {
isRunning: false,
isPaused: false,
startTime: null,
pauseStartTime: null,
pausedDuration: 0,
currentSession: null,
totalTime: 0,
sessions: []
};
let storageLoaded = false;
let storageLoadInProgress = false;
let pendingLoadCallbacks = [];
// Message handler
compat.runtime.onMessage.addListener((request, sender, sendResponse) => {
loadStoredData(() => {
normalizeTimerData();
switch (request.action) {
case 'startTimer':
startTimer(request.problemId, request.problemName);
sendResponse({ success: true });
break;
case 'stopTimer':
stopTimer(false);
sendResponse({ success: true });
break;
case 'completeTimer':
stopTimer(true);
sendResponse({ success: true });
break;
case 'togglePauseTimer':
sendResponse(togglePauseTimer());
break;
case 'deleteSession':
deleteSession(request.sessionIndex);
sendResponse({ success: true });
break;
case 'startCustomExam':
startCustomExam(request.exam, () => sendResponse({ success: true }));
break;
case 'getActiveCustomExam':
getActiveCustomExam((exam) => sendResponse({ success: true, exam: exam }));
break;
case 'setCurrentExamProblem':
setCurrentExamProblem(request.problemIndex, () => sendResponse({ success: true }));
break;
case 'markExamProblemSolved':
markExamProblemSolved(request.problemId, () => sendResponse({ success: true }));
break;
case 'finishCustomExam':
finishCustomExam(() => sendResponse({ success: true }));
break;
case 'clearAllData':
clearAllData(() => sendResponse({ success: true }));
break;
case 'getTimerData':
sendResponse(timerData);
break;
case 'setCountdown':
setCountdown(request.duration);
sendResponse({ success: true });
break;
case 'getCountdown':
sendResponse({ countdown: countdownData });
break;
default:
sendResponse({ success: false });
}
});
return true; // Keep message channel open for async responses
});
function loadStoredData(callback) {
if (storageLoaded) {
callback();
return;
}
pendingLoadCallbacks.push(callback);
if (storageLoadInProgress) {
return;
}
storageLoadInProgress = true;
compat.storage.local.get(['timerData', 'countdownData'], (result) => {
if (result.timerData) {
timerData = { ...timerData, ...result.timerData };
}
if (result.countdownData) {
countdownData = result.countdownData;
if (countdownData.isRunning) {
startCountdownLoop();
}
}
storageLoaded = true;
storageLoadInProgress = false;
const callbacks = pendingLoadCallbacks;
pendingLoadCallbacks = [];
callbacks.forEach((pendingCallback) => pendingCallback());
});
}
function normalizeTimerData() {
timerData.sessions = Array.isArray(timerData.sessions) ? timerData.sessions : [];
timerData.isPaused = Boolean(timerData.isPaused);
timerData.pausedDuration = Number(timerData.pausedDuration) || 0;
timerData.pauseStartTime = timerData.pauseStartTime || null;
timerData.sessions.forEach((session) => {
if (!session.problemName) {
session.problemName = session.problemId || '未知题目';
}
});
if (timerData.currentSession && !timerData.currentSession.problemName) {
timerData.currentSession.problemName = timerData.currentSession.problemId || '计时中...';
}
}
function startTimer(problemId, problemName) {
if (timerData.isRunning && timerData.currentSession?.problemId === problemId) {
if (problemName && timerData.currentSession.problemName !== problemName) {
timerData.currentSession.problemName = problemName;
compat.storage.local.set({ timerData: timerData });
}
return;
}
if (timerData.isRunning) {
stopTimer(false);
}
timerData.isRunning = true;
timerData.isPaused = false;
timerData.startTime = Date.now();
timerData.pauseStartTime = null;
timerData.pausedDuration = 0;
timerData.currentSession = {
problemId: problemId,
problemName: problemName || problemId,
startTime: timerData.startTime,
endTime: null
};
compat.storage.local.set({ timerData: timerData });
}
function stopTimer(saveSession) {
if (timerData.isRunning && timerData.currentSession) {
const endTime = Date.now();
const pausedDuration = timerData.pausedDuration +
(timerData.isPaused && timerData.pauseStartTime ? endTime - timerData.pauseStartTime : 0);
const completedSession = {
...timerData.currentSession,
endTime: endTime,
duration: Math.max(0, endTime - timerData.currentSession.startTime - pausedDuration)
};
timerData.isRunning = false;
timerData.isPaused = false;
if (saveSession) {
timerData.sessions.push(completedSession);
timerData.totalTime += completedSession.duration;
}
timerData.currentSession = null;
timerData.startTime = null;
timerData.pauseStartTime = null;
timerData.pausedDuration = 0;
compat.storage.local.set({ timerData: timerData });
}
}
function togglePauseTimer() {
if (!timerData.isRunning || !timerData.currentSession) {
return { success: false, timerData: timerData };
}
if (timerData.isPaused) {
timerData.pausedDuration += Date.now() - timerData.pauseStartTime;
timerData.pauseStartTime = null;
timerData.isPaused = false;
} else {
timerData.pauseStartTime = Date.now();
timerData.isPaused = true;
}
compat.storage.local.set({ timerData: timerData });
return { success: true, timerData: timerData };
}
function deleteSession(sessionIndex) {
if (!Number.isInteger(sessionIndex) || !timerData.sessions[sessionIndex]) {
return;
}
const [session] = timerData.sessions.splice(sessionIndex, 1);
timerData.totalTime = Math.max(0, timerData.totalTime - (session.duration || 0));
compat.storage.local.set({ timerData: timerData });
}
function startCustomExam(exam, callback) {
if (!exam) {
callback();
return;
}
const activeExam = {
...exam,
currentProblemIndex: exam.currentProblemIndex || 0,
problems: Array.isArray(exam.problems) ? exam.problems : []
};
compat.storage.local.remove(['customExamSelectionPreview'], () => {
compat.storage.local.set({
activeCustomExam: activeExam,
isExamMode: true,
currentExam: activeExam,
currentProblemIndex: activeExam.currentProblemIndex,
examStartTime: activeExam.startedAt,
examDuration: activeExam.duration
}, callback);
});
}
function getActiveCustomExam(callback) {
compat.storage.local.get(['activeCustomExam'], (result) => {
callback(result.activeCustomExam || null);
});
}
function setCurrentExamProblem(problemIndex, callback) {
compat.storage.local.get(['activeCustomExam', 'currentExam'], (result) => {
const index = Number(problemIndex) || 0;
const updates = { currentProblemIndex: index };
if (result.activeCustomExam) {
updates.activeCustomExam = { ...result.activeCustomExam, currentProblemIndex: index };
}
if (result.currentExam?.mode === 'custom') {
updates.currentExam = { ...result.currentExam, currentProblemIndex: index };
}
compat.storage.local.set(updates, callback);
});
}
function markExamProblemSolved(problemId, callback) {
if (!problemId) {
callback();
return;
}
compat.storage.local.get(['activeCustomExam', 'currentExam'], (result) => {
if (!result.activeCustomExam?.problems) {
callback();
return;
}
const solvedAt = Date.now();
const latestSession = [...timerData.sessions].reverse().find((session) => session.problemId === problemId);
const updateProblems = (problems) => problems.map((problem) => {
if (problem.id !== problemId) {
return problem;
}
return {
...problem,
status: 'solved',
solvedAt: solvedAt,
duration: latestSession?.duration || problem.duration || 0
};
});
const activeCustomExam = {
...result.activeCustomExam,
problems: updateProblems(result.activeCustomExam.problems)
};
const updates = { activeCustomExam: activeCustomExam };
if (result.currentExam?.mode === 'custom') {
updates.currentExam = {
...result.currentExam,
problems: updateProblems(result.currentExam.problems || [])
};
}
compat.storage.local.set(updates, callback);
});
}
function finishCustomExam(callback) {
compat.storage.local.remove([
'activeCustomExam',
'customExamSelectionPreview',
'isExamMode',
'currentExam',
'currentProblemIndex',
'examStartTime',
'examDuration'
], callback);
}
function clearAllData(callback) {
compat.storage.local.clear(() => {
timerData = {
isRunning: false,
isPaused: false,
startTime: null,
pauseStartTime: null,
pausedDuration: 0,
currentSession: null,
totalTime: 0,
sessions: []
};
countdownData = {
isRunning: false,
duration: 0,
remaining: 0,
startTime: null
};
storageLoaded = true;
callback();
});
}
// Countdown
let countdownData = {
isRunning: false,
duration: 0,
remaining: 0,
startTime: null
};
function setCountdown(duration) {
countdownData.duration = duration * 60 * 1000;
countdownData.remaining = countdownData.duration;
countdownData.startTime = Date.now();
countdownData.isRunning = true;
startCountdownLoop();
compat.storage.local.set({ countdownData: countdownData });
}
function startCountdownLoop() {
if (countdownData.isRunning) {
const elapsed = Date.now() - countdownData.startTime;
countdownData.remaining = Math.max(0, countdownData.duration - elapsed);
if (countdownData.remaining <= 0) {
countdownData.isRunning = false;
compat.notifications.create({
type: 'basic',
iconUrl: 'icons/icon48.png',
title: 'PTA Timer',
message: '倒计时结束!'
});
} else {
setTimeout(startCountdownLoop, 1000);
}
compat.storage.local.set({ countdownData: countdownData });
}
}
// Load persisted data on startup
compat.runtime.onStartup.addListener(() => {
compat.storage.local.get(['timerData', 'countdownData'], (result) => {
if (result.timerData) {
timerData = result.timerData;
}
if (result.countdownData) {
countdownData = result.countdownData;
if (countdownData.isRunning) {
startCountdownLoop();
}
}
});
});
// Also load on install/update
compat.runtime.onInstalled.addListener(() => {
compat.storage.local.get(['timerData'], (result) => {
if (result.timerData) {
timerData = result.timerData;
}
});
});
});
runModule('popup', function () {
// PTA Timer - Popup Script
// Multi-View: Timer, Exam List, Exam Problems, Exam Mode, Settings
initializePanelApp = function initPanelApp() {
const document = panelDocument;
// ============================================
// State
// ============================================
let currentView = 'timer';
let countdownVisible = false;
let examData = []; // Loaded exam sessions
let currentExam = null; // Currently selected exam
let activeCustomExam = null;
let currentProblemIndex = 0; // Current problem in exam mode
let pendingConfirmAction = null;
let selectedProblemIds = new Set();
let customExamSelectionMode = false;
// ============================================
// Views
// ============================================
const views = {
timer: document.getElementById('view-timer'),
examList: document.getElementById('view-exam-list'),
examProblems: document.getElementById('view-exam-problems'),
examMode: document.getElementById('view-exam-mode'),
settings: document.getElementById('view-settings')
};
// ============================================
// DOM References - Timer View
// ============================================
const timerValue = document.getElementById('timer-value');
const playPauseBtn = document.getElementById('play-pause-btn');
const resetBtn = document.getElementById('reset-btn');
const countdownBtn = document.getElementById('countdown-btn');
const countdownInputSection = document.getElementById('countdown-input-section');
const countdownInput = document.getElementById('countdown-input');
const startCountdownBtn = document.getElementById('start-countdown-btn');
const cancelCountdownBtn = document.getElementById('cancel-countdown-btn');
const sessionsList = document.getElementById('sessions-list');
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const navExamsBtn = document.getElementById('nav-exams-btn');
const settingsBtn = document.getElementById('settings-btn');
const confirmOverlay = document.getElementById('delete-confirm-overlay');
const confirmCancel = document.getElementById('delete-confirm-cancel');
const confirmOk = document.getElementById('delete-confirm-ok');
const confirmTitle = document.getElementById('delete-confirm-title');
const confirmMessage = document.querySelector('.confirm-message');
const confirmIcon = document.querySelector('.confirm-icon .material-symbols-outlined');
// DOM References - Exam List View
const examListBackBtn = document.getElementById('exam-list-back-btn');
const examSearchInput = document.getElementById('exam-search-input');
const examListScroll = document.getElementById('exam-list-scroll');
const returnTimerBtn = document.getElementById('return-timer-btn');
const loadCurrentPageProblemsBtn = document.getElementById('load-current-page-problems-btn');
// DOM References - Exam Problems View
const backToExamsBtn = document.getElementById('back-to-exams-btn');
const examTitle = document.getElementById('exam-title');
const examStatusBadge = document.getElementById('exam-status-badge');
const examDurationText = document.getElementById('exam-duration-text');
const examDurationMeta = examDurationText?.closest('.exam-duration');
const problemList = document.getElementById('problem-list');
const customExamToolbar = document.getElementById('custom-exam-toolbar');
const customExamCount = document.getElementById('custom-exam-count');
const customExamDurationInput = document.getElementById('custom-exam-duration-input');
const selectAllProblemsBtn = document.getElementById('select-all-problems-btn');
const createCustomExamBtn = document.getElementById('create-custom-exam-btn');
const deleteCustomExamBtn = document.getElementById('delete-custom-exam-btn');
const progressValue = document.getElementById('progress-value');
const progressBarFill = document.getElementById('progress-bar-fill');
const progressPercent = document.getElementById('progress-percent');
// DOM References - Exam Mode View
const examGlobalTimer = document.getElementById('exam-global-timer');
const examGlobalProgress = document.getElementById('exam-global-progress');
const examProblemIndex = document.getElementById('exam-problem-index');
const examProblemName = document.getElementById('exam-problem-name');
const examProblemDifficulty = document.getElementById('exam-problem-difficulty');
const examTimerValue = document.getElementById('exam-timer-value');
const examPlayPauseBtn = document.getElementById('exam-play-pause-btn');
const examResetBtn = document.getElementById('exam-reset-btn');
const examPrevBtn = document.getElementById('exam-prev-btn');
const examNextBtn = document.getElementById('exam-next-btn');
const examSubmitBtn = document.getElementById('exam-submit-btn');
const examCancelBtn = document.getElementById('exam-cancel-btn');
const examModeCloseBtn = document.getElementById('exam-mode-close-btn');
// DOM References - Settings View
const settingsBackBtn = document.getElementById('settings-back-btn');
const clearDataBtn = document.getElementById('clear-data-btn');
const autoStartToggle = document.getElementById('auto-start-toggle');
const notificationToggle = document.getElementById('notification-toggle');
// ============================================
// Initialize
// ============================================
loadSettings();
loadExamData();
updateTimerDisplay();
setInterval(updateTimerDisplay, 1000);
checkExamMode();
// ============================================
// Navigation Events
// ============================================
navExamsBtn.addEventListener('click', () => switchView('examList'));
settingsBtn.addEventListener('click', () => switchView('settings'));
examListBackBtn.addEventListener('click', () => switchView('timer'));
returnTimerBtn.addEventListener('click', () => switchView('timer'));
loadCurrentPageProblemsBtn.addEventListener('click', loadCurrentPageProblems);
backToExamsBtn.addEventListener('click', () => switchView('examList'));
settingsBackBtn.addEventListener('click', () => switchView('timer'));
examModeCloseBtn.addEventListener('click', () => switchView('examList'));
// Timer View Events
playPauseBtn?.addEventListener('click', togglePlayPause);
resetBtn?.addEventListener('click', resetTimer);
countdownBtn?.addEventListener('click', toggleCountdownInput);
startCountdownBtn?.addEventListener('click', startCountdown);
cancelCountdownBtn?.addEventListener('click', () => {
countdownInputSection.style.display = 'none';
});
countdownInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') startCountdown();
});
// Exam Mode Events
examPlayPauseBtn.addEventListener('click', togglePlayPause);
examResetBtn.addEventListener('click', resetTimer);
examPrevBtn.addEventListener('click', () => navigateProblem(-1));
examNextBtn.addEventListener('click', () => navigateProblem(1));
examSubmitBtn.addEventListener('click', submitExam);
examCancelBtn.addEventListener('click', confirmDeleteCustomExam);
// Settings Events
autoStartToggle.addEventListener('change', saveSettings);
notificationToggle.addEventListener('change', saveSettings);
clearDataBtn.addEventListener('click', clearData);
confirmCancel.addEventListener('click', closeConfirm);
confirmOk.addEventListener('click', confirmPendingAction);
confirmOverlay.addEventListener('click', (e) => {
if (e.target === confirmOverlay) closeConfirm();
});
selectAllProblemsBtn.addEventListener('click', toggleSelectAllProblems);
createCustomExamBtn.addEventListener('click', confirmCreateCustomExam);
deleteCustomExamBtn.addEventListener('click', confirmDeleteCustomExam);
// Search
examSearchInput.addEventListener('input', renderExamList);
compat.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && changes.activeCustomExam) {
activeCustomExam = changes.activeCustomExam.newValue || null;
currentExam = activeCustomExam || currentExam;
if (currentView === 'examList') {
renderExamList();
}
if (currentView === 'examProblems') {
renderExamProblems();
}
}
});
// ============================================
// Settings
// ============================================
function loadSettings() {
compat.storage.local.get(['settings'], (result) => {
const settings = {
autoStart: result.settings?.autoStart !== false,
notifications: result.settings?.notifications !== false
};
autoStartToggle.checked = settings.autoStart;
notificationToggle.checked = settings.notifications;
});
}
function saveSettings() {
compat.storage.local.set({
settings: {
autoStart: autoStartToggle.checked,
notifications: notificationToggle.checked
}
});
}
// ============================================
// View Switching
// ============================================
function switchView(view) {
currentView = view;
Object.values(views).forEach(v => v.style.display = 'none');
switch (view) {
case 'timer':
views.timer.style.display = 'flex';
break;
case 'examList':
views.examList.style.display = 'flex';
renderExamList();
break;
case 'examProblems':
views.examProblems.style.display = 'flex';
renderExamProblems();
break;
case 'examMode':
views.examMode.style.display = 'flex';
updateExamModeDisplay();
break;
case 'settings':
views.settings.style.display = 'flex';
break;
}
}
// ============================================
// Check if we should show exam mode
// ============================================
function checkExamMode() {
compat.storage.local.get(['activeCustomExam', 'isExamMode', 'currentExam', 'currentProblemIndex'], (result) => {
activeCustomExam = result.activeCustomExam || null;
const storedExam = activeCustomExam || result.currentExam;
if (result.isExamMode && storedExam) {
currentExam = storedExam;
currentProblemIndex = storedExam.currentProblemIndex || result.currentProblemIndex || 0;
switchView('examMode');
}
});
}
// ============================================
// Timer Display Update
// ============================================
function updateTimerDisplay() {
compat.runtime.sendMessage({ action: 'getTimerData' }, (timerData) => {
if (compat.runtime.lastError || !timerData) {
statusDot.classList.remove('connected');
statusText.textContent = '未连接';
return;
}
statusDot.classList.add('connected');
statusText.textContent = '已连接';
if (timerData.isRunning && timerData.currentSession) {
const currentTime = getElapsedTime(timerData);
const timeStr = formatTime(currentTime);
if (timerValue) timerValue.textContent = timeStr;
updateExamProblemTimer(timerData);
updatePlayPauseIcons(timerData.isPaused);
} else if (currentView === 'examMode') {
updateExamProblemTimer(timerData);
updatePlayPauseIcons(false);
} else if (currentView === 'timer') {
if (timerValue) {
timerValue.textContent = '00:00:00';
timerValue.classList.remove('warning', 'danger');
}
updatePlayPauseIcons(false);
}
updateSessionsList(timerData.sessions || []);
});
updateCountdownDisplay();
if (currentView === 'examMode') updateExamGlobalTimer();
}
function updateExamProblemTimer(timerData) {
if (!examTimerValue) return;
compat.storage.local.get(['examStartTime', 'examDuration'], (result) => {
const elapsed = result.examStartTime ? Date.now() - result.examStartTime : 0;
const duration = Number(result.examDuration) || Number(currentExam?.duration) || 0;
const remaining = duration > 0 ? Math.max(0, duration - elapsed) : 0;
const minutes = Math.floor(remaining / 60000);
examTimerValue.textContent = formatTime(remaining);
examTimerValue.classList.remove('warning', 'danger');
if (duration > 0 && minutes <= 5) {
examTimerValue.classList.add('danger');
} else if (duration > 0 && minutes <= 10) {
examTimerValue.classList.add('warning');
}
});
}
// ============================================
// Countdown Display
// ============================================
function updateCountdownDisplay() {
compat.storage.local.get(['countdownData'], (result) => {
if (result.countdownData && result.countdownData.isRunning) {
const countdown = result.countdownData;
const elapsed = Date.now() - countdown.startTime;
const remaining = Math.max(0, countdown.duration - elapsed);
if (remaining > 0) {
const timeStr = formatCountdownTime(remaining);
if (timerValue) timerValue.textContent = timeStr;
if (examTimerValue) examTimerValue.textContent = timeStr;
const minutes = Math.floor(remaining / 60000);
timerValue?.classList.remove('warning', 'danger');
if (examTimerValue) examTimerValue.classList.remove('warning', 'danger');
if (minutes <= 5) {
timerValue?.classList.add('danger');
if (examTimerValue) examTimerValue.classList.add('danger');
} else if (minutes <= 10) {
timerValue?.classList.add('warning');
if (examTimerValue) examTimerValue.classList.add('warning');
}
} else {
timerValue?.classList.remove('warning', 'danger');
if (examTimerValue) examTimerValue.classList.remove('warning', 'danger');
}
}
});
}
// ============================================
// Exam Global Timer
// ============================================
function updateExamGlobalTimer() {
compat.storage.local.get(['examStartTime', 'examDuration'], (result) => {
if (result.examStartTime && result.examDuration) {
const elapsed = Date.now() - result.examStartTime;
const remaining = Math.max(0, result.examDuration - elapsed);
examGlobalTimer.textContent = formatTime(remaining);
const progress = ((result.examDuration - remaining) / result.examDuration) * 100;
examGlobalProgress.style.width = Math.min(100, progress) + '%';
} else if (result.examStartTime) {
const elapsed = Date.now() - result.examStartTime;
examGlobalTimer.textContent = formatTime(elapsed);
}
});
}
// ============================================
// Exam Mode Display
// ============================================
function updateExamModeDisplay() {
if (!currentExam || !currentExam.problems) return;
const prob = currentExam.problems[currentProblemIndex];
if (prob) {
examProblemIndex.textContent = `题目 ${currentProblemIndex + 1}/${currentExam.problems.length}`;
examProblemName.textContent = prob.name || prob.id || '未知题目';
examProblemDifficulty.textContent = prob.difficulty || '';
}
}
// ============================================
// Timer Controls
// ============================================
function togglePlayPause() {
compat.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (!tabs[0]) return;
compat.tabs.sendMessage(tabs[0].id, { action: 'togglePlayPause' }, () => {
if (compat.runtime.lastError) {
compat.runtime.sendMessage({ action: 'togglePauseTimer' }, (response) => {
if (compat.runtime.lastError || !response?.success) return;
updatePlayPauseIcons(response.timerData.isPaused);
updateTimerDisplay();
});
return;
}
setTimeout(updateTimerDisplay, 120);
});
});
}
function resetTimer() {
if (confirm('确定要重置当前计时吗?')) {
compat.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
compat.tabs.sendMessage(tabs[0].id, { action: 'resetTimer' }, () => {
if (compat.runtime.lastError) {
compat.runtime.sendMessage({ action: 'stopTimer' });
compat.storage.local.remove(['countdownData']);
}
if (timerValue) {
timerValue.textContent = '00:00:00';
timerValue.classList.remove('warning', 'danger');
}
if (examTimerValue) {
examTimerValue.textContent = '00:00:00';
examTimerValue.classList.remove('warning', 'danger');
}
updatePlayPauseIcons(false);
setTimeout(updateTimerDisplay, 100);
});
}
});
}
}
function toggleCountdownInput() {
if (!countdownInputSection || !countdownInput) return;
countdownVisible = !countdownVisible;
countdownInputSection.style.display = countdownVisible ? 'block' : 'none';
if (countdownVisible) countdownInput.focus();
}
function startCountdown() {
if (!countdownInput || !countdownInputSection) return;
const duration = parseInt(countdownInput.value);
if (duration && duration > 0) {
compat.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
compat.tabs.sendMessage(tabs[0].id, {
action: 'startCountdown',
duration: duration
});
}
});
countdownInput.value = '';
countdownInputSection.style.display = 'none';
countdownVisible = false;
} else {
countdownInput.style.borderColor = 'var(--error)';
setTimeout(() => { countdownInput.style.borderColor = ''; }, 1500);
}
}
// ============================================
// Exam Navigation
// ============================================
function navigateProblem(direction) {
if (!currentExam || !currentExam.problems) return;
const newIndex = currentProblemIndex + direction;
if (newIndex >= 0 && newIndex < currentExam.problems.length) {
currentProblemIndex = newIndex;
compat.runtime.sendMessage({ action: 'setCurrentExamProblem', problemIndex: currentProblemIndex });
updateExamModeDisplay();
const prob = currentExam.problems[currentProblemIndex];
if (prob && prob.url) {
compat.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
compat.tabs.update(tabs[0].id, { url: prob.url });
}
});
}
}
}
function submitExam() {
showConfirm({
icon: 'task_alt',
title: '提交考试?',
message: '提交后将结束当前自定义考试,题目完成状态会保留到做题记录中。',
confirmText: '提交',
onConfirm: () => {
compat.runtime.sendMessage({ action: 'finishCustomExam' }, () => {
activeCustomExam = null;
currentExam = null;
currentProblemIndex = 0;
switchView('timer');
refreshActiveTabHighlights();
});
}
});
}
// ============================================
// Exam Data Management
// ============================================
function loadExamData() {
compat.storage.local.get(['activeCustomExam'], (result) => {
activeCustomExam = result.activeCustomExam || null;
examData = [];
compat.storage.local.remove(['examSessions']);
if (currentView === 'examList') renderExamList();
});
}
// ============================================
// Current Page Problem Selection
// ============================================
function loadCurrentPageProblems() {
compat.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (!tabs[0]) return;
compat.tabs.sendMessage(tabs[0].id, { action: 'getCurrentPageProblems' }, (response) => {
if (compat.runtime.lastError || !response?.problems?.length) {
showConfirm({
icon: 'info',
title: '未找到题目列表',
message: '请先打开 PTA 的题目列表或考试题目列表页面,再从当前页面选择题目。',
confirmText: '知道了',
onConfirm: () => {}
});
return;
}
compat.storage.local.remove(['customExamSelectionPreview'], refreshActiveTabHighlights);
customExamSelectionMode = true;
currentExam = {
id: `selection-${Date.now()}`,
name: '当前页面题目',
mode: 'selection',
date: new Date().toISOString(),
duration: response.problems.length * 30 * 60000,
problems: response.problems.map((problem) => ({
...problem,
status: 'pending',
selected: false,
solvedAt: null
}))
};
selectedProblemIds = new Set();
switchView('examProblems');
});
});
}
function getProblemKey(problem, index) {
return problem.id || String(index);
}
function toggleProblemSelection(problem, index) {
const key = getProblemKey(problem, index);
if (selectedProblemIds.has(key)) {
selectedProblemIds.delete(key);
} else {
selectedProblemIds.add(key);
}
syncSelectionPreview();
renderExamProblems();
}
function toggleSelectAllProblems() {
if (!currentExam?.problems) return;
if (selectedProblemIds.size === currentExam.problems.length) {
selectedProblemIds.clear();
} else {
selectedProblemIds = new Set(currentExam.problems.map((problem, index) => getProblemKey(problem, index)));
}
syncSelectionPreview();
renderExamProblems();
}
function syncSelectionPreview() {
if (!customExamSelectionMode || !currentExam?.problems) return;
const selectedProblems = currentExam.problems
.filter((problem, index) => selectedProblemIds.has(getProblemKey(problem, index)))
.map((problem) => ({ ...problem, status: 'pending' }));
if (selectedProblems.length === 0) {
compat.storage.local.remove(['customExamSelectionPreview'], refreshActiveTabHighlights);
return;
}
compat.storage.local.set({
customExamSelectionPreview: {
id: 'selection-preview',
mode: 'selection-preview',
problems: selectedProblems
}
}, refreshActiveTabHighlights);
}
function confirmCreateCustomExam() {
if (!selectedProblemIds.size) return;
const durationMinutes = getCustomExamDurationMinutes();
customExamDurationInput.value = String(durationMinutes);
showConfirm({
icon: 'timer',
title: '创建自定义考试?',
message: `将使用选中的 ${selectedProblemIds.size} 道题创建一场 ${durationMinutes} 分钟的计时考试。`,
confirmText: '开始',
onConfirm: createCustomExamFromSelection
});
}
function getCustomExamDurationMinutes() {
const fallback = selectedProblemIds.size * 30;
const value = Number(customExamDurationInput.value);
const minutes = Number.isFinite(value) && value > 0 ? value : fallback;
return Math.max(1, Math.min(999, Math.round(minutes)));
}
function confirmDeleteCustomExam() {
showConfirm({
icon: 'delete',
title: customExamSelectionMode ? '取消当前选择?' : '删除当前考试?',
message: customExamSelectionMode
? '将退出当前页面题目选择,并清除网站题目列表上的选中状态。'
: '将删除当前自定义考试设置,并清除网站题目列表上的蓝色/绿色状态。',
confirmText: customExamSelectionMode ? '取消选择' : '删除',
onConfirm: deleteCustomExam
});
}
function deleteCustomExam() {
const finishDelete = () => {
customExamSelectionMode = false;
activeCustomExam = null;
selectedProblemIds.clear();
currentExam = null;
currentProblemIndex = 0;
switchView('examList');
refreshActiveTabHighlights();
};
if (customExamSelectionMode) {
compat.storage.local.remove(['customExamSelectionPreview'], finishDelete);
return;
}
compat.runtime.sendMessage({ action: 'finishCustomExam' }, finishDelete);
}
function createCustomExamFromSelection() {
const durationMinutes = getCustomExamDurationMinutes();
const selectedProblems = currentExam.problems
.filter((problem, index) => selectedProblemIds.has(getProblemKey(problem, index)))
.map((problem) => ({
...problem,
status: problem.status === 'solved' ? 'solved' : 'pending',
selected: true,
solvedAt: problem.solvedAt || null
}));
currentExam = {
id: `custom-${Date.now()}`,
name: `自定义考试 · ${selectedProblems.length} 题`,
mode: 'custom',
startedAt: Date.now(),
duration: durationMinutes * 60000,
currentProblemIndex: 0,
problems: selectedProblems
};
customExamSelectionMode = false;
selectedProblemIds.clear();
startExamMode(0);
}
// ============================================
// Render Exam List
// ============================================
function renderExamList() {
const query = (examSearchInput.value || '').toLowerCase();
const exams = activeCustomExam ? [activeCustomExam] : examData;
const filtered = exams.filter(e => e.name.toLowerCase().includes(query));
if (filtered.length === 0) {
examListScroll.innerHTML = `
<div class="empty-state">
<span class="material-symbols-outlined icon-empty">assignment</span>
<span class="empty-text">${query ? '未找到匹配的考试' : '暂无考试记录'}</span>
</div>
`;
return;
}
examListScroll.innerHTML = '';
filtered.forEach((exam) => {
const isActive = activeCustomExam?.id === exam.id;
const item = document.createElement('div');
item.className = `exam-list-item${isActive ? ' active-exam' : ''}`;
item.innerHTML = `
<div class="exam-list-item-left">
<div class="exam-list-icon">
<span class="material-symbols-outlined">${isActive ? 'timer' : 'calendar_today'}</span>
</div>
<div class="exam-list-copy">
<span class="exam-list-name">${exam.name}</span>
${isActive ? '<span class="exam-list-subtitle">进行中的考试</span>' : ''}
</div>
</div>
<div class="exam-list-item-right">
<span class="exam-list-count">${exam.problems.length} 题</span>
${isActive ? '<button class="exam-list-delete-btn" title="取消考试"><span class="material-symbols-outlined">close</span></button>' : ''}
<span class="material-symbols-outlined exam-list-chevron">chevron_right</span>
</div>
`;
item.addEventListener('click', () => {
currentExam = exam;
currentProblemIndex = exam.currentProblemIndex || 0;
switchView(isActive ? 'examMode' : 'examProblems');
});
item.querySelector('.exam-list-delete-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
currentExam = exam;
confirmDeleteCustomExam();
});
examListScroll.appendChild(item);
});
}
// ============================================
// Render Exam Problems
// ============================================
function renderExamProblems() {
if (!currentExam) return;
const isCustomExam = currentExam.mode === 'custom';
examTitle.textContent = currentExam.name;
examDurationText.textContent = currentExam.duration
? Math.round(currentExam.duration / 60000) + ' min'
: '未知';
examStatusBadge.textContent = customExamSelectionMode ? '选择题目' : isCustomExam ? '进行中' : '已完成';
if (examDurationMeta) examDurationMeta.style.display = customExamSelectionMode || isCustomExam ? 'none' : '';
customExamToolbar.style.display = (customExamSelectionMode || isCustomExam) ? 'flex' : 'none';
customExamDurationInput.style.display = customExamSelectionMode ? '' : 'none';
selectAllProblemsBtn.style.display = customExamSelectionMode ? '' : 'none';
createCustomExamBtn.style.display = customExamSelectionMode ? '' : 'none';
deleteCustomExamBtn.style.display = isCustomExam ? '' : 'none';
deleteCustomExamBtn.textContent = '删除考试';
if (customExamSelectionMode) {
customExamCount.textContent = `已选择 ${selectedProblemIds.size} 题`;
selectAllProblemsBtn.textContent = selectedProblemIds.size === currentExam.problems.length ? '取消全选' : '全选';
createCustomExamBtn.disabled = selectedProblemIds.size === 0;
} else if (isCustomExam) {
const solvedCount = currentExam.problems.filter((problem) => problem.status === 'solved').length;
customExamCount.textContent = `已完成 ${solvedCount}/${currentExam.problems.length} 题`;
}
problemList.innerHTML = '';
const totalProblems = currentExam.problems.length;
const solvedCount = currentExam.problems.filter((problem) => problem.status === 'solved').length;
currentExam.problems.forEach((prob, index) => {
const key = getProblemKey(prob, index);
const selected = selectedProblemIds.has(key);
const solved = prob.status === 'solved' || Boolean(prob.duration);
const item = document.createElement('div');
item.className = 'problem-list-item';
if (customExamSelectionMode && selected) item.classList.add('selected');
if (isCustomExam && !solved) item.classList.add('pending');
if (solved) item.classList.add('solved');
const statusText = solved ? '已完成' : (customExamSelectionMode && selected) || isCustomExam ? '待完成' : '';
const statusClass = solved ? 'green' : statusText ? 'blue' : '';
const actionText = customExamSelectionMode ? (selected ? '已选' : '选择') : '开始';
item.innerHTML = `
<div class="problem-list-item-left">
<div class="problem-list-number">${String(prob.displayIndex || index + 1).padStart(2, '0')}</div>
<div class="problem-list-info">
<div class="problem-list-name">${prob.name || prob.id || '题目 ' + (index + 1)}</div>
<div class="problem-list-meta">
<span>${prob.duration ? formatTime(prob.duration) : (prob.id || '未计时')}</span>
${statusText ? `<span class="problem-status-badge ${statusClass}">${statusText}</span>` : ''}
</div>
</div>
</div>
<button class="problem-list-start-btn" data-index="${index}">${actionText}</button>
`;
item.addEventListener('click', () => {
if (customExamSelectionMode) toggleProblemSelection(prob, index);
});
item.querySelector('.problem-list-start-btn').addEventListener('click', (e) => {
e.stopPropagation();
if (customExamSelectionMode) {
toggleProblemSelection(prob, index);
} else {
startExamMode(index);
}
});
problemList.appendChild(item);
});
progressValue.textContent = `${solvedCount} / ${totalProblems} 题`;
progressBarFill.style.width = totalProblems > 0 ? (solvedCount / totalProblems * 100) + '%' : '0%';
progressPercent.textContent = totalProblems > 0 ? Math.round(solvedCount / totalProblems * 100) + '%' : '0%';
}
// ============================================
// Start Exam Mode
// ============================================
function startExamMode(problemIndex) {
currentProblemIndex = problemIndex;
currentExam.currentProblemIndex = currentProblemIndex;
const startExam = () => {
switchView('examMode');
navigateToCurrentProblem();
refreshActiveTabHighlights();
};
if (currentExam.mode === 'custom') {
compat.runtime.sendMessage({ action: 'startCustomExam', exam: currentExam }, startExam);
return;
}
compat.storage.local.set({
isExamMode: true,
currentExam: currentExam,
currentProblemIndex: currentProblemIndex,
examStartTime: Date.now(),
examDuration: currentExam.duration || (currentExam.problems.length * 30 * 60000)
}, startExam);
}
function navigateToCurrentProblem() {
const prob = currentExam?.problems?.[currentProblemIndex];
if (!prob?.url) return;
compat.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
compat.tabs.update(tabs[0].id, { url: prob.url });
}
});
}
function refreshActiveTabHighlights() {
compat.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
compat.tabs.sendMessage(tabs[0].id, { action: 'refreshProblemHighlights' });
}
});
}
// ============================================
// Sessions List
// ============================================
function updateSessionsList(sessions) {
if (!sessions || sessions.length === 0) {
sessionsList.innerHTML = `
<div class="empty-state">
<span class="material-symbols-outlined icon-empty">history</span>
<span class="empty-text">暂无记录</span>
</div>
`;
return;
}
const recentSessions = sessions
.map((session, sessionIndex) => ({ session, sessionIndex }))
.reverse();
sessionsList.innerHTML = '';
recentSessions.forEach(({ session, sessionIndex }, index) => {
const item = document.createElement('div');
item.className = 'session-item';
const left = document.createElement('div');
left.className = 'session-item-left';
const number = document.createElement('div');
number.className = 'session-number';
number.textContent = String(recentSessions.length - index).padStart(2, '0');
const info = document.createElement('div');
info.className = 'session-info';
const name = document.createElement('div');
name.className = 'session-problem-id';
name.textContent = session.problemName || session.problemId || '未知题目';
const date = document.createElement('div');
date.className = 'session-date';
date.textContent = `${formatSessionDate(session.endTime)} · ${session.problemId || '未知 ID'}`;
const right = document.createElement('div');
right.className = 'session-item-right';
const time = document.createElement('span');
time.className = 'session-time';
time.textContent = formatTime(session.duration);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'session-delete-btn';
deleteBtn.title = '删除记录';
deleteBtn.innerHTML = '<span class="material-symbols-outlined">delete</span>';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteSession(sessionIndex);
});
info.appendChild(name);
info.appendChild(date);
left.appendChild(number);
left.appendChild(info);
right.appendChild(time);
right.appendChild(deleteBtn);
item.appendChild(left);
item.appendChild(right);
sessionsList.appendChild(item);
});
}
// ============================================
// Clear Data
// ============================================
function clearData() {
showConfirm({
icon: 'delete_forever',
title: '清除所有数据?',
message: '将删除所有做题记录、倒计时和当前计时状态,此操作无法恢复。',
confirmText: '清除',
onConfirm: performClearData
});
}
function performClearData() {
compat.runtime.sendMessage({ action: 'clearAllData' }, () => {
examData = [];
currentExam = null;
currentProblemIndex = 0;
compat.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
compat.tabs.sendMessage(tabs[0].id, { action: 'resetTimer' }, () => {
updateTimerDisplay();
});
} else {
updateTimerDisplay();
}
});
switchView('timer');
});
}
function deleteSession(sessionIndex) {
showConfirm({
icon: 'delete',
title: '删除这条记录?',
message: '删除后无法恢复,确定要继续吗?',
confirmText: '删除',
onConfirm: () => {
compat.runtime.sendMessage({ action: 'deleteSession', sessionIndex: sessionIndex }, () => {
if (compat.runtime.lastError) return;
updateTimerDisplay();
});
}
});
}
function showConfirm({ icon, title, message, confirmText, onConfirm }) {
pendingConfirmAction = onConfirm;
confirmIcon.textContent = icon;
confirmTitle.textContent = title;
confirmMessage.textContent = message;
confirmOk.textContent = confirmText;
confirmOverlay.classList.add('visible');
confirmOverlay.setAttribute('aria-hidden', 'false');
confirmOk.focus();
}
function closeConfirm() {
pendingConfirmAction = null;
confirmOverlay.classList.remove('visible');
confirmOverlay.setAttribute('aria-hidden', 'true');
}
function confirmPendingAction() {
const action = pendingConfirmAction;
closeConfirm();
if (action) action();
}
// ============================================
// Utility Functions
// ============================================
function getElapsedTime(timerData) {
if (!timerData?.currentSession) return 0;
const now = timerData.isPaused && timerData.pauseStartTime ? timerData.pauseStartTime : Date.now();
return Math.max(0, now - timerData.currentSession.startTime - (timerData.pausedDuration || 0));
}
function updatePlayPauseIcons(paused) {
const icon = playPauseBtn?.querySelector('.material-symbols-outlined');
const examIcon = examPlayPauseBtn?.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = paused ? 'play_arrow' : 'pause';
if (examIcon) examIcon.textContent = paused ? 'play_arrow' : 'pause';
if (playPauseBtn) playPauseBtn.title = paused ? '继续' : '暂停';
if (examPlayPauseBtn) examPlayPauseBtn.title = paused ? '继续' : '暂停';
}
function formatTime(ms) {
if (!ms || ms < 0) return '00:00:00';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
return `${hours.toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
}
function formatCountdownTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
return `${hours.toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
function formatSessionDate(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, '0');
const mins = date.getMinutes().toString().padStart(2, '0');
return `${month}/${day} ${hours}:${mins}`;
}
};
});
runModule('content', function () {
// PTA Timer - Content Script
// Ultra-Compact Floating Timer Widget
// Focus Precision Design System v2.0
let timerDisplay = null;
let currentProblemId = null;
let isFixed = false;
let isPaused = false;
let countdownData = null;
let countdownPauseTime = 0;
let countdownWarningShown = false;
let timerPauseTime = 0;
let timerPauseOffset = 0;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
let isExamMode = false; // 考试模式标志
let hasActiveCustomExam = false;
let examStartTime = null; // 考试开始时间
let timerCompleted = false;
let lastDetectedUrl = null;
let routeObserver = null;
let routeCheckInterval = null;
let routeDetectionTimer = null;
let submissionResultObserver = null;
let submissionResultCheckTimer = null;
let timerDisplayUpdateTimer = null;
let problemListObserver = null;
let problemListRefreshTimer = null;
let settings = {
autoStart: true,
notifications: true
};
// ============================================
// Initialization
// ============================================
function init() {
compat.storage.local.get(['timerFixed', 'timerPosition', 'activeCustomExam', 'isExamMode', 'settings'], (result) => {
if (result.timerFixed !== undefined) {
isFixed = result.timerFixed;
}
settings = {
autoStart: result.settings?.autoStart !== false,
notifications: result.settings?.notifications !== false
};
hasActiveCustomExam = Boolean(result.activeCustomExam);
isExamMode = hasActiveCustomExam || Boolean(result.isExamMode);
detectProblemPage();
setTimeout(() => {
if (timerDisplay && result.timerPosition && !isFixed) {
timerDisplay.style.left = result.timerPosition.x + 'px';
timerDisplay.style.top = result.timerPosition.y + 'px';
timerDisplay.style.right = 'auto';
}
}, 100);
});
isPaused = false;
timerPauseTime = 0;
timerPauseOffset = 0;
countdownPauseTime = 0;
countdownWarningShown = false;
startRouteDetection();
startProblemListWatcher();
scheduleProblemListRefresh();
}
// ============================================
// Message Listener
// ============================================
compat.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'startCountdown') {
startCountdown(request.duration);
sendResponse({ success: true });
} else if (request.action === 'togglePlayPause') {
togglePlayPause();
sendResponse({ success: true });
} else if (request.action === 'syncPauseState') {
syncPauseState(request.isPaused);
sendResponse({ success: true });
} else if (request.action === 'resetTimer') {
resetTimer();
sendResponse({ success: true });
} else if (request.action === 'getCurrentPageProblems') {
sendResponse({ success: true, problems: collectCurrentPageProblems() });
} else if (request.action === 'refreshProblemHighlights') {
scheduleProblemListRefresh();
sendResponse({ success: true });
}
});
// ============================================
// Page Detection
// ============================================
function detectProblemPage() {
const url = window.location.href;
lastDetectedUrl = url;
// 检测考试模式:URL 包含 /exam/ 或 /exams/
const wasExamMode = isExamMode;
isExamMode = hasActiveCustomExam || /\/exams?\//.test(url);
// 考试模式状态变化时,保存到 storage 供 popup 读取
if (isExamMode !== wasExamMode) {
if (isExamMode) {
compat.storage.local.set({ isExamMode: true });
} else {
compat.storage.local.get(['activeCustomExam'], (result) => {
if (!result.activeCustomExam) {
compat.storage.local.set({ isExamMode: false });
}
});
}
if (isExamMode && !examStartTime) {
// 进入考试模式,记录考试开始时间
compat.storage.local.get(['examStartTime'], (result) => {
if (result.examStartTime) {
examStartTime = result.examStartTime;
} else {
examStartTime = Date.now();
compat.storage.local.set({ examStartTime: examStartTime });
}
});
}
}
const problemId = extractProblemId(url);
scheduleProblemListRefresh();
if (problemId) {
if (problemId !== currentProblemId) {
if (currentProblemId) {
stopProblemTimer();
}
currentProblemId = problemId;
timerCompleted = false;
resetLocalTimerState();
if (settings.autoStart) {
startProblemTimer(problemId);
}
createTimerDisplay();
}
startSubmissionResultWatcher();
checkSubmissionResult();
} else {
stopSubmissionResultWatcher();
if (currentProblemId) {
stopProblemTimer();
currentProblemId = null;
timerCompleted = false;
}
if (timerDisplay) {
timerDisplay.style.display = 'none';
}
}
}
function extractProblemId(url) {
const parsedUrl = new URL(url);
const problemSetProblemId = parsedUrl.searchParams.get('problemSetProblemId');
if (problemSetProblemId) {
return problemSetProblemId;
}
if (/\/exams?\//.test(parsedUrl.pathname)) {
return null;
}
const pathMatch = parsedUrl.pathname.match(/\/problems\/([^\/\?]+)/);
return pathMatch ? pathMatch[1] : null;
}
function startRouteDetection() {
if (!routeObserver) {
routeObserver = new MutationObserver(scheduleRouteDetection);
routeObserver.observe(document.body, {
childList: true
});
}
if (!routeCheckInterval) {
routeCheckInterval = setInterval(scheduleRouteDetection, 1000);
}
}
function scheduleRouteDetection() {
if (routeDetectionTimer || window.location.href === lastDetectedUrl) {
return;
}
routeDetectionTimer = setTimeout(() => {
routeDetectionTimer = null;
if (window.location.href !== lastDetectedUrl) {
detectProblemPage();
}
}, 200);
}
function startSubmissionResultWatcher() {
if (submissionResultObserver || timerCompleted) {
return;
}
submissionResultObserver = new MutationObserver(scheduleSubmissionResultCheck);
submissionResultObserver.observe(document.body, {
childList: true,
subtree: true
});
}
function stopSubmissionResultWatcher() {
if (submissionResultObserver) {
submissionResultObserver.disconnect();
submissionResultObserver = null;
}
if (submissionResultCheckTimer) {
clearTimeout(submissionResultCheckTimer);
submissionResultCheckTimer = null;
}
}
function scheduleSubmissionResultCheck() {
if (submissionResultCheckTimer || timerCompleted) {
return;
}
submissionResultCheckTimer = setTimeout(() => {
submissionResultCheckTimer = null;
checkSubmissionResult();
}, 500);
}
function checkSubmissionResult() {
if (timerCompleted || !isFullAcceptedSubmissionVisible()) {
return;
}
timerCompleted = true;
stopSubmissionResultWatcher();
completeProblemTimer();
markTimerAsCompleted();
}
function isFullAcceptedSubmissionVisible() {
const modalBodies = document.querySelectorAll('[data-e2e="modal-body"]');
for (const body of modalBodies) {
const modal = body.closest('[data-e2e="modal-mask"], .pc-modal') || body;
const title = modal.querySelector('.title_H10HL, [class*="title_"]');
if (!title || !title.textContent.includes('提交结果')) {
continue;
}
const text = body.textContent;
if (!text.includes('答案正确')) {
continue;
}
const scoreMatch = text.match(/分数\s*(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (scoreMatch && Number(scoreMatch[1]) === Number(scoreMatch[2])) {
return true;
}
}
return false;
}
function resetLocalTimerState() {
isPaused = false;
timerPauseTime = 0;
timerPauseOffset = 0;
countdownData = null;
countdownPauseTime = 0;
countdownWarningShown = false;
compat.storage.local.remove(['countdownData']);
}
function markTimerAsCompleted() {
isPaused = true;
countdownData = null;
compat.storage.local.remove(['countdownData']);
const btn = document.getElementById('pta-play-pause');
const icon = btn && btn.querySelector('.material-symbols-outlined');
const statusDot = document.getElementById('pta-status-dot');
const modeLabel = document.getElementById('pta-mode-label');
if (icon) icon.textContent = 'check';
if (btn) btn.title = '已完成';
if (statusDot) statusDot.classList.add('paused');
if (modeLabel) {
modeLabel.textContent = 'DONE';
modeLabel.style.color = '#16a34a';
}
}
// ============================================
// Problem List Collection & Highlighting
// ============================================
function startProblemListWatcher() {
if (problemListObserver || !document.body) {
return;
}
problemListObserver = new MutationObserver(scheduleProblemListRefresh);
problemListObserver.observe(document.body, {
childList: true,
subtree: true
});
}
function scheduleProblemListRefresh() {
if (problemListRefreshTimer) {
return;
}
problemListRefreshTimer = setTimeout(() => {
problemListRefreshTimer = null;
refreshProblemListHighlights();
}, 400);
}
function collectCurrentPageProblems() {
return collectCurrentPageProblemEntries().map(({ id, name, displayIndex, url }) => ({ id, name, displayIndex, url }));
}
function collectCurrentPageProblemEntries() {
const entries = [];
const seen = new Set();
const links = document.querySelectorAll('a[href*="problemSetProblemId="], a[href*="/problems/"]');
links.forEach((anchor) => {
if (anchor.closest('#pta-timer-display, .timer-countdown-dialog, .timer-finished-toast')) {
return;
}
const url = new URL(anchor.href, window.location.href).href;
const id = extractProblemId(url);
if (!id || seen.has(id)) {
return;
}
const target = getProblemListItemTarget(anchor);
seen.add(id);
entries.push({
id: id,
name: getProblemNameFromListItem(anchor, target, id),
displayIndex: getProblemDisplayIndex(anchor),
url: url,
target: target
});
});
return entries;
}
function getProblemListItemTarget(anchor) {
const listTarget = anchor.closest('td, li, tr, [role="row"], [role="listitem"]');
if (listTarget) {
return listTarget;
}
return anchor;
}
function getProblemDisplayIndex(anchor) {
const text = anchor.textContent.trim().replace(/\s+/g, ' ');
return /^\d+$/.test(text) ? text : '';
}
function getProblemNameFromListItem(anchor, target, fallback) {
const anchorText = anchor.textContent.trim().replace(/\s+/g, ' ');
if (anchorText && anchorText.length <= 40) {
return /^\d+$/.test(anchorText) ? `题目 ${anchorText}` : anchorText;
}
const title = target?.querySelector('h1, h2, h3, [class*="title"], [class*="Title"], [class*="name"], [class*="Name"]');
const text = (title?.textContent || target?.textContent || '').trim().replace(/\s+/g, ' ');
if (text && text.length <= 60 && !/^编程题\s*\d+\s*\/\s*\d+$/.test(text)) {
return text;
}
return fallback;
}
function refreshProblemListHighlights() {
document.querySelectorAll('.pta-problem-highlight-pending, .pta-problem-highlight-solved').forEach((element) => {
element.classList.remove('pta-problem-highlight-pending', 'pta-problem-highlight-solved');
});
compat.storage.local.get(['activeCustomExam', 'customExamSelectionPreview'], (result) => {
const exam = result.activeCustomExam || result.customExamSelectionPreview;
if (!exam?.problems) {
return;
}
const statusById = new Map(exam.problems.map((problem) => [problem.id, problem.status]));
collectCurrentPageProblemEntries().forEach(({ id, target }) => {
const status = statusById.get(id);
if (!status || !target) {
return;
}
target.classList.add(status === 'solved' ? 'pta-problem-highlight-solved' : 'pta-problem-highlight-pending');
});
});
}
compat.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== 'local') {
return;
}
if (changes.settings) {
settings = {
autoStart: changes.settings.newValue?.autoStart !== false,
notifications: changes.settings.newValue?.notifications !== false
};
}
if (changes.activeCustomExam) {
hasActiveCustomExam = Boolean(changes.activeCustomExam.newValue);
isExamMode = hasActiveCustomExam || /\/exams?\//.test(window.location.href);
}
if (changes.activeCustomExam || changes.customExamSelectionPreview) {
scheduleProblemListRefresh();
}
});
// ============================================
// Timer Control
// ============================================
function startProblemTimer(problemId) {
compat.runtime.sendMessage({
action: 'startTimer',
problemId: problemId,
problemName: getProblemName()
});
}
function stopProblemTimer() {
compat.runtime.sendMessage({
action: 'stopTimer'
});
}
function completeProblemTimer() {
compat.runtime.sendMessage({
action: 'completeTimer'
}, () => {
if (currentProblemId) {
compat.runtime.sendMessage({
action: 'markExamProblemSolved',
problemId: currentProblemId
}, () => {
scheduleProblemListRefresh();
});
}
});
}
function getProblemName() {
const title = document.querySelector('.text-darkest.font-bold.text-lg, h1, [class*="problem"] h1');
const text = title?.textContent?.trim();
return text || currentProblemId || '未知题目';
}
function getElapsedTime(timerData) {
if (!timerData?.currentSession) return 0;
const now = timerData.isPaused && timerData.pauseStartTime ? timerData.pauseStartTime : Date.now();
return Math.max(0, now - timerData.currentSession.startTime - (timerData.pausedDuration || 0));
}
function renderPlayPauseState(paused) {
const btn = document.getElementById('pta-play-pause');
const icon = btn && btn.querySelector('.material-symbols-outlined');
const statusDot = document.getElementById('pta-status-dot');
const modeLabel = document.getElementById('pta-mode-label');
isPaused = paused;
if (icon) icon.textContent = paused ? 'play_arrow' : 'pause';
if (btn) btn.title = paused ? '继续' : '暂停';
if (statusDot) statusDot.classList.toggle('paused', paused);
if (modeLabel && !timerCompleted) {
modeLabel.textContent = isExamMode ? 'EXAM' : paused ? 'PAUSED' : (countdownData && countdownData.isRunning ? 'COUNTDOWN' : 'FOCUS');
modeLabel.style.color = isExamMode ? '#cf2c30' : paused ? '#d97706' : '';
}
}
// ============================================
// UI Creation - Ultra Compact Widget
// ============================================
function createTimerDisplay() {
if (timerDisplay) {
timerDisplay.remove();
}
// Load Material Symbols font
if (!document.querySelector('link[href*="Material+Symbols+Outlined"]')) {
const fontLink = document.createElement('link');
fontLink.rel = 'stylesheet';
fontLink.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,[email protected],0..1&display=swap';
document.head.appendChild(fontLink);
}
// Load Inter & JetBrains Mono
if (!document.querySelector('link[href*="JetBrains+Mono"]')) {
const fontLink = document.createElement('link');
fontLink.rel = 'stylesheet';
fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap';
document.head.appendChild(fontLink);
}
timerDisplay = document.createElement('div');
timerDisplay.id = 'pta-timer-display';
const shortId = currentProblemId ? currentProblemId.substring(0, 7) : '---';
const examBadgeHtml = isExamMode ? '<span class="timer-exam-badge">EXAM</span>' : '';
timerDisplay.innerHTML = `
<!-- Left Branding -->
<div class="timer-brand">
<div class="timer-brand-info">
<div class="timer-brand-row">
<div class="timer-status-dot" id="pta-status-dot"></div>
<span class="timer-brand-label">PTA</span>
${examBadgeHtml}
</div>
<div class="timer-problem-id">
<span id="pta-problem-label">${shortId}</span>
<span>•</span>
<span class="timer-problem-mode" id="pta-mode-label">${isExamMode ? 'EXAM' : 'FOCUS'}</span>
</div>
</div>
</div>
<!-- Central Display -->
<div class="timer-center">
<span class="timer-time-display" id="pta-current-time">00:00:00</span>
<div class="timer-divider"></div>
<div class="timer-controls-cluster">
<button class="timer-ctrl-btn btn-play-pause" id="pta-play-pause" title="暂停">
<span class="material-symbols-outlined">pause</span>
</button>
<button class="timer-ctrl-btn btn-reset" id="pta-reset" title="重置">
<span class="material-symbols-outlined">replay</span>
</button>
<button class="timer-ctrl-btn btn-countdown" id="pta-countdown" title="倒计时">
<span class="material-symbols-outlined">timer</span>
</button>
<button class="timer-ctrl-btn btn-panel" id="pta-open-panel" title="打开面板">
<span class="material-symbols-outlined">dashboard</span>
</button>
</div>
<div class="timer-progress-bar">
<div class="timer-progress-fill" id="pta-progress-fill"></div>
</div>
</div>
<!-- Right Actions -->
<div class="timer-actions">
<button class="timer-action-btn ${isFixed ? 'pinned' : ''}" id="pta-fix-toggle" title="${isFixed ? '取消固定' : '固定'}">
<span class="material-symbols-outlined">push_pin</span>
</button>
<button class="timer-action-btn" id="pta-timer-close" title="关闭">
<span class="material-symbols-outlined">close</span>
</button>
</div>
`;
document.body.appendChild(timerDisplay);
renderMaterialIcons(timerDisplay);
// Apply fixed state
if (isFixed) {
timerDisplay.classList.add('fixed');
} else {
addDragFunctionality();
}
// Bind events
bindEvents();
// Start display update loop
if (timerDisplayUpdateTimer) {
clearTimeout(timerDisplayUpdateTimer);
timerDisplayUpdateTimer = null;
}
updateTimerDisplay();
}
// ============================================
// Event Binding
// ============================================
function bindEvents() {
document.getElementById('pta-timer-close').addEventListener('click', (e) => {
e.stopPropagation();
timerDisplay.style.display = 'none';
});
document.getElementById('pta-fix-toggle').addEventListener('click', (e) => {
e.stopPropagation();
toggleFix();
});
document.getElementById('pta-play-pause').addEventListener('click', (e) => {
e.stopPropagation();
togglePlayPause();
});
document.getElementById('pta-reset').addEventListener('click', (e) => {
e.stopPropagation();
resetTimer();
});
document.getElementById('pta-countdown').addEventListener('click', (e) => {
e.stopPropagation();
showCountdownDialog();
});
const panelButton = document.getElementById('pta-open-panel');
if (panelButton) {
panelButton.addEventListener('click', (e) => {
e.stopPropagation();
openPanel();
});
}
}
// ============================================
// Drag Functionality
// ============================================
function addDragFunctionality() {
if (!timerDisplay) return;
timerDisplay.addEventListener('mousedown', (e) => {
if (isFixed) return;
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
isDragging = true;
const rect = timerDisplay.getBoundingClientRect();
dragOffset.x = e.clientX - rect.left;
dragOffset.y = e.clientY - rect.top;
timerDisplay.style.transition = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging || isFixed) return;
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
const maxX = window.innerWidth - timerDisplay.offsetWidth;
const maxY = window.innerHeight - timerDisplay.offsetHeight;
timerDisplay.style.left = Math.max(0, Math.min(x, maxX)) + 'px';
timerDisplay.style.top = Math.max(0, Math.min(y, maxY)) + 'px';
timerDisplay.style.right = 'auto';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
timerDisplay.style.transition = '';
const rect = timerDisplay.getBoundingClientRect();
compat.storage.local.set({
timerPosition: { x: rect.left, y: rect.top }
});
}
});
}
// ============================================
// Toggle Fix
// ============================================
function toggleFix() {
isFixed = !isFixed;
const fixBtn = document.getElementById('pta-fix-toggle');
if (isFixed) {
fixBtn.classList.add('pinned');
fixBtn.title = '取消固定';
timerDisplay.classList.add('fixed');
} else {
fixBtn.classList.remove('pinned');
fixBtn.title = '固定';
timerDisplay.classList.remove('fixed');
addDragFunctionality();
}
compat.storage.local.set({ timerFixed: isFixed });
}
// ============================================
// Play/Pause
// ============================================
function togglePlayPause() {
if (timerCompleted) return;
const toggleActiveTimer = () => {
compat.runtime.sendMessage({ action: 'togglePauseTimer' }, (response) => {
if (compat.runtime.lastError || !response?.success) return;
syncPauseState(response.timerData.isPaused);
});
};
compat.runtime.sendMessage({ action: 'getTimerData' }, (timerData) => {
if (compat.runtime.lastError) return;
if ((!timerData?.isRunning || !timerData.currentSession) && currentProblemId) {
startProblemTimer(currentProblemId);
syncPauseState(false);
return;
}
toggleActiveTimer();
});
}
function syncPauseState(paused) {
renderPlayPauseState(paused);
if (paused) {
if (countdownData && countdownData.isRunning) {
countdownPauseTime = Date.now();
}
} else if (countdownData && countdownData.isRunning && countdownPauseTime > 0) {
const pauseDuration = Date.now() - countdownPauseTime;
countdownData.startTime += pauseDuration;
countdownPauseTime = 0;
compat.storage.local.set({ countdownData: countdownData });
}
}
// ============================================
// Reset Timer
// ============================================
function resetTimer() {
compat.runtime.sendMessage({ action: 'stopTimer' });
timerCompleted = false;
startSubmissionResultWatcher();
isPaused = false;
timerPauseTime = 0;
timerPauseOffset = 0;
countdownData = null;
countdownPauseTime = 0;
countdownWarningShown = false;
compat.storage.local.remove(['countdownData']);
const btn = document.getElementById('pta-play-pause');
const icon = btn.querySelector('.material-symbols-outlined');
icon.textContent = 'pause';
btn.title = '暂停';
const statusDot = document.getElementById('pta-status-dot');
statusDot.classList.remove('paused');
const modeLabel = document.getElementById('pta-mode-label');
modeLabel.textContent = 'FOCUS';
modeLabel.style.color = '';
const timeDisplay = document.getElementById('pta-current-time');
timeDisplay.textContent = '00:00:00';
timeDisplay.classList.remove('warning', 'danger');
const progressFill = document.getElementById('pta-progress-fill');
progressFill.style.width = '0%';
progressFill.classList.remove('warning', 'danger');
if (currentProblemId) {
startProblemTimer(currentProblemId);
}
scheduleProblemListRefresh();
}
// ============================================
// Countdown Dialog
// ============================================
function showCountdownDialog() {
// Remove existing dialog
const existing = document.querySelector('.timer-countdown-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.className = 'timer-countdown-dialog';
dialog.innerHTML = `
<div class="timer-countdown-panel">
<h3>设置倒计时</h3>
<input type="number" id="pta-countdown-input" placeholder="输入分钟数" min="1" max="999" autofocus>
<div class="timer-countdown-actions">
<button class="btn-cancel" id="pta-countdown-cancel">取消</button>
<button class="btn-confirm" id="pta-countdown-confirm">开始</button>
</div>
</div>
`;
document.body.appendChild(dialog);
renderMaterialIcons(dialog);
const input = document.getElementById('pta-countdown-input');
const confirmBtn = document.getElementById('pta-countdown-confirm');
const cancelBtn = document.getElementById('pta-countdown-cancel');
setTimeout(() => input.focus(), 100);
confirmBtn.addEventListener('click', () => {
const value = parseInt(input.value);
if (value && value > 0) {
startCountdown(value);
dialog.remove();
} else {
input.style.borderColor = '#ab0b1c';
setTimeout(() => { input.style.borderColor = ''; }, 1500);
}
});
cancelBtn.addEventListener('click', () => dialog.remove());
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.remove();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmBtn.click();
if (e.key === 'Escape') dialog.remove();
});
}
// ============================================
// Countdown Logic
// ============================================
function startCountdown(minutes) {
countdownData = {
duration: minutes * 60 * 1000,
startTime: Date.now(),
isRunning: true
};
isPaused = false;
countdownPauseTime = 0;
countdownWarningShown = false;
timerPauseTime = 0;
timerPauseOffset = 0;
const btn = document.getElementById('pta-play-pause');
const icon = btn.querySelector('.material-symbols-outlined');
icon.textContent = 'pause';
btn.title = '暂停';
const modeLabel = document.getElementById('pta-mode-label');
modeLabel.textContent = 'COUNTDOWN';
compat.storage.local.set({ countdownData: countdownData });
}
// ============================================
// Countdown Reminders
// ============================================
function showCountdownWarningToast(remaining) {
document.querySelector('.timer-warning-toast')?.remove();
const toast = document.createElement('div');
toast.className = 'timer-finished-toast timer-warning-toast';
toast.innerHTML = `
<div class="timer-finished-card warning">
<div class="timer-finished-icon">
<span class="material-symbols-outlined">timer</span>
</div>
<div class="timer-finished-copy">
<div class="timer-finished-title">倒计时提醒</div>
<div class="timer-finished-message">剩余 ${formatCountdownTime(remaining)},请注意时间。</div>
</div>
<button class="timer-finished-close" type="button" aria-label="关闭提醒">
<span class="material-symbols-outlined">close</span>
</button>
</div>
`;
document.body.appendChild(toast);
renderMaterialIcons(toast);
const closeToast = () => {
toast.classList.add('closing');
setTimeout(() => toast.remove(), 180);
};
toast.querySelector('.timer-finished-close').addEventListener('click', closeToast);
setTimeout(closeToast, 6000);
}
function showCountdownWarningIfNeeded(remaining) {
if (!settings.notifications || countdownWarningShown || isPaused || remaining <= 0) {
return;
}
const warningThreshold = Math.min(5 * 60 * 1000, countdownData.duration / 2);
if (remaining > warningThreshold) {
return;
}
countdownWarningShown = true;
showCountdownWarningToast(remaining);
if (Notification.permission === 'granted') {
new Notification('PTA Timer', {
body: `倒计时剩余 ${formatCountdownTime(remaining)}`,
icon: compat.runtime.getURL('icons/icon48.png')
});
}
}
// ============================================
// Countdown Finished Toast
// ============================================
function showCountdownFinishedToast() {
document.querySelector('.timer-finished-toast')?.remove();
const toast = document.createElement('div');
toast.className = 'timer-finished-toast';
toast.innerHTML = `
<div class="timer-finished-card">
<div class="timer-finished-icon">
<span class="material-symbols-outlined">timer_off</span>
</div>
<div class="timer-finished-copy">
<div class="timer-finished-title">倒计时结束</div>
<div class="timer-finished-message">时间到,记得检查当前题目状态。</div>
</div>
<button class="timer-finished-close" type="button" aria-label="关闭提醒">
<span class="material-symbols-outlined">close</span>
</button>
</div>
`;
document.body.appendChild(toast);
renderMaterialIcons(toast);
const closeToast = () => {
toast.classList.add('closing');
setTimeout(() => toast.remove(), 180);
};
toast.querySelector('.timer-finished-close').addEventListener('click', closeToast);
setTimeout(closeToast, 8000);
}
// ============================================
// Display Update Loop
// ============================================
function updateTimerDisplay() {
if (!timerDisplay || timerDisplay.style.display === 'none') {
timerDisplayUpdateTimer = setTimeout(updateTimerDisplay, 1000);
return;
}
if (isExamMode) {
compat.storage.local.get(['examStartTime', 'examDuration'], (result) => {
if (result.examStartTime && Number(result.examDuration) > 0) {
updateExamCountdownDisplay(result);
syncCurrentProblemTimerState();
return;
}
isExamMode = false;
if (!/\/exams?\//.test(window.location.href)) {
compat.storage.local.set({ isExamMode: false });
}
updateProblemTimerDisplay();
});
} else {
updateProblemTimerDisplay();
}
timerDisplayUpdateTimer = setTimeout(updateTimerDisplay, 1000);
}
function updateProblemTimerDisplay() {
updateCountdownDisplay();
if (countdownData && countdownData.isRunning) {
return;
}
compat.runtime.sendMessage({ action: 'getTimerData' }, (timerData) => {
if (compat.runtime.lastError || !timerData) return;
if (timerData.isRunning && timerData.currentSession) {
syncCurrentProblemName(timerData);
const timeEl = document.getElementById('pta-current-time');
if (timeEl) {
timeEl.textContent = formatTime(getElapsedTime(timerData));
}
renderPlayPauseState(timerData.isPaused);
return;
}
if (currentProblemId && !timerCompleted && settings.autoStart) {
startProblemTimer(currentProblemId);
renderPlayPauseState(false);
return;
}
renderPlayPauseState(true);
});
}
function syncCurrentProblemTimerState() {
compat.runtime.sendMessage({ action: 'getTimerData' }, (timerData) => {
if (compat.runtime.lastError || !timerData || timerCompleted) return;
if (timerData.isRunning && timerData.currentSession) {
syncCurrentProblemName(timerData);
}
renderPlayPauseState(timerData.isPaused);
});
}
function syncCurrentProblemName(timerData) {
const problemName = getProblemName();
if (problemName && problemName !== currentProblemId && problemName !== timerData.currentSession.problemName) {
compat.runtime.sendMessage({
action: 'startTimer',
problemId: currentProblemId,
problemName: problemName
});
}
}
function updateExamCountdownDisplay(examTiming) {
const timeDisplay = document.getElementById('pta-current-time');
const progressFill = document.getElementById('pta-progress-fill');
const elapsed = Date.now() - examTiming.examStartTime;
const duration = Number(examTiming.examDuration) || 0;
const remaining = Math.max(0, duration - elapsed);
const minutes = Math.floor(remaining / 60000);
if (timeDisplay) {
timeDisplay.textContent = formatTime(remaining);
timeDisplay.classList.remove('warning', 'danger');
if (minutes <= 5) {
timeDisplay.classList.add('danger');
} else if (minutes <= 10) {
timeDisplay.classList.add('warning');
}
}
if (progressFill) {
progressFill.style.width = Math.min(100, elapsed / duration * 100) + '%';
progressFill.classList.remove('warning', 'danger');
if (minutes <= 5) {
progressFill.classList.add('danger');
} else if (minutes <= 10) {
progressFill.classList.add('warning');
}
}
}
function updateCountdownDisplay() {
if (!countdownData || !countdownData.isRunning) {
compat.storage.local.get(['countdownData'], (result) => {
if (result.countdownData && result.countdownData.isRunning) {
countdownData = result.countdownData;
updateCountdownDisplay();
}
});
return;
}
let elapsed;
if (isPaused && countdownPauseTime > 0) {
elapsed = countdownPauseTime - countdownData.startTime;
} else {
elapsed = Date.now() - countdownData.startTime;
}
const remaining = Math.max(0, countdownData.duration - elapsed);
const timeDisplay = document.getElementById('pta-current-time');
const progressFill = document.getElementById('pta-progress-fill');
showCountdownWarningIfNeeded(remaining);
if (remaining <= 0) {
countdownData.isRunning = false;
showCountdownFinishedToast();
if (settings.notifications && Notification.permission === 'granted') {
new Notification('PTA Timer', {
body: '倒计时结束!',
icon: compat.runtime.getURL('icons/icon48.png')
});
}
compat.storage.local.remove(['countdownData']);
isPaused = false;
countdownPauseTime = 0;
if (timeDisplay) {
timeDisplay.classList.remove('warning', 'danger');
}
if (progressFill) {
progressFill.style.width = '100%';
progressFill.classList.remove('warning', 'danger');
}
const modeLabel = document.getElementById('pta-mode-label');
if (modeLabel) modeLabel.textContent = 'FOCUS';
} else {
if (timeDisplay) {
timeDisplay.textContent = formatCountdownTime(remaining);
const minutes = Math.floor(remaining / 60000);
timeDisplay.classList.remove('warning', 'danger');
if (minutes <= 5) {
timeDisplay.classList.add('danger');
} else if (minutes <= 10) {
timeDisplay.classList.add('warning');
}
}
// Update progress bar
if (progressFill) {
const progress = ((countdownData.duration - remaining) / countdownData.duration) * 100;
progressFill.style.width = progress + '%';
const minutes = Math.floor(remaining / 60000);
progressFill.classList.remove('warning', 'danger');
if (minutes <= 5) {
progressFill.classList.add('danger');
} else if (minutes <= 10) {
progressFill.classList.add('warning');
}
}
}
}
// ============================================
// Utility Functions
// ============================================
function formatCountdownTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
return `${hours.toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
function formatTime(ms) {
if (!ms || ms < 0) return '00:00:00';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
return `${hours.toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
}
// ============================================
// Bootstrap
// ============================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
});
})();