// ==UserScript== // @name 工单页保存解答到 PocketBase 题库 // @namespace pocketbase-answer-collector // @version 0.4.0 // @description 在教师端工单详情页采集题目、图片、老师解答记录,并保存到 PocketBase。 // @author ChatGPT // @match https://chath5.kaoshids.com/* // @match https://chatteacher.kaoshids.com/* // @connect chatteacher.kaoshids.com // @connect 127.0.0.1 // @connect localhost // @connect * // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const CONFIG = { PB_BASE_DEFAULT: 'https://pocketbase.hellomaggie.top', TEACHER_API_BASE: 'https://chatteacher.kaoshids.com', GRADE_FIELD: 'grade', CHAT_PAGE_SIZE: 100 }; const state = { issue: null, messages: [], profile: null, existingOrder: null, existingProblem: null, token: '', pbBase: GM_getValue('pb_base', CONFIG.PB_BASE_DEFAULT) }; GM_addStyle(` .pbqc-btn { position: fixed; right: 22px; bottom: 28px; z-index: 2147483646; border: none; border-radius: 999px; background: #1677ff; color: #fff; padding: 12px 18px; font-size: 14px; font-weight: 700; cursor: pointer; box-shadow: 0 10px 28px rgba(22,119,255,.35); } .pbqc-btn:hover { background: #0958d9; } .pbqc-mask { position: fixed; inset: 0; z-index: 2147483647; background: rgba(15,23,42,.46); display: flex; align-items: center; justify-content: center; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; } .pbqc-panel { width: min(1080px, 96vw); height: min(860px, 92vh); background: #f8fafc; border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 24px 80px rgba(0,0,0,.32); color: #111827; } .pbqc-head { height: 58px; background: #fff; border-bottom: 1px solid #e5e7eb; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; } .pbqc-title { font-weight: 800; font-size: 16px; } .pbqc-sub { font-size: 12px; color: #64748b; margin-top: 2px; } .pbqc-body { flex: 1; display: grid; grid-template-columns: 370px 1fr; min-height: 0; } .pbqc-left, .pbqc-right { overflow: auto; padding: 14px; } .pbqc-left { border-right: 1px solid #e5e7eb; background: #f8fafc; } .pbqc-right { background: #fff; } .pbqc-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px; margin-bottom: 12px; } .pbqc-card-title { font-size: 13px; font-weight: 800; color: #334155; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; } .pbqc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .pbqc-field { margin-bottom: 10px; } .pbqc-label { display: block; font-size: 12px; color: #475569; font-weight: 700; margin-bottom: 5px; } .pbqc-value { background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px; min-height: 32px; padding: 7px 9px; font-size: 13px; overflow-wrap: anywhere; } .pbqc-input, .pbqc-select, .pbqc-textarea { width: 100%; border: 1px solid #d1d5db; border-radius: 9px; padding: 9px 10px; font-size: 13px; color: #111827; outline: none; background: #fff; } .pbqc-input:focus, .pbqc-select:focus, .pbqc-textarea:focus { border-color: #1677ff; box-shadow: 0 0 0 3px rgba(22,119,255,.12); } .pbqc-textarea { min-height: 82px; resize: vertical; line-height: 1.55; } .pbqc-problem { min-height: 160px; } .pbqc-help { font-size: 12px; color: #94a3b8; margin-top: 4px; } .pbqc-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .pbqc-action { border: 1px solid #d1d5db; background: #fff; color: #334155; border-radius: 9px; padding: 7px 10px; font-size: 12px; font-weight: 700; cursor: pointer; } .pbqc-action:hover { background: #f8fafc; } .pbqc-primary { background: #1677ff; color: #fff; border-color: #1677ff; } .pbqc-primary:hover { background: #0958d9; } .pbqc-danger { color: #b91c1c; border-color: #fecaca; background: #fff5f5; } .pbqc-footer { background: #fff; border-top: 1px solid #e5e7eb; padding: 12px 14px; display: flex; justify-content: space-between; gap: 10px; align-items: center; } .pbqc-log { white-space: pre-wrap; background: #0f172a; color: #dbeafe; border-radius: 9px; padding: 10px; font-size: 12px; max-height: 160px; overflow: auto; } .pbqc-msg-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; } .pbqc-msg { border: 1px solid #e5e7eb; border-radius: 11px; padding: 10px; display: grid; grid-template-columns: 24px 1fr; gap: 10px; cursor: pointer; background: #fff; } .pbqc-msg.checked { border-color: #1677ff; background: #f0f7ff; } .pbqc-msg.disabled { opacity: .52; cursor: not-allowed; background: #f8fafc; } .pbqc-meta { font-size: 12px; color: #64748b; margin-bottom: 5px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } .pbqc-badge { display: inline-block; border-radius: 999px; background: #f1f5f9; color: #475569; padding: 3px 7px; font-size: 12px; font-weight: 700; } .pbqc-blue { background: #e8f1ff; color: #0958d9; } .pbqc-orange { background: #fff7ed; color: #c2410c; } .pbqc-content { font-size: 13px; color: #111827; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } .pbqc-imgs { display: flex; gap: 8px; flex-wrap: wrap; } .pbqc-img { width: 86px; height: 86px; object-fit: cover; border-radius: 10px; border: 1px solid #e5e7eb; cursor: zoom-in; } .pbqc-empty { border: 1px dashed #cbd5e1; background: #f8fafc; border-radius: 10px; color: #94a3b8; padding: 12px; text-align: center; font-size: 13px; } .pbqc-toast { position: fixed; right: 24px; bottom: 88px; z-index: 2147483647; background: #0f172a; color: #fff; border-radius: 10px; padding: 10px 13px; font-size: 13px; box-shadow: 0 12px 36px rgba(15,23,42,.28); } `); function qs(sel, root = document) { return root.querySelector(sel); } function qsa(sel, root = document) { return Array.from(root.querySelectorAll(sel)); } function cleanText(text) { return String(text || '') .replace(/\r/g, '') .replace(/[ \t]+\n/g, '\n') .trim(); } function escapeHtml(str) { return String(str ?? '').replace(/[<>&"]/g, s => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[s])); } function normalizeUrl(url) { if (!url) return url; return String(url).replace(/^http:\/\//i, 'https://'); } function toNumber(value, fallback = 0) { const n = Number(value); return Number.isFinite(n) ? n : fallback; } function formatTime(ts) { if (!ts) return ''; const d = new Date(Number(ts) * 1000); return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(); } function toast(text, duration = 2600) { const old = qs('.pbqc-toast'); if (old) old.remove(); const el = document.createElement('div'); el.className = 'pbqc-toast'; el.textContent = text; document.body.appendChild(el); setTimeout(() => el.remove(), duration); } function getIssueIdFromUrl() { const text = location.href; const match = text.match(/issueId[=/](\d+)/i) || text.match(/issue_id[=/](\d+)/i); return match ? Number(match[1]) : null; } function getTeacherToken() { const keys = [ 'token', 'Authorization', 'authToken', 'teacherToken', 'access_token', 'accessToken' ]; for (const key of keys) { const value = localStorage.getItem(key); if (value && value.length > 30) { return value.replace(/^Bearer\s+/i, ''); } } for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); const value = localStorage.getItem(key); if (!value) continue; const match = value.match(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/); if (match) return match[0]; } return prompt('没有自动找到教师端 Token,请粘贴 Bearer Token:')?.replace(/^Bearer\s+/i, '') || ''; } function gmJson({ method = 'GET', url, headers = {}, data = null }) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers, data: data ? JSON.stringify(data) : undefined, responseType: 'json', timeout: 30000, onload: (res) => { const ok = res.status >= 200 && res.status < 300; let body = res.response; if (!body && res.responseText) { try { body = JSON.parse(res.responseText); } catch (_) { body = res.responseText; } } if (ok) { resolve(body); } else { reject(new Error(`HTTP ${res.status}\n${typeof body === 'string' ? body : JSON.stringify(body, null, 2)}`)); } }, onerror: (err) => reject(new Error('网络请求失败:' + JSON.stringify(err))), ontimeout: () => reject(new Error('请求超时:' + url)) }); }); } function unwrapTeacherResponse(json) { if (json && json.errcode === 1) return json.data; if (json && json.data && json.data.errcode === 1) return json.data.data; throw new Error('教师端接口返回异常:\n' + JSON.stringify(json, null, 2)); } async function teacherGet(path) { if (!state.token) { state.token = getTeacherToken(); } if (!state.token) { throw new Error('没有教师端 Token,无法请求教师端接口。'); } const json = await gmJson({ method: 'GET', url: `${CONFIG.TEACHER_API_BASE}${path}`, headers: { Authorization: `Bearer ${state.token}`, 'Content-Type': 'application/json' } }); return unwrapTeacherResponse(json); } function pbBase() { return String(state.pbBase || CONFIG.PB_BASE_DEFAULT).replace(/\/$/, ''); } async function pbGetList(collection, filter) { const url = `${pbBase()}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}`; const json = await gmJson({ method: 'GET', url, headers: { 'Content-Type': 'application/json' } }); return json.items || []; } async function pbCreate(collection, payload) { return gmJson({ method: 'POST', url: `${pbBase()}/api/collections/${collection}/records`, headers: { 'Content-Type': 'application/json' }, data: payload }); } async function pbUpdate(collection, id, payload) { return gmJson({ method: 'PATCH', url: `${pbBase()}/api/collections/${collection}/records/${id}`, headers: { 'Content-Type': 'application/json' }, data: payload }); } function isAiMessage(msg) { return ( msg?.msg_type === 'ai' || msg?.sender_id === 1 || msg?.teacher_sender?.nickname === 'AI' ); } function isTeacherTemplateMessage(msg) { const body = msg?.msg_body || ''; return /老师已经收到你的题目|15-30\s*分钟|转接人工|已为你转接|请你检查并补充完整|会在\s*15/.test(body); } function isValidSolutionMessage(msg) { if (!msg) return false; if (!['text', 'photo', 'voice'].includes(msg.msg_type)) return false; if (isAiMessage(msg)) return false; return true; } function isDefaultChecked(msg) { if (!isValidSolutionMessage(msg)) return false; if (msg.sender_type !== 1) return false; if (isTeacherTemplateMessage(msg)) return false; return true; } function getSenderName(msg) { if (isAiMessage(msg)) return 'AI'; if (msg.sender_type === 1) return msg.teacher_sender?.nickname || `老师${msg.sender_id || ''}`; if (msg.sender_type === 2) return msg.student_sender?.nickname || `学生${msg.sender_id || ''}`; return `发送者${msg.sender_id || ''}`; } function getTypeName(type) { return { text: '文字', photo: '图片', voice: '语音', ai: 'AI' }[type] || type || '未知'; } function previewBody(msg) { let body = msg.msg_body || ''; if (msg.msg_type === 'photo') body = '[图片] ' + body; if (msg.msg_type === 'voice') body = '[语音] ' + body; if (msg.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(m => m.sender_type === 1 && !isAiMessage(m))?.sender_id || 0 ); } function getTeacherName(messages, profile) { const msg = messages.find(m => m.sender_type === 1 && !isAiMessage(m) && m.teacher_sender?.nickname); return msg?.teacher_sender?.nickname || profile?.nickname || ''; } function buildProblemImages(issue, messages) { const result = []; const seen = new Set(); function add(url) { if (!url) return; const u = normalizeUrl(url); if (seen.has(u)) return; seen.add(u); result.push({ index: result.length + 1, msg_type: 'photo', msg_body: u }); } add(issue?.issue_pic); messages .filter(m => m.sender_type === 2 && m.msg_type === 'photo' && m.msg_body) .forEach(m => add(m.msg_body)); return result; } function buildSolution(messages) { return messages.map((msg, index) => { const item = { index: index + 1, msg_type: msg.msg_type, msg_body: msg.msg_type === 'text' ? cleanText(msg.msg_body) : normalizeUrl(msg.msg_body), explain: msg.msg_type === 'text' ? '老师文字解答' : msg.msg_type === 'photo' ? '老师解答图片' : msg.msg_type === 'voice' ? '老师语音讲解' : '老师解答记录' }; if (msg.msg_type === 'voice' && msg.duration) { item.duration = toNumber(msg.duration); } return item; }); } function log(text, obj) { const el = qs('#pbqc-log'); if (!el) return; if (el.textContent === '等待操作...') { el.textContent = ''; } el.textContent += obj === undefined ? text + '\n' : text + ' ' + (typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)) + '\n'; el.scrollTop = el.scrollHeight; } function showLoading() { closePanel(); const mask = document.createElement('div'); mask.className = 'pbqc-mask'; mask.innerHTML = `
${escapeHtml(err.message || String(err))}
等待操作...