// ==UserScript== // @name 题库助手 - 保存与历史转发合并版 // @namespace pocketbase-answer-toolkit // @version 2.1.0 // @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-idle // ==/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',//p2t模式 OCR_DEFAULT_RESIZED_SHAPE: 1024, DEFAULT_DELAY_MS: 800 }; const state = { pbBase: GM_getValue('pbBase', GM_getValue('pb_base', CONFIG.PB_BASE_DEFAULT)), pbToken: GM_getValue('pbToken', ''), teacherToken: GM_getValue('teacherHttpToken', ''), 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, 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 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); return match ? Number(match[1]) : null; } 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 scoreJwtCandidate(candidate) { const key = String(candidate.key || ''); const payload = candidate.payload || {}; let score = 0; if (isExpiredJwtPayload(payload)) return -1; if (payload.teacherId || payload.teacher_id) score += 100; if (/teacher/i.test(key)) score += 25; if (/token|auth|authorization|access/i.test(key)) score += 12; if (payload.exp) score += 5; if (payload.studentId || payload.student_id) score -= 50; 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 = String(token || '').replace(/^Bearer\s+/i, '').trim(); if (!cleaned) return ''; state.teacherToken = cleaned; 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) { sourceEl.textContent = source === 'page' ? '已从当前页面自动获取' : source === 'saved' ? '使用已保存 Token' : '使用手动 Token'; } return state.teacherToken; } function autoRefreshTeacherTokenFromPage() { const candidate = getPageTeacherTokenCandidate(); if (!candidate?.token) return ''; const previous = state.teacherToken; const token = applyTeacherToken(candidate.token, 'page'); 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)); state.pbBase = pbBase; state.pbToken = pbToken.replace(/^Bearer\s+/i, ''); state.teacherToken = teacherToken.replace(/^Bearer\s+/i, ''); 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() { 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:') || ''; state.teacherToken = manual.replace(/^Bearer\s+/i, '').trim(); if (state.teacherToken) { GM_setValue('teacherHttpToken', state.teacherToken); const input = qs('#pbt-teacher-token'); if (input) input.value = state.teacherToken; } return state.teacherToken; } 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}: ${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('请求超时:' + 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)); } async function teacherGet(path, askForToken = false) { const token = ensureTeacherToken(askForToken); if (!token) { throw new Error('没有教师端 Token,无法请求教师端接口。'); } const raw = await gmRequestJson({ method: 'GET', url: `${CONFIG.TEACHER_API_BASE}${path}`, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }); return normalizeTeacherResponse(raw); } 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 getTeacherId(issue, messages) { return ( issue?.assignments?.[0]?.teacher_id || messages.find(message => message.sender_type === 1 && !isAiMessage(message))?.sender_id || 0 ); } function getTeacherName(messages, profile) { const message = messages.find(item => item.sender_type === 1 && !isAiMessage(item) && item.teacher_sender?.nickname); return message?.teacher_sender?.nickname || profile?.nickname || ''; } 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(err.message || String(err))}
等待操作...