// ==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);
})();