// ==UserScript== // @name 习题库 - 历史解答转发到当前学生 // @namespace https://pocketbase.hellomaggie.top/ // @version 1.1.0 // @description 通过 issue_problems 记录 ID 读取历史解答,并发送给当前教师端聊天页的学生;默认收起,无开场和结尾提示 // @author hyb // @match https://chath5.kaoshids.com/* // @match https://chatteacher.kaoshids.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @connect pocketbase.hellomaggie.top // @connect chatteacher.kaoshids.com // @run-at document-end // ==/UserScript== (function () { 'use strict'; /****************************************************************** * 1. 基础配置 ******************************************************************/ const CONFIG = { pbBase: GM_getValue('pbBase', 'https://pocketbase.hellomaggie.top'), pbCollection: 'issue_problems', teacherApiBase: 'https://chatteacher.kaoshids.com', socketUrl: 'wss://chatteacher.kaoshids.com/socket.io/?EIO=4&transport=websocket', defaultDelayMs: Number(GM_getValue('delayMs', 800)) || 800 }; const STATE = { currentIssueId: null, currentRoomId: null, currentIssueInfo: null, teacherHttpToken: '', socketToken: '', oldProblemId: '', oldProblemRecord: null, solutionItems: [], ws: null, namespaceConnected: false, ackSeq: 0, ackMap: new Map(), isSending: false, stopSending: false }; /****************************************************************** * 2. 样式 ******************************************************************/ GM_addStyle(` #pb-forward-panel { position: fixed; right: 18px; bottom: 18px; z-index: 999999; width: 390px; max-height: 86vh; background: #ffffff; border: 1px solid #d9e2ec; border-radius: 14px; box-shadow: 0 12px 34px rgba(15, 23, 42, 0.18); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; color: #172033; overflow: hidden; } #pb-forward-panel * { box-sizing: border-box; } .pbfp-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: linear-gradient(135deg, #2563eb, #14b8a6); color: #fff; font-weight: 700; cursor: move; user-select: none; } .pbfp-head-title { font-size: 15px; } .pbfp-head-btns { display: flex; gap: 6px; } .pbfp-icon-btn { border: none; background: rgba(255,255,255,0.18); color: #fff; border-radius: 8px; padding: 4px 8px; cursor: pointer; font-size: 12px; } .pbfp-body { padding: 12px; overflow-y: auto; max-height: calc(86vh - 48px); background: #f8fafc; } .pbfp-row { margin-bottom: 10px; } .pbfp-label { font-size: 12px; font-weight: 700; color: #475569; margin-bottom: 5px; } .pbfp-input { width: 100%; border: 1px solid #cbd5e1; border-radius: 10px; padding: 8px 10px; font-size: 13px; outline: none; background: #fff; } .pbfp-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.14); } .pbfp-btn-line { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 10px; } .pbfp-btn { border: none; border-radius: 10px; padding: 8px 10px; font-size: 13px; font-weight: 700; cursor: pointer; background: #e2e8f0; color: #172033; } .pbfp-btn.primary { background: #2563eb; color: #fff; } .pbfp-btn.success { background: #16a34a; color: #fff; } .pbfp-btn.danger { background: #dc2626; color: #fff; } .pbfp-btn.warning { background: #f59e0b; color: #fff; } .pbfp-btn:disabled { opacity: 0.55; cursor: not-allowed; } .pbfp-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 9px; margin-bottom: 10px; } .pbfp-meta { font-size: 12px; line-height: 1.65; color: #334155; word-break: break-all; } .pbfp-meta b { color: #0f172a; } .pbfp-check-line { display: flex; align-items: center; gap: 7px; font-size: 13px; color: #334155; margin: 6px 0; } .pbfp-preview { display: flex; flex-direction: column; gap: 8px; } .pbfp-item { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 8px; } .pbfp-item-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } .pbfp-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 52px; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #fff; background: #64748b; } .pbfp-badge.text { background: #2563eb; } .pbfp-badge.photo { background: #7c3aed; } .pbfp-badge.voice { background: #0891b2; } .pbfp-item-content { font-size: 12px; color: #334155; word-break: break-all; line-height: 1.55; max-height: 90px; overflow: auto; background: #f8fafc; border-radius: 8px; padding: 6px; } .pbfp-item-content img { max-width: 100%; border-radius: 8px; display: block; } .pbfp-status { font-size: 12px; font-weight: 700; color: #64748b; } .pbfp-status.ok { color: #16a34a; } .pbfp-status.err { color: #dc2626; } .pbfp-status.wait { color: #f59e0b; } .pbfp-log { height: 120px; overflow: auto; background: #0f172a; color: #dbeafe; border-radius: 10px; padding: 8px; font-size: 12px; line-height: 1.55; white-space: pre-wrap; } .pbfp-small { font-size: 12px; color: #64748b; line-height: 1.5; } .pbfp-hidden { display: none !important; } `); /****************************************************************** * 3. 工具函数 ******************************************************************/ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function normalizeUrl(url) { if (!url) return ''; return String(url).trim().replace(/^http:\/\//i, 'https://'); } function safeJsonParse(text, fallback = null) { try { return JSON.parse(text); } catch (_) { return fallback; } } function normalizeTeacherResponse(json) { if (json && typeof json === 'object') { if (typeof json.errcode !== 'undefined') return json; if (json.data && typeof json.data.errcode !== 'undefined') return json.data; } return json; } function toArray(value) { if (Array.isArray(value)) return value; if (typeof value === 'string') { const parsed = safeJsonParse(value, []); return Array.isArray(parsed) ? parsed : []; } return []; } function decodeJwtPayload(token) { try { const raw = String(token || '').replace(/^Bearer\s+/i, ''); const parts = raw.split('.'); if (parts.length < 2) return null; const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); const json = decodeURIComponent( atob(payload) .split('') .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('') ); return JSON.parse(json); } catch (_) { return null; } } function collectJwtCandidates() { const root = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const stores = []; try { stores.push(root.localStorage); } catch (_) {} try { stores.push(root.sessionStorage); } catch (_) {} try { stores.push(window.localStorage); } catch (_) {} try { stores.push(window.sessionStorage); } catch (_) {} const results = []; for (const store of stores) { if (!store) continue; for (let i = 0; i < store.length; i++) { const key = store.key(i); const value = store.getItem(key) || ''; const matches = value.match(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g); if (!matches) continue; for (const token of matches) { results.push({ key, token, payload: decodeJwtPayload(token) }); } } } return results; } function findTeacherHttpToken() { const saved = GM_getValue('teacherHttpToken', ''); if (saved && saved.length > 40) { return saved.replace(/^Bearer\s+/i, ''); } const candidates = collectJwtCandidates(); const teacherToken = candidates.find(x => x.payload && x.payload.teacherId); if (teacherToken) return teacherToken.token; const byKey = candidates.find(x => /token|auth|authorization/i.test(x.key || '')); if (byKey) return byKey.token; return ''; } function gmRequestJson(options) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || 'GET', url: options.url, headers: options.headers || {}, data: options.data ? JSON.stringify(options.data) : undefined, timeout: options.timeout || 30000, onload: res => { const text = res.responseText || ''; const json = safeJsonParse(text, null); if (res.status < 200 || res.status >= 300) { reject(new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`)); return; } if (!json) { reject(new Error(`返回不是 JSON:${text.slice(0, 300)}`)); return; } resolve(json); }, onerror: err => reject(new Error('网络请求失败:' + JSON.stringify(err))), ontimeout: () => reject(new Error('请求超时')) }); }); } function log(message, type = 'info') { const el = document.querySelector('#pbfp-log'); if (!el) return; const time = new Date().toLocaleTimeString(); const prefix = type === 'error' ? '❌' : type === 'success' ? '✅' : type === 'warn' ? '⚠️' : 'ℹ️'; el.textContent += `[${time}] ${prefix} ${message}\n`; el.scrollTop = el.scrollHeight; } function setStatus(message) { const el = document.querySelector('#pbfp-status-line'); if (el) el.textContent = message || ''; } /****************************************************************** * 4. 当前页面识别 ******************************************************************/ function getCurrentIssueIdFromUrl() { const text = String(location.href || ''); const match = text.match(/issueId[=/](\d+)/i); return match ? Number(match[1]) : null; } async function fetchCurrentIssueInfo() { const issueId = getCurrentIssueIdFromUrl(); if (!issueId) { throw new Error('当前页面 URL 中没有识别到 issueId,请确认你在教师端聊天页。'); } STATE.currentIssueId = issueId; STATE.teacherHttpToken = findTeacherHttpToken(); if (!STATE.teacherHttpToken) { throw new Error('没有自动识别到教师 HTTP Token,请在面板“高级设置”里手动填写。'); } const url = `${CONFIG.teacherApiBase}/apis/teacher/issue/${issueId}`; const raw = await gmRequestJson({ method: 'GET', url, headers: { Authorization: `Bearer ${STATE.teacherHttpToken}`, 'Content-Type': 'application/json' } }); const json = normalizeTeacherResponse(raw); if (!json || json.errcode !== 1 || !json.data) { throw new Error('获取当前工单失败:' + JSON.stringify(json).slice(0, 300)); } const info = json.data; STATE.currentIssueInfo = info; STATE.currentRoomId = info.room_id; if (!STATE.currentRoomId) { throw new Error('当前工单没有返回 room_id,无法确定发送目标。'); } renderCurrentInfo(); log(`当前 issueId=${issueId},room_id=${STATE.currentRoomId}`, 'success'); return info; } function renderCurrentInfo() { const info = STATE.currentIssueInfo || {}; const el = document.querySelector('#pbfp-current-info'); if (!el) return; el.innerHTML = `
当前 issueId:${escapeHtml(STATE.currentIssueId || '-')}
当前 room_id:${escapeHtml(STATE.currentRoomId || '-')}
当前学生:${escapeHtml(info.student_name || info.studentName || '-')}
当前题目:${escapeHtml((info.issue_title || '').replace(/\s+/g, ' ').trim() || '-')}
`; } /****************************************************************** * 5. PocketBase 历史解答读取 ******************************************************************/ async function fetchIssueProblemRecord(issueProblemId) { const id = String(issueProblemId || '').trim(); if (!id) { throw new Error('请先输入 issue_problems 记录 ID。'); } CONFIG.pbBase = document.querySelector('#pbfp-pb-base')?.value.trim() || CONFIG.pbBase; GM_setValue('pbBase', CONFIG.pbBase); const pbToken = document.querySelector('#pbfp-pb-token')?.value.trim() || GM_getValue('pbToken', ''); if (pbToken) GM_setValue('pbToken', pbToken); const url = `${CONFIG.pbBase.replace(/\/+$/, '')}/api/collections/${CONFIG.pbCollection}/records/${encodeURIComponent(id)}`; const headers = { 'Content-Type': 'application/json' }; if (pbToken) { headers.Authorization = `Bearer ${pbToken.replace(/^Bearer\s+/i, '')}`; } const data = await gmRequestJson({ method: 'GET', url, headers }); const solution = toArray(data.solution) .map((item, idx) => normalizeSolutionItem(item, idx)) .filter(item => item.msg_type && item.msg_body) .filter(item => ['text', 'photo', 'voice'].includes(item.msg_type)) .sort((a, b) => { const ai = Number(a.index || 0); const bi = Number(b.index || 0); return ai - bi; }) .map((item, idx) => ({ ...item, index: Number(item.index || idx + 1) })); if (!solution.length) { throw new Error('这条 issue_problems 记录里没有可发送的 solution。'); } STATE.oldProblemId = id; STATE.oldProblemRecord = data; STATE.solutionItems = solution; renderPreview(); log(`已读取历史解答:${id},共 ${solution.length} 条`, 'success'); return data; } function normalizeSolutionItem(item, idx) { const msgType = String(item?.msg_type || '').trim(); let msgBody = item?.msg_body; if (msgType === 'photo' || msgType === 'voice') { msgBody = normalizeUrl(msgBody); } else { msgBody = String(msgBody ?? '').trim(); } return { index: Number(item?.index || idx + 1), msg_type: msgType, msg_body: msgBody, explain: String(item?.explain || ''), duration: item?.duration, source_msg_id: item?.source_msg_id || '', checked: true }; } function renderPreview() { const el = document.querySelector('#pbfp-preview'); if (!el) return; const record = STATE.oldProblemRecord || {}; const items = STATE.solutionItems || []; const textCount = items.filter(x => x.msg_type === 'text').length; const photoCount = items.filter(x => x.msg_type === 'photo').length; const voiceCount = items.filter(x => x.msg_type === 'voice').length; document.querySelector('#pbfp-old-meta').innerHTML = `
历史记录 ID:${escapeHtml(record.id || '-')}
题目文本:${escapeHtml((record.problem_text || '').slice(0, 80) || '-')}
解答数量:${items.length} 条,文字 ${textCount},图片 ${photoCount},语音 ${voiceCount}
`; el.innerHTML = items.map((item, idx) => { let bodyHtml = ''; if (item.msg_type === 'photo') { bodyHtml = `解答图片`; } else if (item.msg_type === 'voice') { bodyHtml = `
${escapeHtml(item.msg_body)}
`; } else { bodyHtml = escapeHtml(item.msg_body); } return `
待发送
${item.explain ? `
说明:${escapeHtml(item.explain)}
` : ''}
${bodyHtml}
`; }).join(''); document.querySelectorAll('.pbfp-item-check').forEach(chk => { chk.addEventListener('change', () => { const idx = Number(chk.dataset.idx); if (STATE.solutionItems[idx]) { STATE.solutionItems[idx].checked = chk.checked; } }); }); } /****************************************************************** * 6. Socket token 与 WebSocket ******************************************************************/ async function fetchSocketToken() { if (!STATE.teacherHttpToken) { STATE.teacherHttpToken = findTeacherHttpToken(); } if (!STATE.teacherHttpToken) { throw new Error('没有教师 HTTP Token,无法获取 socket token。'); } const url = `${CONFIG.teacherApiBase}/apis/teacher/socket`; const raw = await gmRequestJson({ method: 'GET', url, headers: { Authorization: `Bearer ${STATE.teacherHttpToken}`, 'Content-Type': 'application/json' } }); const json = normalizeTeacherResponse(raw); if (!json || json.errcode !== 1 || !json.data || !json.data.token) { throw new Error('获取 socket token 失败:' + JSON.stringify(json).slice(0, 300)); } STATE.socketToken = json.data.token; log('已获取 socket token', 'success'); return STATE.socketToken; } function closeSocket() { try { if (STATE.ws && STATE.ws.readyState === WebSocket.OPEN) { STATE.ws.send('41/sim/room-talk,'); STATE.ws.close(); } } catch (_) {} STATE.ws = null; STATE.namespaceConnected = false; STATE.ackMap.clear(); } async function connectSocket() { if (!STATE.currentRoomId) { throw new Error('缺少当前 room_id,不能连接房间。'); } if (!STATE.socketToken) { await fetchSocketToken(); } closeSocket(); return new Promise((resolve, reject) => { const ws = new WebSocket(CONFIG.socketUrl); STATE.ws = ws; STATE.namespaceConnected = false; const timer = setTimeout(() => { reject(new Error('WebSocket 连接超时')); }, 15000); ws.onopen = () => { log('WebSocket 已打开,等待 Engine.IO open'); }; ws.onerror = () => { clearTimeout(timer); reject(new Error('WebSocket 连接错误')); }; ws.onclose = () => { STATE.namespaceConnected = false; log('WebSocket 已关闭', 'warn'); }; ws.onmessage = event => { const data = event.data; if (typeof data === 'string' && data.startsWith('0{')) { ws.send('40'); setTimeout(() => { const frame = `40/sim/room-talk,{"token":"${STATE.socketToken}"}`; ws.send(frame); }, 180); return; } if (data === '2') { ws.send('3'); return; } if (typeof data === 'string' && data.startsWith('40/sim/room-talk,')) { STATE.namespaceConnected = true; const joinFrame = `42/sim/room-talk,["teacher-join-room",{"room_id":${Number(STATE.currentRoomId)}}]`; ws.send(joinFrame); clearTimeout(timer); log(`已连接 /sim/room-talk,并加入 room_id=${STATE.currentRoomId}`, 'success'); resolve(true); return; } if (typeof data === 'string' && data.startsWith('43/sim/room-talk,')) { handleAck(data); return; } if (typeof data === 'string' && data.startsWith('42/sim/room-talk,')) { log('收到服务端推送事件'); } }; }); } function handleAck(frame) { const match = frame.match(/^43\/sim\/room-talk,(\d+)(.*)$/); if (!match) { log(`收到未知 ack:${frame}`, 'warn'); return; } const ackId = Number(match[1]); const payloadText = match[2] || ''; const payload = safeJsonParse(payloadText, null); const task = STATE.ackMap.get(ackId); if (!task) return; clearTimeout(task.timer); STATE.ackMap.delete(ackId); task.resolve(payload); } function sendTeacherMessage({ room_id, msg_type, msg_body }) { return new Promise((resolve, reject) => { if (!STATE.ws || STATE.ws.readyState !== WebSocket.OPEN || !STATE.namespaceConnected) { reject(new Error('WebSocket 未连接,不能发送。')); return; } const ackId = STATE.ackSeq++; const payload = JSON.stringify([ 'teacher-send-message', { room_id: Number(room_id), msg_type, msg_body } ]); const frame = `42/sim/room-talk,${ackId}${payload}`; const timer = setTimeout(() => { STATE.ackMap.delete(ackId); reject(new Error(`发送超时:ackId=${ackId}`)); }, 20000); STATE.ackMap.set(ackId, { resolve, reject, timer }); STATE.ws.send(frame); }); } /****************************************************************** * 7. 转发控制 ******************************************************************/ function getSelectedItems() { return (STATE.solutionItems || []) .filter(item => item.checked) .sort((a, b) => Number(a.index || 0) - Number(b.index || 0)); } function setItemStatus(idx, text, cls = '') { const el = document.querySelector(`#pbfp-item-status-${idx}`); if (!el) return; el.className = `pbfp-status ${cls}`; el.textContent = text; } async function forwardSelectedSolution() { if (STATE.isSending) return; const selected = getSelectedItems(); if (!STATE.currentIssueId || !STATE.currentRoomId) { throw new Error('请先初始化当前页面,确保已获取当前学生 room_id。'); } if (!STATE.oldProblemRecord || !STATE.solutionItems.length) { throw new Error('请先读取历史 issue_problems 解答。'); } if (!selected.length) { throw new Error('没有勾选任何要发送的解答内容。'); } const info = STATE.currentIssueInfo || {}; const delayMs = Math.max(300, Number(document.querySelector('#pbfp-delay')?.value || CONFIG.defaultDelayMs)); GM_setValue('delayMs', delayMs); const confirmText = [ '确认把历史解答发送给当前页面学生?', '', `当前学生:${info.student_name || '-'}`, `当前 issueId:${STATE.currentIssueId}`, `当前 room_id:${STATE.currentRoomId}`, `历史解答 ID:${STATE.oldProblemId}`, `勾选内容:${selected.length} 条`, '', '注意:本次只发送勾选的历史解答内容,不发送开场提示和结尾提示。' ].join('\n'); if (!window.confirm(confirmText)) return; STATE.isSending = true; STATE.stopSending = false; document.querySelector('#pbfp-send-btn').disabled = true; document.querySelector('#pbfp-stop-btn').disabled = false; try { setStatus('正在连接 WebSocket...'); await connectSocket(); setStatus('正在发送...'); for (const item of selected) { if (STATE.stopSending) { log('用户已停止发送', 'warn'); break; } const idx = STATE.solutionItems.indexOf(item); setItemStatus(idx, '发送中', 'wait'); log(`发送 #${item.index} ${item.msg_type}`); try { const ack = await sendTeacherMessage({ room_id: STATE.currentRoomId, msg_type: item.msg_type, msg_body: item.msg_body }); const msgId = Array.isArray(ack) && ack[0] && ack[0].msg_id ? ack[0].msg_id : ''; setItemStatus(idx, msgId ? `已发送 ${msgId}` : '已发送', 'ok'); log(`#${item.index} 已发送 ${msgId ? 'msg_id=' + msgId : ''}`, 'success'); } catch (err) { setItemStatus(idx, '发送失败', 'err'); log(`#${item.index} 发送失败:${err.message}`, 'error'); throw err; } await sleep(delayMs); } setStatus('发送完成'); log('全部发送流程结束', 'success'); } finally { STATE.isSending = false; document.querySelector('#pbfp-send-btn').disabled = false; document.querySelector('#pbfp-stop-btn').disabled = true; } } /****************************************************************** * 8. UI ******************************************************************/ function createPanel() { if (document.querySelector('#pb-forward-panel')) return; const panel = document.createElement('div'); panel.id = 'pb-forward-panel'; panel.innerHTML = `
历史解答转发
当前页面目标
尚未初始化。请先点击“初始化当前页面”。
历史 issue_problems 记录 ID
尚未读取历史解答。
每条发送间隔毫秒
历史解答预览
读取后会在这里显示 text / photo / voice。
高级设置
PocketBase 地址
PocketBase Bearer Token,可选
教师 HTTP Token,可选
不建议把管理员 Token 写进脚本。需要权限时,建议创建只读账号或设置安全 viewRule。
日志
`; document.body.appendChild(panel); bindPanelEvents(); makeDraggable(panel, document.querySelector('#pbfp-drag-head')); setTimeout(() => { fetchCurrentIssueInfo().catch(err => log(err.message, 'warn')); }, 1200); } function bindPanelEvents() { document.querySelector('#pbfp-min-btn').addEventListener('click', () => { const body = document.querySelector('#pbfp-body'); const btn = document.querySelector('#pbfp-min-btn'); body.classList.toggle('pbfp-hidden'); btn.textContent = body.classList.contains('pbfp-hidden') ? '展开' : '收起'; }); document.querySelector('#pbfp-init-btn').addEventListener('click', async () => { try { const manualToken = document.querySelector('#pbfp-teacher-token')?.value.trim(); if (manualToken) { STATE.teacherHttpToken = manualToken.replace(/^Bearer\s+/i, ''); GM_setValue('teacherHttpToken', STATE.teacherHttpToken); } await fetchCurrentIssueInfo(); } catch (err) { log(err.message, 'error'); alert(err.message); } }); document.querySelector('#pbfp-load-btn').addEventListener('click', async () => { try { const id = document.querySelector('#pbfp-problem-id').value.trim(); await fetchIssueProblemRecord(id); } catch (err) { log(err.message, 'error'); alert(err.message); } }); document.querySelector('#pbfp-send-btn').addEventListener('click', async () => { try { const manualToken = document.querySelector('#pbfp-teacher-token')?.value.trim(); if (manualToken) { STATE.teacherHttpToken = manualToken.replace(/^Bearer\s+/i, ''); GM_setValue('teacherHttpToken', STATE.teacherHttpToken); } if (!STATE.currentRoomId) { await fetchCurrentIssueInfo(); } await forwardSelectedSolution(); } catch (err) { setStatus('发送失败'); log(err.message, 'error'); alert(err.message); } }); document.querySelector('#pbfp-stop-btn').addEventListener('click', () => { STATE.stopSending = true; log('正在停止,当前消息发送完后会中断。', 'warn'); }); document.querySelector('#pbfp-select-all-btn').addEventListener('click', () => { STATE.solutionItems.forEach(x => x.checked = true); document.querySelectorAll('.pbfp-item-check').forEach(x => x.checked = true); }); document.querySelector('#pbfp-select-none-btn').addEventListener('click', () => { STATE.solutionItems.forEach(x => x.checked = false); document.querySelectorAll('.pbfp-item-check').forEach(x => x.checked = false); }); document.querySelector('#pbfp-save-setting-btn').addEventListener('click', () => { const pbBase = document.querySelector('#pbfp-pb-base').value.trim(); const pbToken = document.querySelector('#pbfp-pb-token').value.trim(); const teacherToken = document.querySelector('#pbfp-teacher-token').value.trim(); if (pbBase) GM_setValue('pbBase', pbBase); if (pbToken) GM_setValue('pbToken', pbToken.replace(/^Bearer\s+/i, '')); if (teacherToken) GM_setValue('teacherHttpToken', teacherToken.replace(/^Bearer\s+/i, '')); GM_setValue('delayMs', Number(document.querySelector('#pbfp-delay').value || 800)); log('设置已保存', 'success'); }); document.querySelector('#pbfp-clear-log-btn').addEventListener('click', () => { document.querySelector('#pbfp-log').textContent = ''; }); } function makeDraggable(panel, handle) { if (!panel || !handle) return; let startX = 0; let startY = 0; let startRight = 0; let startBottom = 0; let dragging = false; handle.addEventListener('mousedown', e => { if (e.target && e.target.tagName === 'BUTTON') return; dragging = true; startX = e.clientX; startY = e.clientY; const rect = panel.getBoundingClientRect(); startRight = window.innerWidth - rect.right; startBottom = window.innerHeight - rect.bottom; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; panel.style.right = Math.max(0, startRight - dx) + 'px'; panel.style.bottom = Math.max(0, startBottom - dy) + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); } /****************************************************************** * 9. SPA 页面兼容 ******************************************************************/ function boot() { createPanel(); } boot(); let lastHref = location.href; setInterval(() => { if (location.href !== lastHref) { lastHref = location.href; STATE.currentIssueId = null; STATE.currentRoomId = null; STATE.currentIssueInfo = null; renderCurrentInfo(); log('检测到页面切换,已重置当前目标,请重新初始化。', 'warn'); setTimeout(() => { fetchCurrentIssueInfo().catch(err => log(err.message, 'warn')); }, 1000); } }, 1000); })();