// ==UserScript== // @name 题库助手 - 保存与历史转发合并版 // @namespace pocketbase-answer-toolkit // @version 2.1.2 // @description 在教师端统一完成当前工单解答保存到 PocketBase 题库、历史解答读取与转发。 // @author ChatGPT + hyb // @match https://chath5.kaoshids.com/* // @match https://chatteacher.kaoshids.com/* // @connect chatteacher.kaoshids.com // @connect pocketbase.hellomaggie.top // @connect 127.0.0.1 // @connect localhost // @connect * // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @run-at document-start // ==/UserScript== (function () { 'use strict'; const CONFIG = { PB_BASE_DEFAULT: 'https://pocketbase.hellomaggie.top', PB_COLLECTION: 'issue_problems', TEACHER_API_BASE: 'https://chatteacher.kaoshids.com', SOCKET_URL: 'wss://chatteacher.kaoshids.com/socket.io/?EIO=4&transport=websocket', GRADE_FIELD: 'grade', CHAT_PAGE_SIZE: 100, OCR_API_PATH: '/api/xta/ocr', OCR_DEFAULT_FILE_TYPE: 'text_formula', OCR_DEFAULT_RESIZED_SHAPE: 1024, DEFAULT_DELAY_MS: 800 }; const SUBJECT_NAME_MAP = { 1: '语文', 2: '数学', 3: '英语', 4: '物理', 5: '化学', 6: '生物', 7: '历史', 8: '地理', 9: '政治', 10: '道法', 11: '科学' }; const state = { pbBase: GM_getValue('pbBase', GM_getValue('pb_base', CONFIG.PB_BASE_DEFAULT)), pbToken: GM_getValue('pbToken', ''), teacherToken: GM_getValue('teacherHttpToken', ''), teacherTokenSource: '', delayMs: Number(GM_getValue('delayMs', CONFIG.DEFAULT_DELAY_MS)) || CONFIG.DEFAULT_DELAY_MS, activeTab: GM_getValue('pbtActiveTab', 'save'), collapsed: GM_getValue('pbtCollapsed', true), currentIssueId: null, currentRoomId: null, currentIssueInfo: null, profile: null, messages: [], saveImages: [], existingOrder: null, existingProblem: null, existingProblems: [], ocrResult: null, ocrRunning: false, isOcrRunning: false, lastTeacherResponseCache: {}, oldProblemId: '', oldProblemRecord: null, solutionItems: [], socketToken: '', ws: null, namespaceConnected: false, ackSeq: 0, ackMap: new Map(), isSending: false, stopSending: false }; GM_addStyle(` #pbt-panel, .pbt-mask { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; color: #172033; } #pbt-panel *, .pbt-mask * { box-sizing: border-box; } #pbt-panel { position: fixed; right: 18px; bottom: 18px; z-index: 2147483645; width: min(420px, calc(100vw - 24px)); max-height: min(88vh, 760px); background: #ffffff; border: 1px solid #d9e2ec; border-radius: 8px; box-shadow: 0 18px 46px rgba(15, 23, 42, 0.2); overflow: hidden; } .pbt-head { min-height: 56px; padding: 11px 12px; display: flex; align-items: center; justify-content: space-between; gap: 10px; background: linear-gradient(135deg, #1d4ed8, #0f766e); color: #fff; user-select: none; cursor: move; } .pbt-title { font-size: 15px; line-height: 1.2; font-weight: 800; } .pbt-sub { margin-top: 3px; font-size: 12px; line-height: 1.25; color: rgba(255, 255, 255, .82); max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pbt-head-actions, .pbt-actions, .pbt-btn-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .pbt-head-btn { border: 1px solid rgba(255, 255, 255, .28); background: rgba(255, 255, 255, .16); color: #fff; border-radius: 8px; padding: 6px 9px; font-size: 12px; line-height: 1; font-weight: 700; cursor: pointer; } .pbt-head-btn:hover { background: rgba(255, 255, 255, .24); } .pbt-body { max-height: calc(min(88vh, 760px) - 56px); overflow: auto; background: #f6f8fb; padding: 12px; } .pbt-hidden { display: none !important; } .pbt-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-bottom: 10px; padding: 4px; border: 1px solid #dde5ee; border-radius: 8px; background: #eef3f8; } .pbt-tab { border: none; border-radius: 6px; background: transparent; color: #475569; padding: 8px 6px; font-size: 13px; font-weight: 800; cursor: pointer; } .pbt-tab.active { background: #ffffff; color: #0f172a; box-shadow: 0 1px 3px rgba(15, 23, 42, .08); } .pbt-card { background: #fff; border: 1px solid #dde5ee; border-radius: 8px; padding: 11px; margin-bottom: 10px; } .pbt-card-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 9px; color: #243244; font-size: 13px; font-weight: 900; } .pbt-small { font-size: 12px; line-height: 1.55; color: #64748b; word-break: break-word; } .pbt-meta { display: grid; gap: 5px; font-size: 12px; line-height: 1.55; color: #334155; word-break: break-word; } .pbt-meta b { color: #111827; } .pbt-field { margin-bottom: 10px; } .pbt-label { display: block; margin-bottom: 5px; font-size: 12px; color: #475569; font-weight: 800; } .pbt-input, .pbt-select, .pbt-textarea { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 9px 10px; color: #111827; background: #fff; outline: none; font-size: 13px; line-height: 1.4; } .pbt-input:focus, .pbt-select:focus, .pbt-textarea:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, .13); } .pbt-textarea { min-height: 84px; resize: vertical; } .pbt-btn { border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; color: #334155; min-height: 34px; padding: 7px 11px; font-size: 13px; line-height: 1.2; font-weight: 800; cursor: pointer; } .pbt-btn:hover { background: #f8fafc; } .pbt-btn:disabled { opacity: .55; cursor: not-allowed; } .pbt-btn-primary { border-color: #2563eb; background: #2563eb; color: #fff; } .pbt-btn-primary:hover { background: #1d4ed8; } .pbt-btn-success { border-color: #15803d; background: #15803d; color: #fff; } .pbt-btn-success:hover { background: #166534; } .pbt-btn-danger { border-color: #dc2626; background: #dc2626; color: #fff; } .pbt-btn-warning { border-color: #f59e0b; background: #f59e0b; color: #fff; } .pbt-badge { display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: #eef2f7; color: #475569; padding: 3px 8px; min-height: 22px; font-size: 12px; line-height: 1; font-weight: 800; white-space: nowrap; } .pbt-badge-blue { background: #e8f1ff; color: #1d4ed8; } .pbt-badge-green { background: #dcfce7; color: #166534; } .pbt-badge-orange { background: #fff7ed; color: #c2410c; } .pbt-badge-red { background: #fee2e2; color: #b91c1c; } .pbt-preview { display: grid; gap: 8px; } .pbt-history-item { border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; padding: 9px; } .pbt-history-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; } .pbt-check-line { display: inline-flex; align-items: center; gap: 7px; min-width: 0; font-size: 13px; color: #334155; font-weight: 700; } .pbt-item-content { max-height: 120px; overflow: auto; border-radius: 8px; background: #f8fafc; padding: 7px; color: #334155; font-size: 12px; line-height: 1.55; word-break: break-word; white-space: pre-wrap; } .pbt-item-content img { display: block; max-width: 100%; border-radius: 8px; border: 1px solid #e2e8f0; } .pbt-status { font-size: 12px; color: #64748b; font-weight: 800; white-space: nowrap; } .pbt-status.ok { color: #15803d; } .pbt-status.err { color: #dc2626; } .pbt-status.wait { color: #d97706; } .pbt-log { height: 128px; overflow: auto; border-radius: 8px; background: #111827; color: #dbeafe; padding: 9px; font-size: 12px; line-height: 1.55; white-space: pre-wrap; } .pbt-empty { border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc; color: #64748b; padding: 12px; text-align: center; font-size: 13px; line-height: 1.55; } .pbt-toast { position: fixed; right: 22px; bottom: 88px; z-index: 2147483647; max-width: min(420px, calc(100vw - 36px)); border-radius: 8px; background: #111827; color: #fff; padding: 10px 13px; font-size: 13px; line-height: 1.45; box-shadow: 0 16px 40px rgba(15, 23, 42, .28); } .pbt-mask { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; padding: 18px; background: rgba(15, 23, 42, .46); } .pbt-modal { width: min(1120px, 96vw); height: min(880px, 92vh); display: flex; flex-direction: column; overflow: hidden; border-radius: 8px; background: #f6f8fb; box-shadow: 0 26px 80px rgba(15, 23, 42, .32); } .pbt-modal-head, .pbt-modal-footer { background: #fff; border-color: #dde5ee; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .pbt-modal-head { border-bottom: 1px solid #dde5ee; } .pbt-modal-footer { border-top: 1px solid #dde5ee; } .pbt-modal-title { color: #111827; font-size: 16px; font-weight: 900; line-height: 1.2; } .pbt-modal-sub { margin-top: 3px; color: #64748b; font-size: 12px; line-height: 1.35; } .pbt-save-layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 370px minmax(0, 1fr); } .pbt-save-left, .pbt-save-right { min-height: 0; overflow: auto; padding: 14px; } .pbt-save-left { border-right: 1px solid #dde5ee; background: #f6f8fb; } .pbt-save-right { background: #fff; } .pbt-grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .pbt-value { min-height: 32px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; padding: 7px 9px; font-size: 12px; line-height: 1.45; color: #334155; word-break: break-word; } .pbt-imgs { display: flex; flex-wrap: wrap; gap: 8px; } .pbt-img { width: 86px; height: 86px; border: 1px solid #e2e8f0; border-radius: 8px; object-fit: cover; cursor: zoom-in; background: #fff; } .pbt-ocr-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } .pbt-img-card { width: 168px; border: 1px solid #dde5ee; border-radius: 8px; background: #fff; padding: 8px; } .pbt-img-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; font-size: 12px; font-weight: 800; color: #334155; } .pbt-img-card .pbt-img { width: 100%; height: 108px; margin-bottom: 8px; } .pbt-ocr-status { min-height: 22px; margin: 7px 0; color: #64748b; font-size: 12px; line-height: 1.45; } .pbt-ocr-status.ok { color: #15803d; } .pbt-ocr-status.err { color: #dc2626; } .pbt-ocr-status.wait { color: #d97706; } .pbt-ocr-text { max-height: 80px; overflow: auto; border-radius: 8px; background: #f8fafc; padding: 6px; color: #334155; font-size: 12px; line-height: 1.5; word-break: break-word; white-space: pre-wrap; } .pbt-ocr-result-panel { margin-top: 10px; border-top: 1px solid #e2e8f0; padding-top: 10px; } .pbt-ocr-result-panel .pbt-textarea { min-height: 110px; background: #fbfdff; } .pbt-crop-mask { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; padding: 18px; background: rgba(15, 23, 42, .58); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; } .pbt-crop-panel { width: min(960px, 96vw); max-height: 92vh; display: flex; flex-direction: column; overflow: hidden; border-radius: 8px; background: #fff; box-shadow: 0 26px 80px rgba(15, 23, 42, .34); } .pbt-crop-head, .pbt-crop-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-color: #dde5ee; background: #fff; } .pbt-crop-head { border-bottom: 1px solid #dde5ee; } .pbt-crop-footer { border-top: 1px solid #dde5ee; } .pbt-crop-stage-wrap { overflow: auto; padding: 14px; background: #f6f8fb; } .pbt-crop-stage { position: relative; width: max-content; max-width: 100%; margin: 0 auto; user-select: none; } .pbt-crop-img { display: block; max-width: min(880px, calc(96vw - 56px)); max-height: 68vh; border-radius: 8px; border: 1px solid #dde5ee; background: #fff; } .pbt-crop-box { position: absolute; border: 2px solid #2563eb; background: rgba(37, 99, 235, .12); box-shadow: 0 0 0 9999px rgba(15, 23, 42, .32); cursor: move; } .pbt-crop-handle { position: absolute; right: -7px; bottom: -7px; width: 14px; height: 14px; border: 2px solid #fff; border-radius: 50%; background: #2563eb; cursor: nwse-resize; } .pbt-msg-list { display: grid; gap: 8px; margin-top: 10px; } .pbt-msg { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 9px; border: 1px solid #dde5ee; border-radius: 8px; background: #fff; padding: 10px; cursor: pointer; } .pbt-msg.checked { border-color: #2563eb; background: #f0f7ff; } .pbt-msg.disabled { opacity: .55; cursor: not-allowed; background: #f8fafc; } .pbt-msg-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 5px; color: #64748b; font-size: 12px; } .pbt-msg-content { color: #111827; font-size: 13px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; } @media (max-width: 760px) { #pbt-panel { right: 10px; bottom: 10px; width: calc(100vw - 20px); } .pbt-save-layout { grid-template-columns: 1fr; } .pbt-save-left { border-right: none; border-bottom: 1px solid #dde5ee; } .pbt-modal-head, .pbt-modal-footer { align-items: flex-start; flex-direction: column; } } `); function ready(fn) { if (document.body) { fn(); return; } const observer = new MutationObserver(() => { if (!document.body) return; observer.disconnect(); fn(); }); observer.observe(document.documentElement || document, { childList: true, subtree: true }); } function qs(selector, root = document) { return root.querySelector(selector); } function qsa(selector, root = document) { return Array.from(root.querySelectorAll(selector)); } function cleanText(text) { return String(text || '') .replace(/\r/g, '') .replace(/[ \t]+\n/g, '\n') .trim(); } function escapeHtml(value) { return String(value ?? '').replace(/[<>&"']/g, char => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[char])); } 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 toArray(value) { if (Array.isArray(value)) return value; if (typeof value === 'string') { const parsed = safeJsonParse(value, []); return Array.isArray(parsed) ? parsed : []; } return []; } function toNumber(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) ? number : fallback; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function formatTime(ts) { if (!ts) return ''; const date = new Date(Number(ts) * 1000); return Number.isNaN(date.getTime()) ? '' : date.toLocaleString(); } function toast(text, duration = 2600) { const old = qs('.pbt-toast'); if (old) old.remove(); const el = document.createElement('div'); el.className = 'pbt-toast'; el.textContent = text; document.body.appendChild(el); setTimeout(() => el.remove(), duration); } function log(message, type = 'info') { const el = qs('#pbt-log'); if (!el) return; const prefix = { info: '信息', success: '成功', warn: '提醒', error: '错误' }[type] || '信息'; const time = new Date().toLocaleTimeString(); el.textContent += `[${time}] ${prefix}:${message}\n`; el.scrollTop = el.scrollHeight; } function saveLog(message, type = 'info') { const el = qs('#pbt-save-log'); if (el) { if (el.textContent === '等待操作...') el.textContent = ''; el.textContent += `${message}\n`; el.scrollTop = el.scrollHeight; } log(message, type); } function getIssueIdFromUrl() { const text = String(location.href || ''); const match = text.match(/issueId[=/](\d+)/i) || text.match(/issue_id[=/](\d+)/i) || text.match(/issue\/(\d+)/i); return match ? Number(match[1]) : null; } function getUrlSubjectId() { const text = String(location.href || ''); const match = text.match(/subjectId[=/](\d+)/i) || text.match(/subject_id[=/](\d+)/i); return match ? Number(match[1]) : 0; } 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(char => '%' + ('00' + char.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 result = []; for (const store of stores) { if (!store) continue; for (let index = 0; index < store.length; index++) { const key = store.key(index); 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) { result.push({ key, token, payload: decodeJwtPayload(token) }); } } } return result; } function isExpiredJwtPayload(payload) { if (!payload || !payload.exp) return false; return Number(payload.exp) < Math.floor(Date.now() / 1000) - 60; } function normalizeToken(token) { return String(token || '').replace(/^Bearer\s+/i, '').trim(); } function scoreJwtCandidate(candidate) { const key = String(candidate.key || ''); const payload = candidate.payload || {}; let score = 0; if (!candidate.token || isExpiredJwtPayload(payload)) return -1; if (payload.teacherId || payload.teacher_id) score += 120; if (payload.memberId || payload.member_id) score += 80; if (Number(payload.memberType ?? payload.member_type) === 1) score += 40; if (/teacher/i.test(key)) score += 25; if (/token|auth|authorization|access/i.test(key)) score += 10; if (payload.exp) score += 5; if (payload.studentId || payload.student_id) score -= 100; return score; } function getPageTeacherTokenCandidate() { return collectJwtCandidates() .map(candidate => ({ ...candidate, score: scoreJwtCandidate(candidate) })) .filter(candidate => candidate.score > 0) .sort((a, b) => b.score - a.score)[0] || null; } function applyTeacherToken(token, source = 'auto') { const cleaned = normalizeToken(token); if (!cleaned) return ''; if (isExpiredJwtPayload(decodeJwtPayload(cleaned))) return ''; state.teacherToken = cleaned; state.teacherTokenSource = source; GM_setValue('teacherHttpToken', state.teacherToken); const input = qs('#pbt-teacher-token'); if (input) input.value = state.teacherToken; const sourceEl = qs('#pbt-token-source'); if (sourceEl) { const payload = decodeJwtPayload(state.teacherToken); const teacherId = payload?.teacherId || payload?.teacher_id || payload?.memberId || payload?.member_id || ''; const label = source === 'saved' ? '已保存 Token' : source === 'manual' ? '手动 Token' : source || '自动获取'; sourceEl.textContent = `已获取 Token${teacherId ? `,ID=${teacherId}` : ''},来源:${label}`; } return state.teacherToken; } function autoRefreshTeacherTokenFromPage() { const candidate = getPageTeacherTokenCandidate(); if (!candidate?.token) return ''; const previous = state.teacherToken; const token = applyTeacherToken(candidate.token, `storage:${candidate.key || 'unknown'}`); if (token && token !== previous) { log(`已自动获取当前页面教师 Token(来源:${candidate.key || 'storage'})`, 'success'); } return token; } function readSettingsFromPanel() { const pbBase = cleanText(qs('#pbt-pb-base')?.value || state.pbBase || CONFIG.PB_BASE_DEFAULT); const pbToken = cleanText(qs('#pbt-pb-token')?.value || state.pbToken || ''); const teacherToken = cleanText(qs('#pbt-teacher-token')?.value || state.teacherToken || ''); const delayMs = Math.max(300, Number(qs('#pbt-delay')?.value || state.delayMs || CONFIG.DEFAULT_DELAY_MS)); const previousTeacherToken = state.teacherToken; const nextTeacherToken = normalizeToken(teacherToken); state.pbBase = pbBase; state.pbToken = pbToken.replace(/^Bearer\s+/i, ''); state.teacherToken = nextTeacherToken; if (!state.teacherToken) { state.teacherTokenSource = ''; } else if (state.teacherToken !== previousTeacherToken) { state.teacherTokenSource = 'manual'; } state.delayMs = delayMs; GM_setValue('pbBase', state.pbBase); GM_setValue('pb_base', state.pbBase); GM_setValue('pbToken', state.pbToken); GM_setValue('teacherHttpToken', state.teacherToken); GM_setValue('delayMs', state.delayMs); } function findTeacherToken() { if (/header/i.test(state.teacherTokenSource || '') && state.teacherToken && state.teacherToken.length > 40) { return applyTeacherToken(state.teacherToken, state.teacherTokenSource); } const pageToken = autoRefreshTeacherTokenFromPage(); if (pageToken) return pageToken; if (state.teacherToken && state.teacherToken.length > 40) { return applyTeacherToken(state.teacherToken, 'manual'); } const saved = GM_getValue('teacherHttpToken', ''); if (saved && saved.length > 40) { return applyTeacherToken(saved, 'saved'); } return ''; } function ensureTeacherToken(ask = false) { const token = findTeacherToken(); if (token) return token; if (!ask) return ''; const manual = prompt('没有自动找到教师端 Token,请粘贴 Bearer Token:') || ''; return applyTeacherToken(manual, 'manual'); } function readAuthorizationFromHeaders(headers) { if (!headers) return ''; try { if (typeof headers.get === 'function') { return headers.get('Authorization') || headers.get('authorization') || ''; } if (Array.isArray(headers)) { const found = headers.find(item => String(item?.[0] || '').toLowerCase() === 'authorization'); return found?.[1] || ''; } if (typeof headers === 'object') { return headers.Authorization || headers.authorization || ''; } } catch (_) {} return ''; } function normalizeTeacherResponseSoft(json) { if (typeof json === 'string') { json = safeJsonParse(json, null); } if (!json) return null; if (json.errcode === 1) return json.data; if (json.data && json.data.errcode === 1) return json.data.data; return null; } function rememberTeacherApiResponse(url, text) { url = String(url || ''); if (!url.includes('/apis/teacher/')) return; const json = typeof text === 'string' ? safeJsonParse(text, null) : text; const data = normalizeTeacherResponseSoft(json); if (!data) return; if (url.includes('/apis/teacher/profile')) { state.lastTeacherResponseCache.profile = data; } const issueMatch = url.match(/\/apis\/teacher\/issue\/(\d+)/); if (issueMatch) { state.lastTeacherResponseCache.issue = data; state.lastTeacherResponseCache[`issue:${issueMatch[1]}`] = data; } if (url.includes('/apis/teacher/chat-message')) { const roomMatch = url.match(/[?&]room_id=(\d+)/i); state.lastTeacherResponseCache.chatMessage = data; if (roomMatch) { state.lastTeacherResponseCache[`chatMessage:${roomMatch[1]}`] = data; } } } function installRequestInterceptor() { const root = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; try { const rawFetch = root.fetch; if (typeof rawFetch === 'function' && !rawFetch.__pbtPatched) { const patchedFetch = function (...args) { try { const input = args[0]; const init = args[1] || {}; const url = typeof input === 'string' ? input : input?.url || ''; const auth = readAuthorizationFromHeaders(init.headers) || readAuthorizationFromHeaders(input?.headers); if (auth) applyTeacherToken(auth, 'fetch-header'); return rawFetch.apply(this, args).then(async response => { try { const cloned = response.clone(); const text = await cloned.text(); rememberTeacherApiResponse(url, text); } catch (_) {} return response; }); } catch (_) { return rawFetch.apply(this, args); } }; patchedFetch.__pbtPatched = true; root.fetch = patchedFetch; } } catch (_) {} try { const XHR = root.XMLHttpRequest; if (XHR && XHR.prototype && !XHR.prototype.__pbtPatched) { const rawOpen = XHR.prototype.open; const rawSetHeader = XHR.prototype.setRequestHeader; const rawSend = XHR.prototype.send; XHR.prototype.open = function (method, url) { this.__pbtUrl = url; return rawOpen.apply(this, arguments); }; XHR.prototype.setRequestHeader = function (name, value) { if (String(name || '').toLowerCase() === 'authorization') { applyTeacherToken(value, 'xhr-header'); } return rawSetHeader.apply(this, arguments); }; XHR.prototype.send = function () { this.addEventListener('load', function () { try { rememberTeacherApiResponse(this.__pbtUrl, this.responseText); } catch (_) {} }); return rawSend.apply(this, arguments); }; XHR.prototype.__pbtPatched = true; } } catch (_) {} } function gmRequestJson(options) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || 'GET', url: options.url, headers: options.headers || {}, data: options.data === undefined ? undefined : JSON.stringify(options.data), timeout: options.timeout || 30000, onload: response => { const text = response.responseText || ''; let json = null; if (response.response && typeof response.response === 'object') { json = response.response; } else if (typeof response.response === 'string' && response.response.trim()) { json = safeJsonParse(response.response, null); } if (!json && text) { json = safeJsonParse(text, null); } if (response.status < 200 || response.status >= 300) { reject(new Error(`HTTP ${response.status}: ${options.url}\n${text.slice(0, 500)}`)); return; } if (!json) { reject(new Error(`返回不是 JSON:${options.url}\n${text.slice(0, 500)}`)); return; } resolve(json); }, onerror: err => reject(new Error(`网络请求失败:${options.url}\n${JSON.stringify(err)}`)), ontimeout: () => reject(new Error('请求超时:' + options.url)) }); }); } function normalizeTeacherResponse(json) { if (typeof json === 'string') { json = safeJsonParse(json, json); } if (json && json.errcode === 1) return json.data; if (json && json.data && json.data.errcode === 1) return json.data.data; throw new Error('教师端接口返回异常:' + JSON.stringify(json).slice(0, 500)); } function getTeacherCache(cacheKey) { const keys = Array.isArray(cacheKey) ? cacheKey : String(cacheKey || '').split(',').map(key => key.trim()).filter(Boolean); for (const key of keys) { if (state.lastTeacherResponseCache[key]) { return { key, data: state.lastTeacherResponseCache[key] }; } } return null; } async function teacherGet(path, options = {}) { const normalizedOptions = typeof options === 'object' ? options : { askForToken: Boolean(options) }; const askForToken = Boolean(normalizedOptions.askForToken); const allowCache = normalizedOptions.allowCache !== false; const cacheKey = normalizedOptions.cacheKey || ''; const token = ensureTeacherToken(askForToken); if (!token) { const cached = allowCache ? getTeacherCache(cacheKey) : null; if (cached) { log(`没有 Token,使用页面缓存:${cached.key}`, 'warn'); return cached.data; } throw new Error('没有教师端 Token,无法请求教师端接口。'); } try { const raw = await gmRequestJson({ method: 'GET', url: `${CONFIG.TEACHER_API_BASE}${path}`, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }); const data = normalizeTeacherResponse(raw); rememberTeacherApiResponse(path, raw); return data; } catch (err) { const message = err.message || String(err); if (/HTTP 401|HTTP 403|未认证/i.test(message)) { GM_setValue('teacherHttpToken', ''); state.teacherToken = ''; state.teacherTokenSource = ''; } if (/HTTP 0|HTTP 401|HTTP 403|未认证|status"?\s*:0|网络请求失败/i.test(message)) { const refreshed = findTeacherToken(); if (refreshed && refreshed !== token) { const raw = await gmRequestJson({ method: 'GET', url: `${CONFIG.TEACHER_API_BASE}${path}`, headers: { Authorization: `Bearer ${refreshed}`, 'Content-Type': 'application/json' } }); const data = normalizeTeacherResponse(raw); rememberTeacherApiResponse(path, raw); return data; } } const cached = allowCache ? getTeacherCache(cacheKey) : null; if (cached) { log(`接口失败,使用页面缓存:${cached.key}`, 'warn'); return cached.data; } throw err; } } function pbBase() { return String(state.pbBase || CONFIG.PB_BASE_DEFAULT).replace(/\/+$/, ''); } function pbHeaders() { const headers = { 'Content-Type': 'application/json' }; if (state.pbToken) { headers.Authorization = `Bearer ${state.pbToken.replace(/^Bearer\s+/i, '')}`; } return headers; } function pbAuthHeaders() { const headers = {}; if (state.pbToken) { headers.Authorization = `Bearer ${state.pbToken.replace(/^Bearer\s+/i, '')}`; } return headers; } function parseGmJsonResponse(response) { const text = response.responseText || ''; let json = null; if (response.response && typeof response.response === 'object' && !(response.response instanceof Blob)) { json = response.response; } else if (typeof response.response === 'string' && response.response.trim()) { json = safeJsonParse(response.response, null); } if (!json && text) { json = safeJsonParse(text, null); } return json; } function gmFormJson({ method = 'POST', url, headers = {}, data, timeout = 60000 }) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers, data, timeout, onload: response => { const text = response.responseText || ''; const json = parseGmJsonResponse(response); if (response.status < 200 || response.status >= 300) { reject(new Error(`HTTP ${response.status}: ${text.slice(0, 500)}`)); return; } if (!json) { reject(new Error(`返回不是 JSON:${text.slice(0, 500)}`)); return; } resolve(json); }, onerror: err => reject(new Error('网络请求失败:' + JSON.stringify(err))), ontimeout: () => reject(new Error('请求超时:' + url)) }); }); } function gmRequestFormData(options) { return gmFormJson(options); } function gmGetBlob(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', timeout: 30000, onload: response => { if (response.status < 200 || response.status >= 300) { reject(new Error(`图片读取失败:HTTP ${response.status}`)); return; } if (response.response instanceof Blob) { resolve(response.response); return; } reject(new Error('图片读取失败:返回内容不是 Blob')); }, onerror: err => reject(new Error('图片读取失败:' + JSON.stringify(err))), ontimeout: () => reject(new Error('图片读取超时')) }); }); } async function pbGetList(collection, filter) { const url = `${pbBase()}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}`; const json = await gmRequestJson({ method: 'GET', url, headers: pbHeaders() }); return json.items || []; } async function pbGetOne(collection, id) { return gmRequestJson({ method: 'GET', url: `${pbBase()}/api/collections/${collection}/records/${encodeURIComponent(id)}`, headers: pbHeaders() }); } async function pbCreate(collection, payload) { return gmRequestJson({ method: 'POST', url: `${pbBase()}/api/collections/${collection}/records`, headers: pbHeaders(), data: payload }); } async function pbUpdate(collection, id, payload) { return gmRequestJson({ method: 'PATCH', url: `${pbBase()}/api/collections/${collection}/records/${id}`, headers: pbHeaders(), data: payload }); } function syncSavePbBase() { const inputValue = cleanText(qs('#pbt-save-pb-base')?.value || ''); const panelValue = cleanText(qs('#pbt-pb-base')?.value || ''); const next = inputValue || panelValue || state.pbBase || CONFIG.PB_BASE_DEFAULT; state.pbBase = next; GM_setValue('pbBase', state.pbBase); GM_setValue('pb_base', state.pbBase); return state.pbBase; } async function ocrHealthCheck() { syncSavePbBase(); return gmRequestJson({ method: 'GET', url: `${pbBase()}${CONFIG.OCR_API_PATH}/health`, headers: pbHeaders(), timeout: 20000 }); } async function pbOcrByUrl(imageUrl) { syncSavePbBase(); const json = await gmRequestJson({ method: 'POST', url: `${pbBase()}${CONFIG.OCR_API_PATH}`, headers: pbHeaders(), data: { image_url: imageUrl, file_type: CONFIG.OCR_DEFAULT_FILE_TYPE, resized_shape: CONFIG.OCR_DEFAULT_RESIZED_SHAPE }, timeout: 90000 }); if (!json.ok) { throw new Error(json.errmsg || 'OCR 识别失败'); } return json; } async function pbOcrByFile(blob) { syncSavePbBase(); const form = new FormData(); form.append('file_type', CONFIG.OCR_DEFAULT_FILE_TYPE); form.append('resized_shape', String(CONFIG.OCR_DEFAULT_RESIZED_SHAPE)); form.append('image', blob, 'crop.jpg'); const json = await gmRequestFormData({ method: 'POST', url: `${pbBase()}${CONFIG.OCR_API_PATH}`, headers: pbAuthHeaders(), data: form, timeout: 90000 }); if (!json.ok) { throw new Error(json.errmsg || 'OCR 识别失败'); } return json; } async function ocrImageUrl(imageUrl) { return pbOcrByUrl(imageUrl); } async function ocrImageBlob(blob) { return pbOcrByFile(blob); } function isAiMessage(message) { return ( message?.msg_type === 'ai' || message?.sender_id === 1 || message?.teacher_sender?.nickname === 'AI' ); } function isTeacherTemplateMessage(message) { const body = message?.msg_body || ''; return /老师已经收到你的题目|15-30\s*分钟|转接人工|已为你转接|请你检查并补充完整|会在\s*15/.test(body); } function isValidSolutionMessage(message) { if (!message) return false; if (!['text', 'photo', 'voice'].includes(message.msg_type)) return false; if (isAiMessage(message)) return false; return true; } function isDefaultChecked(message) { if (!isValidSolutionMessage(message)) return false; if (message.sender_type !== 1) return false; if (isTeacherTemplateMessage(message)) return false; return true; } function getSenderName(message) { if (isAiMessage(message)) return 'AI'; if (message.sender_type === 1) return message.teacher_sender?.nickname || `老师${message.sender_id || ''}`; if (message.sender_type === 2) return message.student_sender?.nickname || `学生${message.sender_id || ''}`; return `发送者${message.sender_id || ''}`; } function getTypeName(type) { return { text: '文字', photo: '图片', voice: '语音', ai: 'AI' }[type] || type || '未知'; } function previewBody(message) { let body = message.msg_body || ''; if (message.msg_type === 'photo') body = '[图片] ' + body; if (message.msg_type === 'voice') body = '[语音] ' + body; if (message.msg_type === 'ai') body = '[AI] ' + body; if (body.length > 160) body = body.slice(0, 160) + '...'; return body; } function inferSubject(issue, profile) { const urlSubjectId = getUrlSubjectId(); let subjectId = toNumber(issue?.subject_id, 0) || toNumber(issue?.subjectId, 0) || urlSubjectId || 0; let subjectName = cleanText(issue?.subject_name || issue?.subjectName || ''); const profileSubjects = Array.isArray(profile?.subjects) ? profile.subjects : []; if (!subjectId && profileSubjects.length === 1) { subjectId = toNumber(profileSubjects[0].subject_id || profileSubjects[0].id, 0); } if (!subjectName && subjectId) { const matched = profileSubjects.find(item => { return toNumber(item.subject_id || item.id, 0) === subjectId; }); subjectName = cleanText(matched?.subject_name || matched?.name || '') || SUBJECT_NAME_MAP[subjectId] || ''; } if (!subjectName && profileSubjects.length === 1) { subjectName = cleanText(profileSubjects[0].subject_name || profileSubjects[0].name || ''); } if (!subjectName && subjectId) { subjectName = SUBJECT_NAME_MAP[subjectId] || ''; } return { subject_id: subjectId, subject_name: subjectName }; } function getTeacherId(issue, messages, profile = state.profile) { const fromIssue = issue?.assignments?.[0]?.teacher_id || issue?.teacher_id || issue?.teacherId; if (fromIssue) return toNumber(fromIssue, 0); const fromMessage = messages.find(message => message.sender_type === 1 && !isAiMessage(message) && message.sender_id)?.sender_id; if (fromMessage) return toNumber(fromMessage, 0); const payload = decodeJwtPayload(state.teacherToken); return ( toNumber( payload?.teacherId || payload?.teacher_id || payload?.memberId || payload?.member_id || profile?.teacher_id || profile?.id, 0 ) || 0 ); } function getTeacherName(messages, profile) { const message = messages.find(item => item.sender_type === 1 && !isAiMessage(item) && item.teacher_sender?.nickname); return cleanText(message?.teacher_sender?.nickname) || cleanText(profile?.nickname) || cleanText(profile?.real_name) || ''; } function buildProblemImages(issue, messages) { const result = []; const seen = new Set(); function add(url) { if (!url) return; const normalized = normalizeUrl(url); if (seen.has(normalized)) return; seen.add(normalized); result.push({ index: result.length + 1, msg_type: 'photo', msg_body: normalized }); } add(issue?.issue_pic); messages .filter(message => message.sender_type === 2 && message.msg_type === 'photo' && message.msg_body) .forEach(message => add(message.msg_body)); return result; } function initSaveImages(issue, messages) { state.ocrResult = null; state.ocrRunning = false; state.isOcrRunning = false; state.saveImages = buildProblemImages(issue, messages).map(image => ({ ...image, selected: true, ocr: null, ocrStatus: 'idle', ocrStatusText: '未识别', ocrError: '' })); return state.saveImages; } function getSaveImageByIndex(index) { return state.saveImages[Number(index)] || null; } function getSelectedProblemImages() { const selected = qsa('.pbt-save-image-check:checked') .map(input => getSaveImageByIndex(input.dataset.index)) .filter(Boolean); return selected.map((image, index) => ({ index: index + 1, msg_type: 'photo', msg_body: image.msg_body })); } function getSelectedOcrImages() { return qsa('.pbt-save-image-check:checked') .map(input => ({ image: getSaveImageByIndex(input.dataset.index), index: Number(input.dataset.index) })) .filter(item => item.image); } function getOcrText(image, field = 'problem') { if (!image?.ocr) return ''; if (field === 'latex') { return cleanText(image.ocr.raw_text || image.ocr.problem_latex || image.ocr.problem_text || ''); } if (field === 'raw') { return cleanText(image.ocr.raw_text || image.ocr.problem_latex || image.ocr.problem_text || ''); } return cleanText(image.ocr.problem_text || image.ocr.raw_text || ''); } function getCombinedOcrText(field = 'problem') { const recognized = (state.saveImages || []) .filter(image => image.ocr && getOcrText(image, field)) .sort((a, b) => Number(a.index || 0) - Number(b.index || 0)); if (!recognized.length) return ''; return recognized.map(image => { const text = getOcrText(image, field); return recognized.length > 1 ? `[图片${image.index}]\n${text}` : text; }).join('\n\n'); } function updateSelectedImageCount() { const count = qsa('.pbt-save-image-check:checked').length; const countEl = qs('#pbt-save-image-count'); if (countEl) countEl.textContent = String(count); } function updateOcrCombinedPreview() { state.ocrResult = { problem_text: getCombinedOcrText('problem'), raw_text: getCombinedOcrText('raw'), problem_latex: getCombinedOcrText('latex') }; const textarea = qs('#pbt-ocr-combined'); if (textarea) textarea.value = state.ocrResult.problem_text; const count = (state.saveImages || []).filter(image => image.ocr).length; const countEl = qs('#pbt-ocr-done-count'); if (countEl) countEl.textContent = String(count); } function updateOcrImageView(index) { const image = getSaveImageByIndex(index); if (!image) return; const statusEl = qs(`#pbt-ocr-status-${index}`); if (statusEl) { statusEl.className = `pbt-ocr-status ${image.ocrStatus === 'done' ? 'ok' : image.ocrStatus === 'error' ? 'err' : image.ocrStatus === 'working' ? 'wait' : ''}`; statusEl.textContent = image.ocrStatusText || '未识别'; } const textEl = qs(`#pbt-ocr-text-${index}`); if (textEl) { const text = getOcrText(image, 'problem'); textEl.textContent = text; textEl.classList.toggle('pbt-hidden', !text); } updateOcrCombinedPreview(); } function clearOcrResults() { (state.saveImages || []).forEach((image, index) => { image.ocr = null; image.ocrStatus = 'idle'; image.ocrStatusText = '未识别'; image.ocrError = ''; updateOcrImageView(index); }); state.ocrResult = null; } function fillProblemTextFromOcr(mode = 'overwrite') { const text = getCombinedOcrText('problem'); const textarea = qs('#pbt-save-problem-text'); if (!text) { toast('还没有可填入的 OCR 结果。'); return; } if (!textarea) return; if (mode === 'append' && cleanText(textarea.value)) { textarea.value = cleanText(textarea.value) + '\n\n' + text; } else { textarea.value = text; } textarea.dispatchEvent(new Event('input', { bubbles: true })); toast(mode === 'append' ? '已追加 OCR 结果' : '已覆盖题干为 OCR 结果'); } async function runOcrForImage(index, options = {}) { const image = getSaveImageByIndex(index); if (!image) return null; image.ocrStatus = 'working'; image.ocrStatusText = options.blob ? '正在识别裁剪图...' : '正在识别原图...'; image.ocrError = ''; updateOcrImageView(index); try { const json = options.blob ? await ocrImageBlob(options.blob) : await ocrImageUrl(image.msg_body); image.ocr = json; image.ocrStatus = 'done'; image.ocrStatusText = `识别完成:${cleanText(json.problem_text || json.raw_text || '').length} 字`; saveLog(`OCR 图片${image.index}完成`, 'success'); updateOcrImageView(index); return json; } catch (err) { image.ocr = null; image.ocrStatus = 'error'; image.ocrError = err.message || String(err); image.ocrStatusText = '识别失败:' + image.ocrError; saveLog(`OCR 图片${image.index}失败:${image.ocrError}`, 'error'); updateOcrImageView(index); throw err; } } async function runOcrForSelectedImages(button) { const selected = getSelectedOcrImages(); if (!selected.length) { toast('请先勾选要识别的题目图片。'); return; } state.ocrRunning = true; state.isOcrRunning = true; if (button) { button.disabled = true; button.textContent = '识别中...'; } try { for (const item of selected) { try { await runOcrForImage(item.index); } catch (_) { // 单张失败不阻断后续图片,日志里会保留具体错误。 } } const text = getCombinedOcrText('problem'); if (text && !cleanText(qs('#pbt-save-problem-text')?.value || '')) { fillProblemTextFromOcr('overwrite'); } else if (text) { toast('OCR 完成,可选择覆盖或追加到题干。'); } } finally { state.ocrRunning = false; state.isOcrRunning = false; if (button) { button.disabled = false; button.textContent = '逐个识别已选图片'; } } } async function runOcrForCurrentIssueImage(button) { const originalText = button?.textContent || ''; if (button) { button.disabled = true; button.textContent = 'OCR 识别中...'; } try { await runOcrForSelectedImages(null); } finally { if (button) { button.disabled = false; button.textContent = originalText || 'OCR 识别勾选题图'; } } } function canvasToBlob(canvas, type = 'image/jpeg', quality = 0.92) { return new Promise((resolve, reject) => { canvas.toBlob(blob => { if (blob) { resolve(blob); } else { reject(new Error('裁剪图片生成失败')); } }, type, quality); }); } async function createCroppedBlobFromElements(img, box) { const imgRect = img.getBoundingClientRect(); const boxRect = box.getBoundingClientRect(); const scaleX = img.naturalWidth / imgRect.width; const scaleY = img.naturalHeight / imgRect.height; const sx = Math.max(0, Math.round((boxRect.left - imgRect.left) * scaleX)); const sy = Math.max(0, Math.round((boxRect.top - imgRect.top) * scaleY)); const sw = Math.max(1, Math.round(boxRect.width * scaleX)); const sh = Math.max(1, Math.round(boxRect.height * scaleY)); const canvas = document.createElement('canvas'); canvas.width = Math.min(sw, img.naturalWidth - sx); canvas.height = Math.min(sh, img.naturalHeight - sy); const ctx = canvas.getContext('2d'); ctx.drawImage(img, sx, sy, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height); return canvasToBlob(canvas); } function positionCropBox(stage, box, rect) { const maxW = stage.clientWidth; const maxH = stage.clientHeight; const minSize = 28; const width = Math.max(minSize, Math.min(rect.width, maxW)); const height = Math.max(minSize, Math.min(rect.height, maxH)); const left = Math.max(0, Math.min(rect.left, maxW - width)); const top = Math.max(0, Math.min(rect.top, maxH - height)); box.style.left = left + 'px'; box.style.top = top + 'px'; box.style.width = width + 'px'; box.style.height = height + 'px'; } async function openImageCropper(image) { const blob = await gmGetBlob(image.msg_body); const objectUrl = URL.createObjectURL(blob); return new Promise((resolve, reject) => { const mask = document.createElement('div'); mask.className = 'pbt-crop-mask'; mask.innerHTML = `
裁剪识别图片${escapeHtml(image.index)}
拖动蓝色区域选择题干范围,裁剪图只用于 OCR,不会替换入库原图。
裁剪图片
`; const closeCropper = () => { URL.revokeObjectURL(objectUrl); mask.remove(); }; const cancelCropper = () => { closeCropper(); resolve(null); }; const finishCropper = value => { closeCropper(); resolve(value); }; document.body.appendChild(mask); const stage = qs('#pbt-crop-stage', mask); const img = qs('#pbt-crop-img', mask); const box = qs('#pbt-crop-box', mask); let dragMode = ''; let startX = 0; let startY = 0; let startRect = null; const resetBox = () => { positionCropBox(stage, box, { left: Math.round(stage.clientWidth * 0.05), top: Math.round(stage.clientHeight * 0.05), width: Math.round(stage.clientWidth * 0.9), height: Math.round(stage.clientHeight * 0.9) }); }; box.addEventListener('mousedown', event => { dragMode = event.target?.dataset?.cropHandle === 'resize' ? 'resize' : 'move'; startX = event.clientX; startY = event.clientY; startRect = { left: box.offsetLeft, top: box.offsetTop, width: box.offsetWidth, height: box.offsetHeight }; event.preventDefault(); }); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); const failLoad = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); closeCropper(); reject(new Error('裁剪图片加载失败')); }; if (img.complete && img.naturalWidth) { setTimeout(resetBox, 30); } else { img.addEventListener('load', () => setTimeout(resetBox, 30), { once: true }); img.addEventListener('error', failLoad, { once: true }); } function onMove(event) { if (!dragMode || !startRect) return; const dx = event.clientX - startX; const dy = event.clientY - startY; if (dragMode === 'resize') { positionCropBox(stage, box, { left: startRect.left, top: startRect.top, width: startRect.width + dx, height: startRect.height + dy }); } else { positionCropBox(stage, box, { left: startRect.left + dx, top: startRect.top + dy, width: startRect.width, height: startRect.height }); } } function onUp() { dragMode = ''; startRect = null; } mask.addEventListener('click', async event => { const action = event.target?.dataset?.cropAction; if (!action) return; if (action === 'cancel') { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); cancelCropper(); return; } if (action === 'reset') { resetBox(); return; } if (action === 'confirm') { const button = event.target; button.disabled = true; button.textContent = '处理中...'; try { const croppedBlob = await createCroppedBlobFromElements(img, box); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); finishCropper(croppedBlob); } catch (err) { toast(err.message || String(err), 3000); button.disabled = false; button.textContent = '识别裁剪区域'; } } }); }); } function buildSolution(messages) { return messages.map((message, index) => { const item = { index: index + 1, msg_type: message.msg_type, msg_body: message.msg_type === 'text' ? cleanText(message.msg_body) : normalizeUrl(message.msg_body), explain: message.msg_type === 'text' ? '老师文字解答' : message.msg_type === 'photo' ? '老师解答图片' : message.msg_type === 'voice' ? '老师语音讲解' : '老师解答记录' }; if (message.msg_type === 'voice' && message.duration) { item.duration = toNumber(message.duration); } if (message.msg_id) { item.source_msg_id = message.msg_id; } return item; }); } async function fetchCurrentIssueInfo(options = {}) { const askForToken = Boolean(options.askForToken); const withMessages = Boolean(options.withMessages); const readExisting = Boolean(options.readExisting); const issueId = getIssueIdFromUrl(); if (!issueId) { state.currentIssueId = null; state.currentRoomId = null; state.currentIssueInfo = null; renderCurrentInfo(); throw new Error('当前页面 URL 中没有识别到 issueId,请确认你在教师端工单或聊天页。'); } state.currentIssueId = issueId; const issue = await teacherGet(`/apis/teacher/issue/${issueId}`, { askForToken, cacheKey: `issue:${issueId}` }); state.currentIssueInfo = issue; state.currentRoomId = issue.room_id; if (!state.currentRoomId) { renderCurrentInfo(); throw new Error('当前工单没有返回 room_id,无法确定聊天房间。'); } if (withMessages) { const [chatData, profile] = await Promise.all([ teacherGet(`/apis/teacher/chat-message?room_id=${issue.room_id}&page_size=${CONFIG.CHAT_PAGE_SIZE}&direction=backward`, { askForToken, cacheKey: `chatMessage:${issue.room_id}` }), teacherGet('/apis/teacher/profile', { askForToken, cacheKey: 'profile' }).catch(err => { log('教师 profile 读取失败,不阻断保存:' + (err.message || String(err)), 'warn'); return null; }) ]); state.profile = profile; state.messages = Array.isArray(chatData) ? chatData.slice().sort((a, b) => (a.client_time || 0) - (b.client_time || 0)) : []; } if (readExisting) { try { const orders = await pbGetList('work_orders', `issue_id=${Number(issue.issue_id)}`); state.existingOrder = orders[0] || null; if (state.existingOrder?.id) { const problems = await pbGetList(CONFIG.PB_COLLECTION, `issue="${state.existingOrder.id}"`); state.existingProblems = problems || []; state.existingProblem = null; } else { state.existingProblem = null; state.existingProblems = []; } } catch (err) { state.existingOrder = null; state.existingProblem = null; state.existingProblems = []; log('读取 PocketBase 已有记录失败,不影响继续操作:' + err.message, 'warn'); } } renderCurrentInfo(); return issue; } function renderCurrentInfo() { const info = state.currentIssueInfo || {}; const student = info.student_name || info.studentName || '-'; const title = cleanText(info.issue_title || '').replace(/\s+/g, ' '); const html = `
issueId:${escapeHtml(state.currentIssueId || '-')}
room_id:${escapeHtml(state.currentRoomId || '-')}
学生:${escapeHtml(student)}
题目:${escapeHtml(title || '-')}
`; qsa('.pbt-current-info').forEach(el => { el.innerHTML = state.currentIssueInfo ? html : '
尚未识别当前工单。点击“刷新目标”或“初始化当前页面”。
'; }); const headSub = qs('#pbt-head-sub'); if (headSub) { headSub.textContent = state.currentIssueId ? `issue ${state.currentIssueId} / ${student}` : '未识别当前工单'; } } function createPanel() { if (qs('#pbt-panel')) return; const panel = document.createElement('div'); panel.id = 'pbt-panel'; panel.innerHTML = `
题库助手
正在识别当前工单...
当前页面目标 自动识别
尚未初始化。
保存流程
读取当前工单、聊天记录和题目图片后,会打开一个大面板供你补充年级、题干、标签和备注,再选择要保存为 solution 的老师解答。
当前发送目标
尚未初始化。
历史解答
尚未读取历史解答。
发送控制
历史解答预览
读取后会显示文字、图片和语音解答,可勾选要转发的内容。
连接设置
会优先从当前登录页面自动获取,手动填写只作为兜底。
不建议把管理员 Token 写进脚本。需要权限时,建议创建受限账号或设置安全 viewRule。
日志
`; document.body.appendChild(panel); bindPanelEvents(); makeDraggable(panel, qs('#pbt-drag-head')); showTab(state.activeTab); renderCurrentInfo(); setTimeout(() => { fetchCurrentIssueInfo().catch(err => log(err.message, 'warn')); }, 1000); } function bindPanelEvents() { const panel = qs('#pbt-panel'); if (!panel) return; panel.addEventListener('click', async event => { const action = event.target?.dataset?.action; if (!action) return; try { if (action === 'toggle-panel') { togglePanel(); return; } if (action === 'switch-tab') { showTab(event.target.dataset.tab); return; } if (action === 'refresh-target' || action === 'init-target') { readSettingsFromPanel(); await fetchCurrentIssueInfo({ askForToken: action === 'init-target' }); log('当前页面目标已更新', 'success'); return; } if (action === 'open-save') { await openSaveCollector(); return; } if (action === 'load-history') { readSettingsFromPanel(); await fetchIssueProblemRecord(qs('#pbt-problem-id')?.value || ''); return; } if (action === 'history-select-all') { state.solutionItems.forEach(item => { item.checked = true; }); qsa('.pbt-history-check').forEach(input => { input.checked = true; }); return; } if (action === 'history-select-none') { state.solutionItems.forEach(item => { item.checked = false; }); qsa('.pbt-history-check').forEach(input => { input.checked = false; }); return; } if (action === 'send-history') { readSettingsFromPanel(); await forwardSelectedSolution(); return; } if (action === 'stop-history') { state.stopSending = true; log('正在停止,当前消息发送完后会中断。', 'warn'); return; } if (action === 'save-settings') { readSettingsFromPanel(); toast('设置已保存'); log('设置已保存', 'success'); return; } if (action === 'auto-token') { const token = autoRefreshTeacherTokenFromPage(); if (!token) { toast('当前页面没有找到可用教师 Token'); log('当前页面没有找到可用教师 Token,请确认已登录教师端。', 'warn'); } else { toast('已自动获取当前页面教师 Token'); } return; } if (action === 'clear-log') { const logEl = qs('#pbt-log'); if (logEl) logEl.textContent = ''; } } catch (err) { console.error(err); toast(err.message || String(err), 3600); log(err.message || String(err), 'error'); } }); panel.addEventListener('change', event => { if (event.target?.classList?.contains('pbt-history-check')) { const index = Number(event.target.dataset.idx); if (state.solutionItems[index]) { state.solutionItems[index].checked = event.target.checked; } } }); } function togglePanel() { state.collapsed = !state.collapsed; GM_setValue('pbtCollapsed', state.collapsed); qs('#pbt-body')?.classList.toggle('pbt-hidden', state.collapsed); const btn = qs('#pbt-toggle-btn'); if (btn) btn.textContent = state.collapsed ? '展开' : '收起'; } function showTab(tab) { const next = ['save', 'forward', 'settings'].includes(tab) ? tab : 'save'; state.activeTab = next; GM_setValue('pbtActiveTab', next); qsa('.pbt-tab').forEach(button => { button.classList.toggle('active', button.dataset.tab === next); }); qsa('.pbt-tab-panel').forEach(panel => { panel.classList.toggle('pbt-hidden', panel.dataset.panel !== next); }); } 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', event => { if (event.target && event.target.tagName === 'BUTTON') return; dragging = true; startX = event.clientX; startY = event.clientY; const rect = panel.getBoundingClientRect(); startRight = window.innerWidth - rect.right; startBottom = window.innerHeight - rect.bottom; event.preventDefault(); }); document.addEventListener('mousemove', event => { if (!dragging) return; const dx = event.clientX - startX; const dy = event.clientY - startY; panel.style.right = Math.max(8, startRight - dx) + 'px'; panel.style.bottom = Math.max(8, startBottom - dy) + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); } async function openSaveCollector() { readSettingsFromPanel(); if (!getIssueIdFromUrl()) { toast('当前页面没有检测到 issueId,请在工单详情页打开。'); return; } try { showSaveLoading(); await fetchCurrentIssueInfo({ askForToken: true, withMessages: true, readExisting: true }); renderSaveModal(); } catch (err) { console.error(err); showSaveError(err); } } function closeSaveModal() { qs('.pbt-mask')?.remove(); } function showSaveLoading() { closeSaveModal(); const mask = document.createElement('div'); mask.className = 'pbt-mask'; mask.innerHTML = `
正在读取当前工单
正在请求教师端接口和 PocketBase...
请稍候
`; mask.addEventListener('click', event => { if (event.target?.dataset?.saveAction === 'close') closeSaveModal(); }); document.body.appendChild(mask); } function showSaveError(err) { closeSaveModal(); const mask = document.createElement('div'); mask.className = 'pbt-mask'; mask.innerHTML = `
读取失败
请检查 Token、PocketBase 地址、字段或权限。
${escapeHtml(err.message || String(err))}
`; mask.addEventListener('click', event => { if (event.target?.dataset?.saveAction === 'close') closeSaveModal(); }); document.body.appendChild(mask); } function renderImageList(images) { if (!images.length) return '
未检测到题目图片
'; return `
${images.map((image, index) => `
入库
题目图片
未识别
`).join('')}
OCR 合并结果 已识别 0
`; } function renderSaveMessages(messages) { if (!messages.length) return '
没有读取到聊天记录
'; return messages.map((message, index) => { const valid = isValidSolutionMessage(message); const checked = isDefaultChecked(message); const isTeacher = message.sender_type === 1 && !isAiMessage(message); return ` `; }).join(''); } function updateSaveMessageCheckedStyle() { qsa('.pbt-msg').forEach(row => { const input = qs('.pbt-save-msg-check', row); row.classList.toggle('checked', Boolean(input?.checked)); }); const count = qsa('.pbt-save-msg-check:checked').length; const countEl = qs('#pbt-save-selected-count'); if (countEl) countEl.textContent = String(count); } function renderSaveModal() { closeSaveModal(); const issue = state.currentIssueInfo; const messages = state.messages; const images = initSaveImages(issue, messages); const teacherId = getTeacherId(issue, messages, state.profile); const teacherName = getTeacherName(messages, state.profile); const subject = inferSubject(issue, state.profile); const oldGrade = state.existingOrder?.grade || ''; const existingProblemCount = Array.isArray(state.existingProblems) ? state.existingProblems.length : 0; const oldProblemText = ''; const oldNote = ''; const oldTags = ''; const mask = document.createElement('div'); mask.className = 'pbt-mask'; mask.innerHTML = `
保存当前解答到 PocketBase 题库
issue_id:${escapeHtml(issue.issue_id)} room_id:${escapeHtml(issue.room_id)}
${state.existingOrder ? `已有 ${existingProblemCount} 题,本次新建` : '新工单,新建题目'}
PocketBase
工单信息
${escapeHtml(issue.issue_id)}
${escapeHtml(issue.issue_sn || '')}
${escapeHtml(issue.student_name || '')}
${escapeHtml(teacherName || '')} / ${escapeHtml(teacherId || '')}
${escapeHtml(issue.course_id || '')}
${escapeHtml(subject.subject_id || issue.subject_id || '')}
${escapeHtml(issue.course_name || '空')}
${escapeHtml(subject.subject_name || issue.subject_name || '空')}
${escapeHtml(cleanText(issue.issue_title || ''))}
题目图片与 OCR 已选 ${images.length} / ${images.length} 张
勾选的图片会保存到当前题目;OCR 可识别原图,也可先裁剪再识别。
${renderImageList(images)}
保存日志
等待操作...
人工补充
后续检索主要靠这个字段;OCR 使用 ${escapeHtml(CONFIG.OCR_DEFAULT_FILE_TYPE)} / ${escapeHtml(CONFIG.OCR_DEFAULT_RESIZED_SHAPE)},工单标题会自动保存在 work_orders.issue_title。
选择保存为 solution 的解答记录 已选 0
${renderSaveMessages(messages)}
`; document.body.appendChild(mask); updateSaveMessageCheckedStyle(); mask.addEventListener('click', handleSaveModalClick); mask.addEventListener('change', event => { if (event.target?.classList?.contains('pbt-save-msg-check')) { updateSaveMessageCheckedStyle(); } if (event.target?.classList?.contains('pbt-save-image-check')) { updateSelectedImageCount(); } }); } async function handleSaveModalClick(event) { const action = event.target?.dataset?.saveAction; if (!action) { if (event.target?.dataset?.img) { window.open(event.target.dataset.img, '_blank'); } return; } if (action === 'close') { closeSaveModal(); return; } if (action === 'reload') { await openSaveCollector(); return; } if (action === 'select-teacher') { qsa('.pbt-save-msg-check').forEach(input => { const message = state.messages[Number(input.dataset.index)]; input.checked = isDefaultChecked(message); }); updateSaveMessageCheckedStyle(); return; } if (action === 'select-valid') { qsa('.pbt-save-msg-check:not(:disabled)').forEach(input => { input.checked = true; }); updateSaveMessageCheckedStyle(); return; } if (action === 'select-none') { qsa('.pbt-save-msg-check').forEach(input => { input.checked = false; }); updateSaveMessageCheckedStyle(); return; } if (action === 'ocr-health') { try { const json = await ocrHealthCheck(); const ok = json?.ok ? '正常' : '异常'; saveLog(`OCR 服务检查:${ok}`, json?.ok ? 'success' : 'warn'); toast(`OCR 服务:${ok}`); } catch (err) { saveLog('OCR 服务检查失败:' + (err.message || String(err)), 'error'); toast('OCR 服务检查失败', 3500); } return; } if (action === 'ocr-selected') { await runOcrForSelectedImages(event.target); return; } if (action === 'ocr-issue-image') { await runOcrForCurrentIssueImage(event.target); return; } if (action === 'ocr-original') { const index = Number(event.target.dataset.index); const button = event.target; button.disabled = true; button.textContent = '识别中...'; try { await runOcrForImage(index); const text = getCombinedOcrText('problem'); if (text && !cleanText(qs('#pbt-save-problem-text')?.value || '')) { fillProblemTextFromOcr('overwrite'); } } catch (err) { toast(err.message || String(err), 3500); } finally { button.disabled = false; button.textContent = '识别原图'; } return; } if (action === 'ocr-crop') { const index = Number(event.target.dataset.index); const image = getSaveImageByIndex(index); if (!image) return; try { const blob = await openImageCropper(image); if (!blob) return; const button = event.target; button.disabled = true; button.textContent = '识别中...'; try { await runOcrForImage(index, { blob }); const text = getCombinedOcrText('problem'); if (text && !cleanText(qs('#pbt-save-problem-text')?.value || '')) { fillProblemTextFromOcr('overwrite'); } } finally { button.disabled = false; button.textContent = '裁剪识别'; } } catch (err) { saveLog('裁剪识别失败:' + (err.message || String(err)), 'error'); toast(err.message || String(err), 3500); } return; } if (action === 'ocr-clear') { clearOcrResults(); toast('已清空 OCR 结果'); return; } if (action === 'ocr-overwrite') { fillProblemTextFromOcr('overwrite'); return; } if (action === 'ocr-append') { fillProblemTextFromOcr('append'); return; } if (action === 'save') { await saveCurrentIssueToPocketBase(event.target); } } function getSaveValues() { const pbBaseValue = cleanText(qs('#pbt-save-pb-base')?.value || state.pbBase || CONFIG.PB_BASE_DEFAULT); const grade = cleanText(qs('#pbt-save-grade')?.value || ''); const problemText = cleanText(qs('#pbt-save-problem-text')?.value || ''); const problemLatex = cleanText(state.ocrResult?.raw_text || getCombinedOcrText('raw') || getCombinedOcrText('latex')); const problemImages = getSelectedProblemImages(); const note = cleanText(qs('#pbt-save-note')?.value || ''); const tags = cleanText(qs('#pbt-save-tags')?.value || '') .split(/[,,;;\s]+/) .map(item => cleanText(item)) .filter(Boolean); return { pbBaseValue, grade, problemText, problemLatex, problemImages, note, tags }; } async function saveCurrentIssueToPocketBase(saveButton) { const issue = state.currentIssueInfo; const values = getSaveValues(); if (!issue) { toast('没有工单数据,请重新读取。'); return; } if (!values.grade) { toast('请选择 grade:初中 或 高中。'); qs('#pbt-save-grade')?.focus(); return; } if (!values.problemText) { const ok = confirm('problem_text 为空,后续只能靠图片和标签检索。确定继续保存吗?'); if (!ok) { qs('#pbt-save-problem-text')?.focus(); return; } } const selectedMessages = qsa('.pbt-save-msg-check:checked') .map(input => state.messages[Number(input.dataset.index)]) .filter(Boolean) .filter(isValidSolutionMessage) .sort((a, b) => (a.client_time || 0) - (b.client_time || 0)); if (!selectedMessages.length) { toast('请至少选择一条解答记录。'); return; } state.pbBase = values.pbBaseValue; GM_setValue('pbBase', state.pbBase); GM_setValue('pb_base', state.pbBase); saveButton.disabled = true; saveButton.textContent = '保存中...'; try { const teacherId = getTeacherId(issue, state.messages, state.profile); const teacherName = getTeacherName(state.messages, state.profile); const subject = inferSubject(issue, state.profile); saveLog('[1] 查询 work_orders...', 'info'); let orders = await pbGetList('work_orders', `issue_id=${Number(issue.issue_id)}`); let order = orders[0]; const orderPayload = { issue_id: Number(issue.issue_id), issue_sn: issue.issue_sn || '', issue_title: cleanText(issue.issue_title || ''), room_id: Number(issue.room_id), student_id: toNumber(issue.student_id), teacher_id: toNumber(teacherId), student_name: issue.student_name || '', teacher_name: teacherName || '', course_id: toNumber(issue.course_id), subject_id: toNumber(subject.subject_id), subject_name: subject.subject_name || '', course_name: issue.course_name || '', [CONFIG.GRADE_FIELD]: values.grade }; if (order) { saveLog(`[2] work_orders 已存在,更新:${order.id}`, 'info'); order = await pbUpdate('work_orders', order.id, orderPayload); } else { saveLog('[2] work_orders 不存在,创建...', 'info'); order = await pbCreate('work_orders', orderPayload); } saveLog('[3] 组装 issue_problems...', 'info'); const problemPayload = { issue: order.id, problem_text: values.problemText, problem_latex: values.problemLatex || '', tags: values.tags, problem_images: values.problemImages, solution: buildSolution(selectedMessages), teacher_id: toNumber(teacherId), teacher_name: teacherName || '', note: values.note, is_proofread: Boolean(values.problemText), is_reviewed: false }; saveLog('[4] 创建 issue_problems 新题目记录...', 'info'); const problem = await pbCreate(CONFIG.PB_COLLECTION, problemPayload); state.existingOrder = order; state.existingProblem = null; state.existingProblems = [ ...(Array.isArray(state.existingProblems) ? state.existingProblems : []), problem ]; saveLog('[完成] 保存成功', 'success'); saveLog(`work_order_id: ${order.id}`, 'success'); saveLog(`problem_id: ${problem.id}`, 'success'); toast('保存成功'); saveButton.textContent = '已保存'; renderCurrentInfo(); } catch (err) { console.error(err); saveLog('[错误] ' + (err.message || String(err)), 'error'); if (/grade|Unknown field|validation/i.test(err.message || '')) { saveLog('提示:请确认 work_orders 已新增 grade 字段,字段名必须是 grade。', 'warn'); } toast('保存失败,请查看日志。', 3600); saveButton.disabled = false; saveButton.textContent = '保存到 PocketBase'; } } async function fetchIssueProblemRecord(issueProblemId) { const id = cleanText(issueProblemId); if (!id) { throw new Error('请先输入 issue_problems 记录 ID。'); } const data = await pbGetOne(CONFIG.PB_COLLECTION, id); const solution = toArray(data.solution) .map((item, index) => normalizeSolutionItem(item, index)) .filter(item => item.msg_type && item.msg_body) .filter(item => ['text', 'photo', 'voice'].includes(item.msg_type)) .sort((a, b) => Number(a.index || 0) - Number(b.index || 0)) .map((item, index) => ({ ...item, index: Number(item.index || index + 1) })); if (!solution.length) { throw new Error('这条 issue_problems 记录里没有可发送的 solution。'); } state.oldProblemId = id; state.oldProblemRecord = data; state.solutionItems = solution; renderHistoryPreview(); log(`已读取历史解答:${id},共 ${solution.length} 条`, 'success'); return data; } function normalizeSolutionItem(item, index) { const msgType = cleanText(item?.msg_type || ''); let msgBody = item?.msg_body; if (msgType === 'photo' || msgType === 'voice') { msgBody = normalizeUrl(msgBody); } else { msgBody = cleanText(msgBody); } return { index: Number(item?.index || index + 1), msg_type: msgType, msg_body: msgBody, explain: cleanText(item?.explain || ''), duration: item?.duration, source_msg_id: item?.source_msg_id || '', checked: true }; } function renderHistoryPreview() { const metaEl = qs('#pbt-history-meta'); const previewEl = qs('#pbt-preview'); if (!metaEl || !previewEl) return; const record = state.oldProblemRecord || {}; const items = state.solutionItems || []; const textCount = items.filter(item => item.msg_type === 'text').length; const photoCount = items.filter(item => item.msg_type === 'photo').length; const voiceCount = items.filter(item => item.msg_type === 'voice').length; metaEl.innerHTML = `
历史记录 ID:${escapeHtml(record.id || '-')}
题目文本:${escapeHtml((record.problem_text || '').slice(0, 90) || '-')}
解答数量:${items.length} 条,文字 ${textCount},图片 ${photoCount},语音 ${voiceCount}
`; previewEl.innerHTML = items.map((item, index) => { 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(''); } function setSendStatus(text) { const el = qs('#pbt-send-status'); if (el) el.textContent = text || ''; } function setHistoryItemStatus(index, text, cls = '') { const el = qs(`#pbt-history-status-${index}`); if (!el) return; el.className = `pbt-status ${cls}`; el.textContent = text; } function getSelectedHistoryItems() { return (state.solutionItems || []) .filter(item => item.checked) .sort((a, b) => Number(a.index || 0) - Number(b.index || 0)); } async function fetchSocketToken() { if (!state.teacherToken) { ensureTeacherToken(true); } if (!state.teacherToken) { throw new Error('没有教师 HTTP Token,无法获取 socket token。'); } const data = await teacherGet('/apis/teacher/socket', true); if (!data || !data.token) { throw new Error('获取 socket token 失败:' + JSON.stringify(data).slice(0, 300)); } state.socketToken = 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.SOCKET_URL); 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(() => { ws.send(`40/sim/room-talk,{"token":"${state.socketToken}"}`); }, 180); return; } if (data === '2') { ws.send('3'); return; } if (typeof data === 'string' && data.startsWith('40/sim/room-talk,')) { state.namespaceConnected = true; ws.send(`42/sim/room-talk,["teacher-join-room",{"room_id":${Number(state.currentRoomId)}}]`); 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); }); } async function forwardSelectedSolution() { if (state.isSending) return; const selected = getSelectedHistoryItems(); if (!state.currentIssueId || !state.currentRoomId) { await fetchCurrentIssueInfo({ askForToken: true }); } if (!state.oldProblemRecord || !state.solutionItems.length) { throw new Error('请先读取历史 issue_problems 解答。'); } if (!selected.length) { throw new Error('没有勾选任何要发送的解答内容。'); } const info = state.currentIssueInfo || {}; 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; const sendBtn = qs('#pbt-send-btn'); const stopBtn = qs('#pbt-stop-btn'); if (sendBtn) sendBtn.disabled = true; if (stopBtn) stopBtn.disabled = false; try { setSendStatus('正在连接 WebSocket...'); await connectSocket(); setSendStatus('正在发送...'); for (const item of selected) { if (state.stopSending) { log('用户已停止发送', 'warn'); break; } const index = state.solutionItems.indexOf(item); setHistoryItemStatus(index, '发送中', '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 : ''; setHistoryItemStatus(index, msgId ? `已发送 ${msgId}` : '已发送', 'ok'); log(`#${item.index} 已发送 ${msgId ? 'msg_id=' + msgId : ''}`, 'success'); } catch (err) { setHistoryItemStatus(index, '发送失败', 'err'); log(`#${item.index} 发送失败:${err.message}`, 'error'); throw err; } await sleep(state.delayMs); } setSendStatus('发送完成'); log('全部发送流程结束', 'success'); } finally { state.isSending = false; if (sendBtn) sendBtn.disabled = false; if (stopBtn) stopBtn.disabled = true; closeSocket(); } } function boot() { createPanel(); } installRequestInterceptor(); ready(boot); let lastHref = location.href; setInterval(() => { if (location.href === lastHref) return; lastHref = location.href; state.currentIssueId = null; state.currentRoomId = null; state.currentIssueInfo = null; state.profile = null; state.messages = []; state.existingOrder = null; state.existingProblem = null; state.existingProblems = []; delete state.lastTeacherResponseCache.issue; delete state.lastTeacherResponseCache.chatMessage; renderCurrentInfo(); log('检测到页面切换,已重置当前目标。', 'warn'); setTimeout(() => { fetchCurrentIssueInfo().catch(err => log(err.message, 'warn')); }, 800); }, 1000); })();