// ==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 = `
正在读取工单
正在请求教师端接口和 PocketBase...
请稍候
`; mask.addEventListener('click', e => { if (e.target.dataset.action === 'close') closePanel(); }); document.body.appendChild(mask); } function showError(err) { closePanel(); const mask = document.createElement('div'); mask.className = 'pbqc-mask'; mask.innerHTML = `
读取失败
请检查 Token、PocketBase 地址、字段或权限。
${escapeHtml(err.message || String(err))}
`; mask.addEventListener('click', e => { if (e.target.dataset.action === 'close') closePanel(); }); document.body.appendChild(mask); } function closePanel() { const old = qs('.pbqc-mask'); if (old) old.remove(); } function renderImageList(images) { if (!images.length) return `
未检测到题目图片
`; return `
${images.map(img => ` `).join('')}
`; } function renderMessages(messages) { if (!messages.length) return `
没有读取到聊天记录
`; return messages.map((msg, index) => { const valid = isValidSolutionMessage(msg); const checked = isDefaultChecked(msg); return ` `; }).join(''); } function updateMessageCheckedStyle() { qsa('.pbqc-msg').forEach(row => { const input = qs('.pbqc-msg-check', row); row.classList.toggle('checked', Boolean(input?.checked)); }); const count = qsa('.pbqc-msg-check:checked').length; const countEl = qs('#pbqc-selected-count'); if (countEl) countEl.textContent = String(count); } function renderPanel() { closePanel(); const issue = state.issue; const messages = state.messages; const images = buildProblemImages(issue, messages); const teacherId = getTeacherId(issue, messages); const teacherName = getTeacherName(messages, state.profile); const oldGrade = state.existingOrder?.grade || ''; const oldProblemText = state.existingProblem?.problem_text || ''; const oldNote = state.existingProblem?.note || ''; const oldTags = Array.isArray(state.existingProblem?.tags) ? state.existingProblem.tags.join(',') : ''; const mask = document.createElement('div'); mask.className = 'pbqc-mask'; mask.innerHTML = `
保存解答到 PocketBase 题库
issue_id:${escapeHtml(issue.issue_id)} room_id:${escapeHtml(issue.room_id)}
${state.existingProblem ? `已存在,保存时更新` : `新题目`}
PocketBase
例如:http://127.0.0.1:8090
工单信息,自动保存到 work_orders
${escapeHtml(issue.issue_id)}
${escapeHtml(issue.issue_sn || '')}
${escapeHtml(issue.student_name || '')}
${escapeHtml(teacherName || '')} / ${escapeHtml(teacherId || '')}
${escapeHtml(issue.course_id || '')}
${escapeHtml(issue.subject_id || '')}
${escapeHtml(issue.course_name || '空')}
${escapeHtml(issue.subject_name || '空')}
${escapeHtml(cleanText(issue.issue_title || ''))}
题目图片,自动保存 problem_images ${images.length} 张
${renderImageList(images)}
日志
等待操作...
需要人工补充
后续检索主要靠这个字段。工单标题会自动保存在 work_orders.issue_title。
选择保存为 solution 的解答记录 已选 0
${renderMessages(messages)}
`; document.body.appendChild(mask); updateMessageCheckedStyle(); mask.addEventListener('click', handlePanelClick); mask.addEventListener('change', e => { if (e.target.classList.contains('pbqc-msg-check')) { updateMessageCheckedStyle(); } }); } async function handlePanelClick(e) { const action = e.target.dataset.action; if (action === 'close') { closePanel(); return; } if (action === 'reload') { closePanel(); await openCollector(); return; } if (action === 'select-teacher') { qsa('.pbqc-msg-check').forEach(input => { const msg = state.messages[Number(input.dataset.index)]; input.checked = isDefaultChecked(msg); }); updateMessageCheckedStyle(); return; } if (action === 'select-valid') { qsa('.pbqc-msg-check:not(:disabled)').forEach(input => { input.checked = true; }); updateMessageCheckedStyle(); return; } if (action === 'select-none') { qsa('.pbqc-msg-check').forEach(input => { input.checked = false; }); updateMessageCheckedStyle(); return; } if (action === 'save') { await saveToPocketBase(e.target); return; } if (e.target.dataset.img) { window.open(e.target.dataset.img, '_blank'); } } function getPanelValues() { const pbBaseValue = cleanText(qs('#pbqc-pb-base')?.value || CONFIG.PB_BASE_DEFAULT); const grade = cleanText(qs('#pbqc-grade')?.value || ''); const problemText = cleanText(qs('#pbqc-problem-text')?.value || ''); const note = cleanText(qs('#pbqc-note')?.value || ''); const tags = cleanText(qs('#pbqc-tags')?.value || '') .split(/[,,]/) .map(s => cleanText(s)) .filter(Boolean); return { pbBaseValue, grade, problemText, note, tags }; } async function saveToPocketBase(saveButton) { const issue = state.issue; const values = getPanelValues(); if (!issue) { toast('没有工单数据,请重新读取。'); return; } if (!values.grade) { toast('请选择 grade:初中 或 高中。'); qs('#pbqc-grade')?.focus(); return; } if (!values.problemText) { const ok = confirm('problem_text 为空,后续只能靠图片和标签检索。确定继续保存吗?'); if (!ok) { qs('#pbqc-problem-text')?.focus(); return; } } const selectedMessages = qsa('.pbqc-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('pb_base', state.pbBase); saveButton.disabled = true; saveButton.textContent = '保存中...'; try { const teacherId = getTeacherId(issue, state.messages); const teacherName = getTeacherName(state.messages, state.profile); log('[1] 查询 work_orders...'); 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(issue.subject_id), subject_name: issue.subject_name || '', course_name: issue.course_name || '', [CONFIG.GRADE_FIELD]: values.grade }; if (order) { log('[2] work_orders 已存在,更新:', order.id); order = await pbUpdate('work_orders', order.id, orderPayload); } else { log('[2] work_orders 不存在,创建...'); order = await pbCreate('work_orders', orderPayload); } log('[3] 组装 issue_problems...'); const problemPayload = { issue: order.id, problem_text: values.problemText, problem_latex: '', tags: values.tags, problem_images: buildProblemImages(issue, state.messages), solution: buildSolution(selectedMessages), teacher_id: toNumber(teacherId), teacher_name: teacherName || '', note: values.note, is_proofread: Boolean(values.problemText), is_reviewed: false }; log('[4] 查询 issue_problems...'); const problems = await pbGetList('issue_problems', `issue="${order.id}"`); let problem = problems[0]; if (problem) { log('[5] issue_problems 已存在,更新:', problem.id); problem = await pbUpdate('issue_problems', problem.id, problemPayload); } else { log('[5] issue_problems 不存在,创建...'); problem = await pbCreate('issue_problems', problemPayload); } state.existingOrder = order; state.existingProblem = problem; log('[完成] 保存成功'); log('work_order_id:', order.id); log('problem_id:', problem.id); toast('保存成功!'); saveButton.textContent = '已保存'; } catch (err) { console.error(err); log('[错误]', err.message || String(err)); if (/grade|Unknown field|validation/i.test(err.message || '')) { log('提示:请确认 work_orders 已新增 grade 字段,字段名必须是 grade。'); } toast('保存失败,请查看日志。', 3500); saveButton.disabled = false; saveButton.textContent = '保存到 PocketBase'; } } async function openCollector() { const issueId = getIssueIdFromUrl(); if (!issueId) { toast('当前页面没有检测到 issueId,请在工单详情页打开。'); return; } try { showLoading(); state.token = getTeacherToken(); if (!state.token) { throw new Error('没有教师端 Token,请先登录教师端或手动粘贴 Token。'); } const issue = await teacherGet(`/apis/teacher/issue/${issueId}`); state.issue = issue; if (!issue.room_id) { throw new Error('issue 接口没有返回 room_id。'); } const [chatData, profile] = await Promise.all([ teacherGet(`/apis/teacher/chat-message?room_id=${issue.room_id}&page_size=${CONFIG.CHAT_PAGE_SIZE}&direction=backward`), teacherGet('/apis/teacher/profile').catch(() => null) ]); state.profile = profile; state.messages = Array.isArray(chatData) ? chatData.slice().sort((a, b) => (a.client_time || 0) - (b.client_time || 0)) : []; 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('issue_problems', `issue="${state.existingOrder.id}"`); state.existingProblem = problems[0] || null; } else { state.existingProblem = null; } } catch (err) { state.existingOrder = null; state.existingProblem = null; console.warn('读取 PocketBase 已有记录失败,不影响继续采集:', err); } renderPanel(); } catch (err) { console.error(err); showError(err); } } function createButton() { if (qs('#pbqc-open-btn')) return; const btn = document.createElement('button'); btn.id = 'pbqc-open-btn'; btn.className = 'pbqc-btn'; btn.textContent = '📚 保存到题库'; btn.addEventListener('click', openCollector); document.body.appendChild(btn); } createButton(); let lastUrl = location.href; setInterval(() => { if (location.href !== lastUrl) { lastUrl = location.href; createButton(); } }, 1000); })();