// ==UserScript== // @name 智能定时刷新器 // @namespace https://github.com/U0Axuan/auto_refresh_script // @version 2.0.0 // @description Material Design 3 风格的网页定时刷新工具。支持可拖拽面板、最小化、进度条、最大运行次数限制、可选同域名广播(v2.1 启用)。 // @author Axuan 薛神 // @license MIT // @match *://*/* // @exclude *://accounts.google.com/* // @exclude *://*.bank* // @exclude */login* // @exclude */signin* // @exclude */checkout* // @noframes // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addStyle // @run-at document-end // @icon https://raw.githubusercontent.com/U0Axuan/auto_refresh_script/main/docs/icon.svg // ==/UserScript== /** * 智能定时刷新器 - v2.0.0 * 重构自 v1.0.2:模块化、修复 P0 Bug、加 a11y、默认禁用、单实例保护。 * 架构说明见 docs/architecture.md。 */ (function () { 'use strict'; // ============================================================ // 1. CONFIG — 集中常量 // ============================================================ const CONFIG = Object.freeze({ VERSION: '2.0.0', SCHEMA_VERSION: 2, REFRESH_DEFAULT: 30, REFRESH_MIN: 1, REFRESH_MAX: 3600, MAX_RUNS_MIN: 0, MAX_RUNS_MAX: 10000, PROGRESS_TICK_MS: 100, DRAG_THRESHOLD_PX: 3, STORAGE_KEYS: Object.freeze({ interval: 'refreshInterval', maxRuns: 'maxRuns', targetTab: 'targetTab', isEnabled: 'isEnabled', isMinimized: 'isMinimized', schemaVersion: 'schemaVersion' }), CSS_CLASSES: Object.freeze({ panel: 'refresh-controller', minimized: 'minimized', selected: 'selected', pulsing: 'pulsing' }), SELECTORS: Object.freeze({ panel: '.refresh-controller', header: '.refresh-header', minimizeBtn: '#minimizeBtn', enableSwitch: '#enableSwitch', intervalInput: '#intervalInput', maxRunsInput: '#maxRunsInput', targetTabs: 'input[name="targetTab"]', tabOptions: '.tab-option', startBtn: '#startBtn', stopBtn: '#stopBtn', resetBtn: '#resetBtn', statusInfo: '#statusInfo', progressBar: '#progressBar', progressFill: '#progressFill' }), BROADCAST_CHANNEL: 'auto-refresh-script', BOOT_FLAG: '__autoRefreshBooted_v2__' }); // ============================================================ // 2. LOGGER — 轻量日志(仅 ?refreshDebug=1 时输出普通日志) // ============================================================ const Logger = (() => { const DEBUG = new URLSearchParams(location.search).has('refreshDebug'); const prefix = `[AutoRefresh v${CONFIG.VERSION}]`; return { log: DEBUG ? (...a) => console.log(prefix, ...a) : () => {}, warn: (...a) => console.warn(prefix, ...a), error: (...a) => console.error(prefix, ...a) }; })(); // ============================================================ // 3. STORAGE — 封装 GM_setValue,提供默认值、迁移、错误处理 // ============================================================ const Storage = (() => { const DEFAULTS = Object.freeze({ [CONFIG.STORAGE_KEYS.interval]: CONFIG.REFRESH_DEFAULT, [CONFIG.STORAGE_KEYS.maxRuns]: 0, [CONFIG.STORAGE_KEYS.targetTab]: 'current', [CONFIG.STORAGE_KEYS.isEnabled]: false, [CONFIG.STORAGE_KEYS.isMinimized]: false, [CONFIG.STORAGE_KEYS.schemaVersion]: CONFIG.SCHEMA_VERSION }); function get(key, fallback) { try { const v = GM_getValue(key, DEFAULTS[key]); return v === undefined || v === null ? (fallback ?? DEFAULTS[key]) : v; } catch (err) { Logger.error('Failed to read', key, err); return fallback ?? DEFAULTS[key]; } } function set(key, value) { try { GM_setValue(key, value); } catch (err) { Logger.error('Failed to write', key, err); } } function clamp(v, min, max, fallback) { const n = Number(v); return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback; } function migrate() { const current = Number(get(CONFIG.STORAGE_KEYS.schemaVersion, 0)); if (current >= CONFIG.SCHEMA_VERSION) return; // v1.x → v2:v1 的 targetTab='all' 映射到 v2 的 'same-domain' const oldTarget = get(CONFIG.STORAGE_KEYS.targetTab); if (oldTarget === 'all') { set(CONFIG.STORAGE_KEYS.targetTab, 'same-domain'); } Logger.log('Migrated storage from schema', current, '→', CONFIG.SCHEMA_VERSION); set(CONFIG.STORAGE_KEYS.schemaVersion, CONFIG.SCHEMA_VERSION); } function loadAll() { migrate(); return { refreshInterval: clamp( get(CONFIG.STORAGE_KEYS.interval), CONFIG.REFRESH_MIN, CONFIG.REFRESH_MAX, CONFIG.REFRESH_DEFAULT ), maxRuns: clamp( get(CONFIG.STORAGE_KEYS.maxRuns), CONFIG.MAX_RUNS_MIN, CONFIG.MAX_RUNS_MAX, 0 ), targetTab: get(CONFIG.STORAGE_KEYS.targetTab), isEnabled: !!get(CONFIG.STORAGE_KEYS.isEnabled), isMinimized: !!get(CONFIG.STORAGE_KEYS.isMinimized) }; } return { get, set, loadAll, clamp }; })(); // ============================================================ // 4. TIMER — 可清理的 setTimeout + requestAnimationFrame 进度 // ============================================================ class RefreshTimer { /** * @param {(pct: number) => void} onProgress 0-100 */ constructor(onProgress) { this.onProgress = onProgress; this._mainId = null; this._rafId = null; this._onFire = null; } schedule(delayMs) { this.cancel(); const startTs = performance.now(); const tick = (now) => { if (this._rafId === null) return; const pct = Math.min(100, ((now - startTs) / delayMs) * 100); this.onProgress(pct); if (pct < 100) { this._rafId = requestAnimationFrame(tick); } }; this._rafId = requestAnimationFrame(tick); this._mainId = setTimeout(() => { this._mainId = null; this.onProgress(100); this._fire(); }, delayMs); } onFire(fn) { this._onFire = fn; return this; } _fire() { if (typeof this._onFire === 'function') this._onFire(); } cancel() { if (this._mainId !== null) { clearTimeout(this._mainId); this._mainId = null; } if (this._rafId !== null) { cancelAnimationFrame(this._rafId); this._rafId = null; } } } // ============================================================ // 5. STYLES — 内联 CSS(Material Design 3) // ============================================================ const STYLES = ` @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap'); @import url('https://fonts.googleapis.com/icon?family=Material+Icons'); .refresh-controller { position: fixed; top: 20px; right: 20px; width: 320px; background: #fef7ff; border: 1px solid #e8def8; border-radius: 28px; box-shadow: 0 4px 8px 3px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.3); font-family: 'Roboto', sans-serif; z-index: 2147483647; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); color: #1d1b20; } .refresh-controller.minimized { width: 56px; height: 56px; border-radius: 28px; overflow: hidden; } .refresh-header { display: flex; align-items: center; padding: 16px 20px; background: linear-gradient(135deg, #6750a4 0%, #7c5dfa 100%); color: #ffffff; border-radius: 28px 28px 0 0; cursor: move; user-select: none; } .refresh-controller.minimized .refresh-header { border-radius: 28px; padding: 16px; justify-content: center; } .refresh-title { flex: 1; font-size: 16px; font-weight: 500; margin: 0; } .refresh-controller.minimized .refresh-title { display: none; } .toggle-btn { background: none; border: none; color: #ffffff; cursor: pointer; padding: 4px; border-radius: 12px; transition: background-color 0.2s; display: inline-flex; align-items: center; justify-content: center; } .toggle-btn:hover { background: rgba(255, 255, 255, 0.1); } .toggle-btn:focus-visible { outline: 2px solid #ffffff; outline-offset: 2px; } .refresh-content { padding: 20px; display: block; } .refresh-controller.minimized .refresh-content { display: none; } .form-group { margin-bottom: 16px; } .form-label, .tab-selector > legend { display: block; font-size: 14px; font-weight: 500; color: #49454f; margin-bottom: 8px; } .form-input { width: 100%; padding: 12px 16px; border: 1px solid #79747e; border-radius: 12px; font-size: 14px; font-family: 'Roboto', sans-serif; background: #fef7ff; color: #1d1b20; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); box-sizing: border-box; } .form-input:focus { outline: none; border-color: #6750a4; box-shadow: 0 0 0 2px rgba(103, 80, 164, 0.2); } .form-input:disabled { background: #f7f2fa; color: #9a9999; border-color: #c4c7c5; } .switch-container { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .switch { position: relative; width: 52px; height: 32px; display: inline-block; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #e8def8; transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 16px; border: 2px solid #79747e; } .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 4px; bottom: 4px; background-color: #79747e; transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 50%; } .switch input:checked + .slider { background-color: #6750a4; border-color: #6750a4; } .switch input:checked + .slider:before { transform: translateX(20px); background-color: #ffffff; } .switch input:focus-visible + .slider { outline: 2px solid #6750a4; outline-offset: 2px; } .btn { padding: 6px 12px; border: none; border-radius: 20px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); font-family: 'Roboto', sans-serif; display: inline-flex; align-items: center; gap: 6px; } .btn:focus-visible { outline: 2px solid #6750a4; outline-offset: 2px; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn-primary { background: #6750a4; color: #ffffff; } .btn-primary:hover:not(:disabled) { background: #5a47a1; box-shadow: 0 2px 4px 1px rgba(103, 80, 164, 0.3); } .btn-secondary { background: #e8def8; color: #6750a4; } .btn-secondary:hover:not(:disabled) { background: #ddd2ea; } .btn-danger { background: #b3261e; color: #ffffff; } .btn-danger:hover:not(:disabled) { background: #8c1d18; } .btn-row { display: flex; gap: 12px; margin-top: 20px; flex-wrap: wrap; } .status-info { background: #e6f3ff; border: 1px solid #bbdefb; border-radius: 12px; padding: 12px; margin-bottom: 16px; font-size: 13px; color: #1565c0; } .progress-bar { width: 100%; height: 4px; background: #e8def8; border-radius: 2px; overflow: hidden; margin-top: 8px; } .progress-fill { height: 100%; background: linear-gradient(90deg, #6750a4, #7c5dfa); transition: width 0.1s linear; border-radius: 2px; width: 0%; } .tab-selector { margin-bottom: 16px; border: 0; padding: 0; } .tab-option { display: flex; align-items: center; padding: 8px 12px; margin: 4px 0; border-radius: 8px; cursor: pointer; transition: background-color 0.2s; font-size: 13px; } .tab-option:hover { background: #f3eff4; } .tab-option.selected { background: #e8def8; color: #6750a4; } .tab-option input[type="radio"] { margin-right: 8px; } .material-icons { font-size: 18px; vertical-align: middle; } @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.6; } 100% { opacity: 1; } } .pulsing { animation: pulse 2s infinite; } /* 默认禁用的浮动触发图标:仅在用户未启用时显示 */ .refresh-trigger { position: fixed; bottom: 20px; right: 20px; width: 48px; height: 48px; border-radius: 24px; background: #6750a4; color: #ffffff; border: none; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); cursor: pointer; z-index: 2147483647; display: inline-flex; align-items: center; justify-content: center; transition: transform 0.2s; } .refresh-trigger:hover { transform: scale(1.05); } .refresh-trigger:focus-visible { outline: 2px solid #6750a4; outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { .refresh-controller, .btn, .toggle-btn, .slider, .slider:before, .progress-fill, .refresh-trigger { transition: none; animation: none; } .pulsing { animation: none; } } `; // ============================================================ // 6. PANEL — UI 模板与挂载 // ============================================================ const Panel = (() => { function template(state) { const cls = CONFIG.CSS_CLASSES; return `