// ==UserScript== // @name 学管答疑 - 待回复工单列表 v3.3 // @namespace https://chath5.kaoshids.com // @version 3.3.0 // @description 精准分类:未快捷回复/已快捷未解答/学生追问,自动获取Token // @match https://chath5.kaoshids.com/* // @match https://chatteacher.kaoshids.com/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_notification // @connect chatteacher.kaoshids.com // @connect * // ==/UserScript== (function () { 'use strict'; console.log('[答疑助手] v3.3 启动'); /* ═══════════════════ 配置 ═══════════════════ */ const CFG = { api: 'https://chatteacher.kaoshids.com', pageSize: 50, chatSize: 100, delay: 400, timeout: 30000, }; const TK = 'xchat_token_v33'; const TPL = 'xchat_tpl_v33'; /* ═══════════════════ 状态 ═══════════════════ */ let TOKEN = ''; let RUNNING = false; let ALL_RESULTS = []; // 所有需要处理的工单 let TEMPLATES = []; let CURRENT_FILTER = 'all'; // 当前筛选 /* ═══════════════════ 工具 ═══════════════════ */ const log = (...a) => console.log('[答疑助手]', ...a); const slp = ms => new Promise(r => setTimeout(r, ms)); const pad = n => String(n).padStart(2, '0'); function fmtTime(sec) { if (!sec) return '—'; const d = new Date(sec * 1000); return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function msgTime(m) { if (!m) return 0; const t = m.client_time; if (typeof t === 'number') return t > 1e12 ? Math.floor(t/1000) : t; for (const k of ['create_time','send_time']) { if (m[k]) { const p = Date.parse(m[k]); if (!isNaN(p)) return Math.floor(p/1000); } } return 0; } /* ═══════════════════ Token ═══════════════════ */ function normTk(s) { s = String(s||'').trim().replace(/^["']|["']$/g,''); if (!s) return ''; if (/^Bearer\s+eyJ[\w-]+\.[\w-]+\.[\w-]+/i.test(s)) return s; if (/^eyJ[\w-]+\.[\w-]+\.[\w-]+/.test(s)) return 'Bearer '+s; return ''; } function maskTk(t) { if (!t) return '未获取'; const j = t.replace(/^Bearer\s+/i,''); return j.length > 20 ? 'Bearer '+j.slice(0,10)+'…'+j.slice(-6) : 'Bearer '+j; } function setTk(t, src) { TOKEN = t; try { GM_setValue(TK, t); } catch(e) {} log('Token['+src+']', maskTk(t)); refreshTkUI(); } function refreshTkUI() { const el = document.getElementById('xTkInfo'); if (!el) return; el.textContent = TOKEN ? '✅ '+maskTk(TOKEN) : '❌ 未获取,请设置Token'; el.style.color = TOKEN ? '#529b2e' : '#f56c6c'; } function scanTk() { try { const s = normTk(GM_getValue(TK,'')); if (s) { TOKEN = s; refreshTkUI(); return true; } } catch(e) {} for (const store of [localStorage, sessionStorage]) { try { for (let i = 0; i < store.length; i++) { const k = store.key(i); const v = store.getItem(k) || ''; if (/token|auth|bearer/i.test(k)) { let n = normTk(v); if (n) { setTk(n, 'storage:'+k); return true; } try { const o = JSON.parse(v); n = normTk(o?.token||o?.access_token||o?.accessToken||''); if (n) { setTk(n, 'storage:'+k+'[json]'); return true; } } catch(_){} } const m = v.match(/eyJ[\w-]+\.[\w-]+\.[\w-]+/); if (m) { const n = normTk(m[0]); if (n) { setTk(n, 'scan:'+k); return true; } } } } catch(_){} } return false; } function installTkInterceptor() { const _sh = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function(n, v) { try { if (String(n).toLowerCase()==='authorization') { const t = normTk(v); if (t && t!==TOKEN) setTk(t, 'xhr'); } } catch(_){} return _sh.apply(this, arguments); }; const _f = window.fetch; if (_f) window.fetch = function(...a) { try { const h = a[1]?.headers || a[0]?.headers; let v = ''; if (h instanceof Headers) v = h.get('Authorization')||''; else if (h) v = h['Authorization']||h['authorization']||''; if (v) { const t = normTk(v); if (t && t!==TOKEN) setTk(t, 'fetch'); } } catch(_){} return _f.apply(this, a); }; } function showTkGuide() { let ov = document.getElementById('xGuideOv'); if (ov) ov.remove(); ov = document.createElement('div'); ov.id = 'xGuideOv'; ov.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483647;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;'; ov.innerHTML = `

🔑 获取 Token

方法1:自动获取(推荐)
刷新页面(F5),脚本自动从页面请求中捕获Token。
标题栏显示 ✅ 即成功。
方法2:F12手动复制
① F12 → Network 面板
② 点击任意工单
③ 找 issue 请求 → Request Headers
④ 复制 Authorization: Bearer eyJ...
方法3:控制台一键复制
F12 → Console,粘贴执行:
`; document.body.appendChild(ov); if (TOKEN) document.getElementById('xTkInput').value = TOKEN; ov.addEventListener('click', e => { if (e.target===ov) ov.remove(); }); document.getElementById('xTkSaveBtn').addEventListener('click', () => { const n = normTk(document.getElementById('xTkInput').value); if (n) { setTk(n, 'manual'); ov.remove(); } else alert('格式不正确,请粘贴以 eyJ 开头的 JWT'); }); } /* ═══════════════════ HTTP ═══════════════════ */ function httpGet(path, params={}) { if (!TOKEN) throw new Error('Token未获取'); const u = new URL(path, CFG.api); for (const [k,v] of Object.entries(params)) if (v!=null) u.searchParams.set(k, String(v)); return new Promise((ok, fail) => { GM_xmlhttpRequest({ method: 'GET', url: u.toString(), timeout: CFG.timeout, headers: { 'Authorization': TOKEN }, onload(r) { if (r.status===401) { TOKEN=''; try{GM_setValue(TK,'');}catch(_){} refreshTkUI(); fail(new Error('Token过期')); return; } if (r.status<200||r.status>=300) { fail(new Error('HTTP '+r.status)); return; } try { const j = JSON.parse(r.responseText); if (j.errcode!==1) { fail(new Error(j.errmsg||'接口错误')); return; } ok(j.data); } catch(e) { fail(new Error('解析失败')); } }, onerror:()=>fail(new Error('网络错误')), ontimeout:()=>fail(new Error('超时')), }); }); } /* ═══════════════════ 自动回复模板 ═══════════════════ */ function isAutoReply(body) { if (!body || !TEMPLATES.length) return false; const b = String(body).trim(); for (const t of TEMPLATES) { if (!t) continue; const tt = t.trim(); if (b === tt) return true; if (b.includes(tt) || tt.includes(b)) return true; if (b.length > 20 && tt.length > 20 && tt.includes(b.slice(0, 40))) return true; } return false; } async function loadTpl() { try { const c = GM_getValue(TPL,''); if (c) { TEMPLATES = JSON.parse(c); if (TEMPLATES.length) return; } } catch(_){} try { const d = await httpGet('/apis/teacher/setting/auto-reply'); const arr = Array.isArray(d) ? d : (d?.data||[]); TEMPLATES = arr.map(i => String(i.reply_content||'').trim()).filter(Boolean); try { GM_setValue(TPL, JSON.stringify(TEMPLATES)); } catch(_){} log('模板:'+TEMPLATES.length+'条'); } catch(e) { log('模板失败:'+e.message); TEMPLATES=[]; } } /* ═══════════════════ 消息分类 ═══════════════════ * * 已确认的字段规律(基于真实API数据): * * 学生: sender_type=2, student_sender有值, teacher_sender=null, is_self=0 * AI: msg_type='ai', sender_id=1, teacher_sender.nickname='AI', is_self=0 * 老师: sender_type=1, teacher_sender有值, sender_id≠1, is_self=1 * 自动回复: 同老师, 但msg_body匹配模板内容(API无标记字段) * * ═══════════════════════════════════════════════ */ function classify(m) { if (!m) return 'unknown'; // 学生 if (m.sender_type === 2) return 'student'; if (m.student_sender && typeof m.student_sender === 'object') return 'student'; // AI(必须在老师之前!AI也有teacher_sender) if (m.msg_type === 'ai') return 'ai'; if (m.sender_id === 1 && m.teacher_sender?.nickname === 'AI') return 'ai'; // 老师 if (m.sender_type === 1 && m.teacher_sender && m.sender_id !== 1) { if (m.msg_type === 'text' && isAutoReply(m.msg_body)) return 'auto'; return 'teacher'; } return 'unknown'; } /* ═══════════════════ 聊天分析(核心逻辑)═══════════════════ * * 工单完整生命周期: * 学生提问 → 系统分配 → AI回复 → [学生补充] → 老师快捷回复 → 老师真人解答 → [学生追问→老师再答] * * 三种需要处理的情况: * 🔴 red: 老师从未发过任何消息(未发快捷回复) * 🟡 yellow: 老师只发了自动回复/快捷回复,没有真人解答 * 🟠 orange: 老师已解答过,但学生又追问了 * * ═══════════════════════════════════════════════════════════ */ function analyze(msgs) { const R = { category: 'ok', // 'red' | 'yellow' | 'orange' | 'ok' label: '', stuT: 0, // 学生最新消息时间 teaT: 0, // 老师真人回复最新时间(不含自动回复) autoT: 0, // 自动回复最新时间 aiT: 0, // AI最新回复时间 c: { s:0, t:0, a:0, ai:0 } // 消息数: student, teacher_real, auto, ai }; if (!msgs?.length) { R.category = 'red'; R.label = '🔴 无消息记录'; return R; } const sorted = [...msgs].sort((a,b) => msgTime(b) - msgTime(a)); for (const m of sorted) { const r = classify(m); const t = msgTime(m); switch (r) { case 'student': R.c.s++; if(t>R.stuT) R.stuT=t; break; case 'teacher': R.c.t++; if(t>R.teaT) R.teaT=t; break; case 'auto': R.c.a++; if(t>R.autoT) R.autoT=t; break; case 'ai': R.c.ai++; if(t>R.aiT) R.aiT=t; break; } } // ── 分类判断 ── const hasTeacherMsg = (R.c.t + R.c.a) > 0; // 老师是否发过消息(含自动回复) const hasRealReply = R.c.t > 0; // 老师是否有真人回复(不含自动回复) const hasAutoOnly = R.c.a > 0 && R.c.t === 0; // 只有自动回复没有真人回复 if (!hasTeacherMsg) { // 🔴 老师从未发过任何消息 R.category = 'red'; if (R.c.ai > 0) { R.label = '🔴 仅AI回复,老师未接手'; } else { R.label = '🔴 老师未回复'; } } else if (hasAutoOnly) { // 🟡 老师只发了自动回复/快捷回复,没有真人解答 R.category = 'yellow'; R.label = '🟡 已快捷回复,未解答'; } else if (hasRealReply && R.stuT > R.teaT) { // 🟠 老师已真人回复过,但学生又追问了 R.category = 'orange'; R.label = '🟠 学生追问待回复'; } else if (hasRealReply && R.stuT <= R.teaT) { // ✅ 老师已回复且是最新的 R.category = 'ok'; R.label = '✅ 已回复'; } else { R.category = 'ok'; R.label = '✅ 已处理'; } return R; } /* ═══════════════════ 主抓取流程 ═══════════════════ */ async function doFetch() { if (RUNNING) return; if (!TOKEN) { scanTk(); if (!TOKEN) { setUI('❌ 请先设置Token','err'); return; } } RUNNING = true; ALL_RESULTS = []; renderList([]); setUI('🔄 加载模板...','run'); try { await loadTpl(); setUI('🔄 获取工单列表...','run'); const list = await httpGet('/apis/teacher/issue/in-progress', { page_size: CFG.pageSize, latest_id: 0 }); if (!Array.isArray(list) || !list.length) { setUI('✅ 没有进行中的工单','ok'); hideProg(); updateCounts([]); renderList([]); return; } const total = list.length; const res = []; for (let i = 0; i < total; i++) { const it = list[i]; const id = it.issue_id; showProg(i, total, `${it.student_name||id} (${i+1}/${total})`); try { const dt = await httpGet('/apis/teacher/issue/'+id); await slp(CFG.delay); const rid = dt.room_id || it.room_id; let msgs = []; if (rid) { const cd = await httpGet('/apis/teacher/chat-message', { room_id: rid, page_size: CFG.chatSize, direction: 'backward' }); msgs = Array.isArray(cd) ? cd : []; await slp(CFG.delay); } const a = analyze(msgs); log(`[${id}] ${it.student_name} | ${a.label} | S${a.c.s} T${a.c.t} A${a.c.a} AI${a.c.ai}`); // 收集所有非 ok 的工单 if (a.category !== 'ok') { res.push({ id, sn: dt.issue_sn||it.issue_sn||'', title: (dt.issue_title||it.issue_title||'').replace(/\s+/g,' ').trim(), name: dt.student_name||it.student_name||'未知', category: a.category, label: a.label, stuT: a.stuT, teaT: a.teaT, autoT: a.autoT, c: a.c, unread: it.unread_messages_count||0 }); } } catch(e) { log(`[${id}] 失败:`, e.message); } } // 排序:红 → 黄 → 橙,同级别按学生最新消息时间倒序 const order = { red: 0, yellow: 1, orange: 2 }; res.sort((a,b) => { const d = (order[a.category]??9) - (order[b.category]??9); if (d !== 0) return d; return b.stuT - a.stuT; }); ALL_RESULTS = res; updateCounts(res); filterAndRender(); showProg(total, total, `完成`); const rc = res.filter(i=>i.category==='red').length; const yc = res.filter(i=>i.category==='yellow').length; const oc = res.filter(i=>i.category==='orange').length; setUI( res.length ? `${total}条中:🔴${rc} 🟡${yc} 🟠${oc} 需处理` : `✅ ${total}条均已回复`, res.length ? 'warn' : 'ok' ); } catch(e) { log('失败:', e); setUI('❌ '+e.message,'err'); hideProg(); } finally { RUNNING = false; } } /* ═══════════════════ UI ═══════════════════ */ let _status, _progWrap, _progBar, _progTxt, _list; let _btnAll, _btnRed, _btnYellow, _btnOrange; let _cntAll, _cntRed, _cntYellow, _cntOrange; function setUI(txt, type) { if (!_status) return; _status.textContent = txt; _status.style.color = {ok:'#529b2e',run:'#409eff',warn:'#e6a23c',err:'#f56c6c'}[type]||'#606266'; } function showProg(cur, tot, label) { if (!_progWrap) return; _progWrap.style.display = 'block'; _progBar.style.width = tot>0 ? Math.round(cur/tot*100)+'%' : '0%'; _progTxt.textContent = label || `${cur}/${tot}`; } function hideProg() { if (_progWrap) _progWrap.style.display='none'; } function updateCounts(list) { const rc = list.filter(i=>i.category==='red').length; const yc = list.filter(i=>i.category==='yellow').length; const oc = list.filter(i=>i.category==='orange').length; const all = list.length; if (_cntAll) _cntAll.textContent = all; if (_cntRed) _cntRed.textContent = rc; if (_cntYellow) _cntYellow.textContent = yc; if (_cntOrange) _cntOrange.textContent = oc; } function filterAndRender() { let filtered; if (CURRENT_FILTER === 'all') { filtered = ALL_RESULTS; } else { filtered = ALL_RESULTS.filter(i => i.category === CURRENT_FILTER); } renderList(filtered); // 更新按钮样式 [_btnAll, _btnRed, _btnYellow, _btnOrange].forEach(b => { if (b) b.style.opacity = '0.5'; }); const active = { all: _btnAll, red: _btnRed, yellow: _btnYellow, orange: _btnOrange }[CURRENT_FILTER]; if (active) active.style.opacity = '1'; } function renderList(list) { if (!_list) return; _list.innerHTML = ''; if (!list || !list.length) { _list.innerHTML = '
📭 当前筛选下无工单
'; return; } for (const it of list) { const d = document.createElement('div'); d.style.cssText = 'padding:11px 16px;border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background .12s;'; d.onmouseenter = () => d.style.background='#f5f7fa'; d.onmouseleave = () => d.style.background=''; // 颜色 const labelBg = { red:'#fef0f0', yellow:'#fdf6ec', orange:'#fdf6ec' }[it.category] || '#f0f9eb'; const labelColor = { red:'#f56c6c', yellow:'#e6a23c', orange:'#e6a23c' }[it.category] || '#529b2e'; d.innerHTML = `
${it.name} ${it.label}
${it.title||it.sn}
学生 ${fmtTime(it.stuT)} · 老师 ${it.teaT ? fmtTime(it.teaT) : (it.autoT ? '快捷'+fmtTime(it.autoT) : '—')} 学${it.c.s} 师${it.c.t} 快捷${it.c.a} AI${it.c.ai}
`; d.onclick = () => { window.open('https://chath5.kaoshids.com/#/pages/chat/chat?issueId='+it.id, '_blank'); }; _list.appendChild(d); } } /* ═══════════════════ 创建面板 ═══════════════════ */ function createPanel() { const old = document.getElementById('xPanelV33'); if (old) old.remove(); const p = document.createElement('div'); p.id = 'xPanelV33'; p.style.cssText = ` position:fixed !important;top:50px !important;right:16px !important;z-index:2147483646 !important; width:420px;max-height:86vh; background:#ffffff !important;border:1px solid #dcdfe6;border-radius:14px; box-shadow:0 6px 30px rgba(0,0,0,.18);font-size:13px; font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif; display:flex !important;flex-direction:column;overflow:hidden; visibility:visible !important;opacity:1 !important;pointer-events:auto !important; `; p.innerHTML = `
📋 待处理工单
检查Token...
就绪
点击「开始抓取」
`; document.body.appendChild(p); log('面板已创建'); // 缓存引用 _status = document.getElementById('xStatus'); _progWrap = document.getElementById('xProg'); _progBar = document.getElementById('xProgBar'); _progTxt = document.getElementById('xProgTxt'); _list = document.getElementById('xList'); _btnAll = document.getElementById('xBtnAll'); _btnRed = document.getElementById('xBtnRed'); _btnYellow = document.getElementById('xBtnYellow'); _btnOrange = document.getElementById('xBtnOrange'); _cntAll = document.getElementById('xCntAll'); _cntRed = document.getElementById('xCntRed'); _cntYellow = document.getElementById('xCntYellow'); _cntOrange = document.getElementById('xCntOrange'); // 折叠 let min = false; document.getElementById('xMin').onclick = () => { min = !min; document.getElementById('xCtrl').style.display = min ? 'none' : ''; _list.style.display = min ? 'none' : ''; document.getElementById('xMin').textContent = min ? '+' : '−'; p.style.width = min ? '180px' : '420px'; }; // 拖动 let drag=false, ox, oy; document.getElementById('xTB').addEventListener('mousedown', e => { if (e.target.tagName==='BUTTON') return; drag=true; const r = p.getBoundingClientRect(); ox = e.clientX - r.left; oy = e.clientY - r.top; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!drag) return; p.style.left = (e.clientX-ox)+'px'; p.style.top = (e.clientY-oy)+'px'; p.style.right = 'auto'; }); document.addEventListener('mouseup', () => drag = false); // 按钮绑定 document.getElementById('xTkBtn').onclick = showTkGuide; document.getElementById('xFetchBtn').onclick = doFetch; document.getElementById('xTplBtn').onclick = async () => { try { GM_setValue(TPL,''); } catch(_){} setUI('刷新模板...','run'); await loadTpl(); setUI('✅ 模板: '+TEMPLATES.length+'条','ok'); }; // 筛选按钮 _btnAll.onclick = () => { CURRENT_FILTER = 'all'; filterAndRender(); }; _btnRed.onclick = () => { CURRENT_FILTER = 'red'; filterAndRender(); }; _btnYellow.onclick = () => { CURRENT_FILTER = 'yellow'; filterAndRender(); }; _btnOrange.onclick = () => { CURRENT_FILTER = 'orange'; filterAndRender(); }; refreshTkUI(); } /* ═══════════════════ 启动 ═══════════════════ */ function boot() { log('启动...'); installTkInterceptor(); createPanel(); scanTk(); setTimeout(()=>{ if(!TOKEN) scanTk(); }, 2000); setTimeout(()=>{ if(!TOKEN) scanTk(); }, 6000); } if (document.body) boot(); else { const ob = new MutationObserver(() => { if (document.body) { ob.disconnect(); boot(); } }); ob.observe(document.documentElement, { childList:true, subtree:true }); } GM_registerMenuCommand('🔑 设置Token', showTkGuide); GM_registerMenuCommand('🔍 抓取工单', doFetch); })();