// ==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 = `
`; } /****************************************************************** * 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 = ` `; el.innerHTML = items.map((item, idx) => { let bodyHtml = ''; if (item.msg_type === 'photo') { bodyHtml = `