// ==UserScript== // @name 学管答疑工单助手(合并版) // @namespace https://chath5.kaoshids.com // @version 5.0.0 // @description 待回复工单分类 + AI 回复一键折叠,统一悬浮面板 // @match https://chath5.kaoshids.com/* // @match https://chatteacher.kaoshids.com/* // @run-at document-start // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect chatteacher.kaoshids.com // @connect * // ==/UserScript== (function () { 'use strict'; const APP = 'xta'; const HOST = location.hostname; const IS_CHAT_PAGE = HOST === 'chath5.kaoshids.com'; const CFG = { api: 'https://chatteacher.kaoshids.com', pageSize: 100, chatSize: 100, requestGap: 60, concurrency: 6, maxPages: 20, timeout: 30000, }; const STORE = { token: 'xta_token_v1', template: 'xta_template_v1', panelPos: 'xta_panel_pos_v1', panelMin: 'xta_panel_min_v1', panelHidden: 'xta_panel_hidden_v1', activeTab: 'xta_active_tab_v1', aiAuto: 'xta_ai_auto_collapse_v1', }; const LEGACY = { token: 'xchat_token_v33', template: 'xchat_tpl_v33', aiAuto: 'defaultCollapse', aiPanelHidden: 'panelHidden', aiPanelMin: 'panelCollapsed', aiLeft: 'panelLeft', aiTop: 'panelTop', }; let TOKEN = ''; let RUNNING = false; let ALL_RESULTS = []; let TEMPLATES = []; let CURRENT_FILTER = 'all'; let CURRENT_TAB = gmGet(STORE.activeTab, 'tickets'); let AI_AUTO_COLLAPSE = gmGet(STORE.aiAuto, gmGet(LEGACY.aiAuto, true)); let PANEL_MINIMIZED = gmGet(STORE.panelMin, false); let PANEL_HIDDEN = gmGet(STORE.panelHidden, gmGet(LEGACY.aiPanelHidden, false)); let mutationObserver = null; let hasFetched = false; const ui = {}; const log = (...args) => console.log('[答疑工单助手]', ...args); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const pad = n => String(n).padStart(2, '0'); injectStyles(); installTokenInterceptor(); migrateLegacyValues(); if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('打开/隐藏助手面板', togglePanelVisible); GM_registerMenuCommand('设置 Token', showTokenGuide); GM_registerMenuCommand('抓取待处理工单', doFetch); GM_registerMenuCommand('折叠全部 AI 回复', () => toggleAllAI(true)); GM_registerMenuCommand('展开全部 AI 回复', () => toggleAllAI(false)); } ready(boot); function gmGet(key, fallback) { try { const value = GM_getValue(key); return value === undefined ? fallback : value; } catch (_) { return fallback; } } function gmSet(key, value) { try { GM_setValue(key, value); } catch (_) {} } function migrateLegacyValues() { const storedToken = normToken(gmGet(STORE.token, '')); const oldToken = normToken(gmGet(LEGACY.token, '')); TOKEN = storedToken || oldToken; if (TOKEN && !storedToken) gmSet(STORE.token, TOKEN); const oldTpl = gmGet(LEGACY.template, ''); if (!gmGet(STORE.template, '') && oldTpl) gmSet(STORE.template, oldTpl); const savedPos = gmGet(STORE.panelPos, null); const oldLeft = gmGet(LEGACY.aiLeft, null); const oldTop = gmGet(LEGACY.aiTop, null); if (!savedPos && Number.isFinite(Number(oldLeft)) && Number.isFinite(Number(oldTop))) { gmSet(STORE.panelPos, { left: Number(oldLeft), top: Number(oldTop) }); } } function ready(fn) { if (document.body) { fn(); return; } const ob = new MutationObserver(() => { if (document.body) { ob.disconnect(); fn(); } }); ob.observe(document.documentElement, { childList: true, subtree: true }); } function boot() { log('启动'); createPanel(); createFab(); applyPanelVisibility(); scanToken(); setupAIObserver(); setupVisibilityRefresh(); setTimeout(() => { if (!TOKEN) scanToken(); }, 2000); setTimeout(() => { if (!TOKEN) scanToken(); }, 6000); if (IS_CHAT_PAGE && AI_AUTO_COLLAPSE) { setTimeout(() => toggleAllAI(true, { quiet: true }), 800); } } function injectStyles() { GM_addStyle(` #xtaPanel, #xtaPanel *, #xtaFab, #xtaGuideOverlay, #xtaGuideOverlay * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } #xtaPanel { position: fixed !important; z-index: 2147483646 !important; width: min(440px, calc(100vw - 24px)); max-height: min(86vh, 760px); color: #1f2937; background: #fff; border: 1px solid rgba(148, 163, 184, .42); border-radius: 10px; box-shadow: 0 18px 44px rgba(15, 23, 42, .18); overflow: hidden; display: flex; flex-direction: column; opacity: 1; transform: translateZ(0); } #xtaPanel.is-hidden { display: none !important; } #xtaPanel.is-minimized { width: min(288px, calc(100vw - 24px)); } #xtaPanel.is-minimized .xta-body { display: none; } .xta-header { min-height: 48px; padding: 10px 12px 10px 14px; color: #fff; background: linear-gradient(135deg, #2563eb 0%, #0f766e 100%); display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: move; user-select: none; } .xta-title { min-width: 0; display: flex; flex-direction: column; gap: 2px; } .xta-title strong { font-size: 15px; line-height: 1.2; letter-spacing: 0; } .xta-title span { font-size: 11px; line-height: 1.2; opacity: .84; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xta-window-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .xta-icon-btn { width: 28px; height: 28px; border: 0; border-radius: 8px; color: inherit; background: rgba(255, 255, 255, .18); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; } .xta-icon-btn:hover { background: rgba(255, 255, 255, .28); } .xta-body { min-height: 0; display: flex; flex-direction: column; background: #f8fafc; } .xta-tabs { padding: 8px; background: #fff; border-bottom: 1px solid #e5e7eb; display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } .xta-tab { height: 32px; border: 1px solid transparent; border-radius: 8px; background: #f1f5f9; color: #475569; font-size: 13px; font-weight: 700; cursor: pointer; } .xta-tab.is-active { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; } .xta-panel-section { display: none; min-height: 0; flex-direction: column; } #xtaPanel[data-tab="tickets"] .xta-section-tickets, #xtaPanel[data-tab="ai"] .xta-section-ai { display: flex; } .xta-toolbar { padding: 12px; background: #fff; border-bottom: 1px solid #e5e7eb; } .xta-token { min-height: 22px; padding: 7px 9px; margin-bottom: 10px; border-radius: 8px; background: #fef2f2; color: #b91c1c; font-size: 11px; line-height: 1.45; word-break: break-all; } .xta-token[data-ready="1"] { background: #ecfdf5; color: #047857; } .xta-actions { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; margin-bottom: 10px; } .xta-btn { min-height: 34px; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; color: #374151; font-size: 13px; font-weight: 700; cursor: pointer; padding: 0 12px; transition: transform .12s ease, box-shadow .12s ease, background .12s ease; } .xta-btn:hover { background: #f8fafc; box-shadow: 0 4px 12px rgba(15, 23, 42, .08); } .xta-btn:active { transform: translateY(1px); } .xta-btn:disabled { opacity: .62; cursor: not-allowed; box-shadow: none; transform: none; } .xta-btn-primary { background: #2563eb; border-color: #2563eb; color: #fff; } .xta-btn-primary:hover { background: #1d4ed8; } .xta-btn-warm { background: #f59e0b; border-color: #f59e0b; color: #fff; } .xta-btn-warm:hover { background: #d97706; } .xta-btn-muted { background: #64748b; border-color: #64748b; color: #fff; } .xta-btn-muted:hover { background: #475569; } .xta-status { min-height: 18px; margin-bottom: 9px; color: #64748b; font-size: 12px; line-height: 1.5; } .xta-status[data-state="ok"] { color: #047857; } .xta-status[data-state="run"] { color: #1d4ed8; } .xta-status[data-state="warn"] { color: #b45309; } .xta-status[data-state="err"] { color: #dc2626; } .xta-progress { display: none; margin-bottom: 10px; } .xta-progress.is-visible { display: block; } .xta-progress-text { margin-bottom: 4px; color: #475569; font-size: 11px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .xta-progress-track { height: 7px; overflow: hidden; border-radius: 999px; background: #e2e8f0; } .xta-progress-bar { width: 0%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #2563eb, #14b8a6); transition: width .2s ease; } .xta-filters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } .xta-filter { min-width: 0; height: 34px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: #475569; cursor: pointer; font-size: 12px; font-weight: 700; } .xta-filter span { margin-left: 4px; font-weight: 800; } .xta-filter.is-active { border-color: #2563eb; color: #1d4ed8; background: #eff6ff; } .xta-list { min-height: 220px; max-height: calc(86vh - 258px); overflow-y: auto; background: #fff; } .xta-empty { padding: 44px 24px; text-align: center; color: #94a3b8; font-size: 13px; } .xta-ticket { width: 100%; padding: 12px 14px; border: 0; border-bottom: 1px solid #eef2f7; background: #fff; text-align: left; cursor: pointer; display: block; } .xta-ticket:hover { background: #f8fafc; } .xta-ticket-main { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; } .xta-ticket-name { min-width: 0; color: #111827; font-size: 14px; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .xta-badge { flex-shrink: 0; display: inline-flex; align-items: center; gap: 5px; max-width: 52%; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xta-badge::before { content: ""; width: 7px; height: 7px; border-radius: 999px; flex-shrink: 0; } .xta-ticket-red .xta-badge { color: #b91c1c; background: #fef2f2; } .xta-ticket-red .xta-badge::before { background: #ef4444; } .xta-ticket-yellow .xta-badge { color: #a16207; background: #fefce8; } .xta-ticket-yellow .xta-badge::before { background: #eab308; } .xta-ticket-orange .xta-badge { color: #c2410c; background: #fff7ed; } .xta-ticket-orange .xta-badge::before { background: #f97316; } .xta-ticket-title { margin-bottom: 5px; color: #475569; font-size: 12px; line-height: 1.45; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .xta-ticket-meta { display: flex; justify-content: space-between; gap: 10px; color: #94a3b8; font-size: 11px; line-height: 1.45; } .xta-ticket-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .xta-ai-card { padding: 14px 12px 16px; background: #fff; border-bottom: 1px solid #e5e7eb; } .xta-ai-summary { margin-bottom: 12px; padding: 10px; border-radius: 8px; color: #475569; background: #f8fafc; font-size: 12px; line-height: 1.6; } .xta-ai-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; } .xta-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: #374151; font-size: 13px; font-weight: 700; } .xta-switch { position: relative; width: 42px; height: 24px; flex-shrink: 0; } .xta-switch input { position: absolute; opacity: 0; inset: 0; } .xta-switch span { position: absolute; inset: 0; border-radius: 999px; background: #cbd5e1; cursor: pointer; transition: background .16s ease; } .xta-switch span::after { content: ""; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; border-radius: 999px; background: #fff; box-shadow: 0 1px 4px rgba(15, 23, 42, .2); transition: transform .16s ease; } .xta-switch input:checked + span { background: #2563eb; } .xta-switch input:checked + span::after { transform: translateX(18px); } .xta-ai-help { padding: 12px; color: #64748b; font-size: 12px; line-height: 1.7; } #xtaFab { position: fixed !important; right: 14px; top: 50%; z-index: 2147483645 !important; width: 44px; height: 44px; border: 0; border-radius: 999px; color: #fff; background: linear-gradient(135deg, #2563eb, #0f766e); box-shadow: 0 12px 28px rgba(37, 99, 235, .28); cursor: pointer; font-size: 15px; font-weight: 900; transform: translateY(-50%); display: none; } #xtaFab.is-visible { display: block; } #xtaFab:hover { box-shadow: 0 16px 36px rgba(37, 99, 235, .34); } #xtaGuideOverlay { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; padding: 18px; background: rgba(15, 23, 42, .46); } .xta-guide { width: min(540px, 100%); max-height: 86vh; overflow-y: auto; border-radius: 10px; background: #fff; color: #1f2937; box-shadow: 0 24px 70px rgba(15, 23, 42, .28); } .xta-guide-header { padding: 16px 18px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; } .xta-guide-header h3 { margin: 0; font-size: 16px; line-height: 1.3; } .xta-guide-body { padding: 16px 18px; font-size: 13px; line-height: 1.75; } .xta-guide-step { margin-bottom: 10px; padding: 11px 12px; border-radius: 8px; background: #f8fafc; color: #374151; } .xta-guide-step b { color: #111827; } .xta-guide code { padding: 1px 4px; border-radius: 4px; background: #e5e7eb; } .xta-guide textarea { width: 100%; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; resize: vertical; font: 12px/1.5 Consolas, "Courier New", monospace; } #xtaTokenCode { height: 62px; margin: 4px 0 12px; color: #d4d4d4; background: #111827; border-color: #111827; cursor: pointer; } #xtaTokenInput { min-height: 68px; } .xta-guide-footer { padding: 14px 18px 18px; display: flex; justify-content: flex-end; gap: 8px; } .xta-ai-collapsed { display: none !important; } @media (max-width: 520px) { #xtaPanel { width: calc(100vw - 18px); max-height: 82vh; } .xta-filters { grid-template-columns: repeat(2, 1fr); } .xta-ticket-meta { flex-direction: column; gap: 2px; } } `); } function createPanel() { if (document.getElementById('xtaPanel')) return; const panel = document.createElement('div'); panel.id = 'xtaPanel'; panel.dataset.tab = CURRENT_TAB; panel.innerHTML = `
答疑工单助手 待处理工单分类 · AI 回复折叠
检查 Token...
就绪
点击「开始抓取」获取待处理工单
正在检查当前页面...
折叠功能仅在聊天页生效,会自动识别页面中的 AI 回复内容。隐藏后可用侧边圆形按钮重新打开面板。
`; document.body.appendChild(panel); cacheUi(); restorePanelPosition(panel); wirePanelEvents(panel); setActiveTab(CURRENT_TAB, { silent: true }); setPanelMinimized(PANEL_MINIMIZED); refreshTokenUI(); refreshAIUI(); } function cacheUi() { ui.panel = document.getElementById('xtaPanel'); ui.header = document.getElementById('xtaHeader'); ui.minBtn = document.getElementById('xtaMinBtn'); ui.hideBtn = document.getElementById('xtaHideBtn'); ui.tokenInfo = document.getElementById('xtaTokenInfo'); ui.tokenBtn = document.getElementById('xtaTokenBtn'); ui.fetchBtn = document.getElementById('xtaFetchBtn'); ui.tplBtn = document.getElementById('xtaTplBtn'); ui.status = document.getElementById('xtaStatus'); ui.progress = document.getElementById('xtaProgress'); ui.progressText = document.getElementById('xtaProgressText'); ui.progressBar = document.getElementById('xtaProgressBar'); ui.list = document.getElementById('xtaList'); ui.aiSummary = document.getElementById('xtaAiSummary'); ui.collapseAiBtn = document.getElementById('xtaCollapseAiBtn'); ui.expandAiBtn = document.getElementById('xtaExpandAiBtn'); ui.autoAiSwitch = document.getElementById('xtaAutoAiSwitch'); ui.counts = { all: document.getElementById('xtaCntAll'), red: document.getElementById('xtaCntRed'), yellow: document.getElementById('xtaCntYellow'), orange: document.getElementById('xtaCntOrange'), }; } function wirePanelEvents(panel) { panel.querySelectorAll('.xta-tab').forEach(btn => { btn.addEventListener('click', () => setActiveTab(btn.dataset.tab)); }); panel.querySelectorAll('.xta-filter').forEach(btn => { btn.addEventListener('click', () => { CURRENT_FILTER = btn.dataset.filter; filterAndRender(); }); }); ui.minBtn.addEventListener('click', () => setPanelMinimized(!PANEL_MINIMIZED)); ui.hideBtn.addEventListener('click', () => { PANEL_HIDDEN = true; gmSet(STORE.panelHidden, true); applyPanelVisibility(); }); ui.tokenBtn.addEventListener('click', showTokenGuide); ui.fetchBtn.addEventListener('click', doFetch); ui.tplBtn.addEventListener('click', refreshTemplateCache); ui.collapseAiBtn.addEventListener('click', () => toggleAllAI(true)); ui.expandAiBtn.addEventListener('click', () => toggleAllAI(false)); ui.autoAiSwitch.checked = Boolean(AI_AUTO_COLLAPSE); ui.autoAiSwitch.addEventListener('change', () => { AI_AUTO_COLLAPSE = ui.autoAiSwitch.checked; gmSet(STORE.aiAuto, AI_AUTO_COLLAPSE); if (AI_AUTO_COLLAPSE) toggleAllAI(true); refreshAIUI(); }); makeDraggable(panel, ui.header); window.addEventListener('resize', () => keepPanelInViewport(panel)); } function restorePanelPosition(panel) { const pos = gmGet(STORE.panelPos, null); const left = Number(pos?.left); const top = Number(pos?.top); if (Number.isFinite(left) && Number.isFinite(top)) { panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; } else { panel.style.top = '52px'; panel.style.right = '16px'; } setTimeout(() => keepPanelInViewport(panel), 0); } function makeDraggable(panel, handle) { let dragging = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; handle.addEventListener('pointerdown', e => { if (e.target.closest('button')) return; const rect = panel.getBoundingClientRect(); dragging = true; startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; panel.style.left = `${rect.left}px`; panel.style.top = `${rect.top}px`; panel.style.right = 'auto'; handle.setPointerCapture?.(e.pointerId); e.preventDefault(); }); document.addEventListener('pointermove', e => { if (!dragging) return; const left = startLeft + e.clientX - startX; const top = startTop + e.clientY - startY; setPanelPosition(panel, left, top); }); document.addEventListener('pointerup', () => { if (!dragging) return; dragging = false; const rect = panel.getBoundingClientRect(); gmSet(STORE.panelPos, { left: Math.round(rect.left), top: Math.round(rect.top) }); }); } function setPanelPosition(panel, left, top) { const margin = 8; const rect = panel.getBoundingClientRect(); const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin); const maxTop = Math.max(margin, window.innerHeight - rect.height - margin); panel.style.left = `${Math.max(margin, Math.min(left, maxLeft))}px`; panel.style.top = `${Math.max(margin, Math.min(top, maxTop))}px`; panel.style.right = 'auto'; } function keepPanelInViewport(panel) { if (!panel || panel.classList.contains('is-hidden')) return; const rect = panel.getBoundingClientRect(); const left = Number.isFinite(rect.left) ? rect.left : window.innerWidth - rect.width - 16; const top = Number.isFinite(rect.top) ? rect.top : 52; setPanelPosition(panel, left, top); } function setPanelMinimized(minimized) { PANEL_MINIMIZED = Boolean(minimized); gmSet(STORE.panelMin, PANEL_MINIMIZED); ui.panel?.classList.toggle('is-minimized', PANEL_MINIMIZED); if (ui.minBtn) { ui.minBtn.textContent = PANEL_MINIMIZED ? '+' : '−'; ui.minBtn.title = PANEL_MINIMIZED ? '展开' : '最小化'; } if (ui.panel) keepPanelInViewport(ui.panel); } function createFab() { if (document.getElementById('xtaFab')) return; const fab = document.createElement('button'); fab.id = 'xtaFab'; fab.type = 'button'; fab.textContent = '答'; fab.title = '打开答疑工单助手'; fab.addEventListener('click', () => { PANEL_HIDDEN = false; gmSet(STORE.panelHidden, false); applyPanelVisibility(); }); document.body.appendChild(fab); } function applyPanelVisibility() { const panel = document.getElementById('xtaPanel'); const fab = document.getElementById('xtaFab'); panel?.classList.toggle('is-hidden', PANEL_HIDDEN); fab?.classList.toggle('is-visible', PANEL_HIDDEN); if (!PANEL_HIDDEN && panel) keepPanelInViewport(panel); } function togglePanelVisible() { PANEL_HIDDEN = !PANEL_HIDDEN; gmSet(STORE.panelHidden, PANEL_HIDDEN); applyPanelVisibility(); } function setActiveTab(tab, opts = {}) { CURRENT_TAB = tab === 'ai' ? 'ai' : 'tickets'; gmSet(STORE.activeTab, CURRENT_TAB); if (ui.panel) ui.panel.dataset.tab = CURRENT_TAB; document.querySelectorAll('#xtaPanel .xta-tab').forEach(btn => { btn.classList.toggle('is-active', btn.dataset.tab === CURRENT_TAB); }); if (CURRENT_TAB === 'ai') refreshAIUI(); if (!opts.silent && ui.panel) keepPanelInViewport(ui.panel); } 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 parsed = Date.parse(m[k]); if (!Number.isNaN(parsed)) return Math.floor(parsed / 1000); } } return 0; } function normToken(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 maskToken(t) { if (!t) return '未获取'; const jwt = t.replace(/^Bearer\s+/i, ''); return jwt.length > 20 ? `Bearer ${jwt.slice(0, 10)}...${jwt.slice(-6)}` : `Bearer ${jwt}`; } function setToken(token, source) { TOKEN = token; gmSet(STORE.token, token); gmSet(LEGACY.token, token); log(`Token[${source}]`, maskToken(token)); refreshTokenUI(); } function refreshTokenUI() { if (!ui.tokenInfo) return; ui.tokenInfo.textContent = TOKEN ? `Token 已就绪:${maskToken(TOKEN)}` : 'Token 未获取,请设置或刷新页面后自动捕获'; ui.tokenInfo.dataset.ready = TOKEN ? '1' : '0'; } function scanToken() { const saved = normToken(gmGet(STORE.token, '')) || normToken(gmGet(LEGACY.token, '')); if (saved) { TOKEN = saved; refreshTokenUI(); return true; } for (const store of [localStorage, sessionStorage]) { try { for (let i = 0; i < store.length; i++) { const key = store.key(i); const value = store.getItem(key) || ''; if (/token|auth|bearer/i.test(key)) { let token = normToken(value); if (token) { setToken(token, `storage:${key}`); return true; } try { const obj = JSON.parse(value); token = normToken(obj?.token || obj?.access_token || obj?.accessToken || ''); if (token) { setToken(token, `storage:${key}[json]`); return true; } } catch (_) {} } const match = value.match(/eyJ[\w-]+\.[\w-]+\.[\w-]+/); if (match) { const token = normToken(match[0]); if (token) { setToken(token, `scan:${key}`); return true; } } } } catch (_) {} } refreshTokenUI(); return false; } function installTokenInterceptor() { try { const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (name, value) { try { if (String(name).toLowerCase() === 'authorization') { const token = normToken(value); if (token && token !== TOKEN) setToken(token, 'xhr'); } } catch (_) {} return originalSetRequestHeader.apply(this, arguments); }; } catch (_) {} try { const originalFetch = window.fetch; if (originalFetch) { window.fetch = function (...args) { try { const headers = args[1]?.headers || args[0]?.headers; let value = ''; if (headers instanceof Headers) value = headers.get('Authorization') || ''; else if (headers) value = headers.Authorization || headers.authorization || ''; const token = normToken(value); if (token && token !== TOKEN) setToken(token, 'fetch'); } catch (_) {} return originalFetch.apply(this, args); }; } } catch (_) {} } function showTokenGuide() { document.getElementById('xtaGuideOverlay')?.remove(); const overlay = document.createElement('div'); overlay.id = 'xtaGuideOverlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const input = document.getElementById('xtaTokenInput'); input.value = TOKEN || ''; const close = () => overlay.remove(); overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); document.getElementById('xtaGuideClose').addEventListener('click', close); document.getElementById('xtaGuideCancel').addEventListener('click', close); document.getElementById('xtaTokenCode').addEventListener('click', e => e.currentTarget.select()); document.getElementById('xtaTokenSave').addEventListener('click', () => { const token = normToken(input.value); if (!token) { alert('格式不正确,请粘贴 Bearer eyJ... 或 eyJ... 格式的 JWT'); return; } setToken(token, 'manual'); close(); }); input.focus(); } function httpGet(path, params = {}) { if (!TOKEN) throw new Error('Token 未获取'); const url = new URL(path, CFG.api); Object.entries(params).forEach(([key, value]) => { if (value !== null && value !== undefined) url.searchParams.set(key, String(value)); }); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url.toString(), timeout: CFG.timeout, headers: { Authorization: TOKEN }, onload(response) { if (response.status === 401) { TOKEN = ''; gmSet(STORE.token, ''); gmSet(LEGACY.token, ''); refreshTokenUI(); reject(new Error('Token 已过期')); return; } if (response.status < 200 || response.status >= 300) { reject(new Error(`HTTP ${response.status}`)); return; } try { const json = JSON.parse(response.responseText); if (json.errcode !== 1) { reject(new Error(json.errmsg || '接口返回错误')); return; } resolve(json.data); } catch (_) { reject(new Error('接口数据解析失败')); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } function isAutoReply(body) { if (!body || !TEMPLATES.length) return false; const text = String(body).trim(); for (const template of TEMPLATES) { if (!template) continue; const tpl = template.trim(); if (text === tpl) return true; if (text.includes(tpl) || tpl.includes(text)) return true; if (text.length > 20 && tpl.length > 20 && tpl.includes(text.slice(0, 40))) return true; } return false; } async function loadTemplates() { try { const cached = gmGet(STORE.template, '') || gmGet(LEGACY.template, ''); if (cached) { TEMPLATES = JSON.parse(cached); if (TEMPLATES.length) return; } } catch (_) {} try { const data = await httpGet('/apis/teacher/setting/auto-reply'); const arr = Array.isArray(data) ? data : (data?.data || []); TEMPLATES = arr.map(item => String(item.reply_content || '').trim()).filter(Boolean); const serialized = JSON.stringify(TEMPLATES); gmSet(STORE.template, serialized); gmSet(LEGACY.template, serialized); log(`模板:${TEMPLATES.length} 条`); } catch (err) { log(`模板加载失败:${err.message}`); TEMPLATES = []; } } async function refreshTemplateCache() { if (!TOKEN) { scanToken(); if (!TOKEN) { setStatus('请先设置 Token', 'err'); return; } } gmSet(STORE.template, ''); gmSet(LEGACY.template, ''); TEMPLATES = []; setStatus('正在刷新快捷回复模板...', 'run'); await loadTemplates(); setStatus(`模板已刷新:${TEMPLATES.length} 条`, 'ok'); } 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'; 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'; } function analyzeMessages(messages) { const result = { category: 'ok', label: '', stuT: 0, teaT: 0, autoT: 0, aiT: 0, c: { s: 0, t: 0, a: 0, ai: 0 }, }; if (!messages?.length) { result.category = 'red'; result.label = '无消息记录'; return result; } const sorted = [...messages].sort((a, b) => msgTime(b) - msgTime(a)); for (const message of sorted) { const role = classify(message); const time = msgTime(message); switch (role) { case 'student': result.c.s++; if (time > result.stuT) result.stuT = time; break; case 'teacher': result.c.t++; if (time > result.teaT) result.teaT = time; break; case 'auto': result.c.a++; if (time > result.autoT) result.autoT = time; break; case 'ai': result.c.ai++; if (time > result.aiT) result.aiT = time; break; } } const hasTeacherMessage = (result.c.t + result.c.a) > 0; const hasRealReply = result.c.t > 0; const hasAutoOnly = result.c.a > 0 && result.c.t === 0; if (!hasTeacherMessage) { result.category = 'red'; result.label = result.c.ai > 0 ? '仅 AI 回复,老师未接手' : '老师未回复'; } else if (hasAutoOnly) { result.category = 'yellow'; result.label = '已快捷回复,未解答'; } else if (hasRealReply && result.stuT > result.teaT) { result.category = 'orange'; result.label = '学生追问待回复'; } else if (hasRealReply && result.stuT <= result.teaT) { result.category = 'ok'; result.label = '已回复'; } else { result.category = 'ok'; result.label = '已处理'; } return result; } async function fetchAllInProgressIssues() { const all = []; const seenIssueIds = new Set(); const seenCursors = new Set(); let cursor = 0; for (let page = 1; page <= CFG.maxPages; page++) { setStatus(`正在获取进行中的工单列表(第 ${page} 页)...`, 'run'); showProgress(0, 1, `列表第 ${page} 页`); const data = await httpGet('/apis/teacher/issue/in-progress', { page_size: CFG.pageSize, latest_id: cursor, }); const list = normalizeList(data); if (!list.length) break; let added = 0; for (const item of list) { const issueId = item.issue_id || item.id || item.issue_sn; if (!issueId || seenIssueIds.has(String(issueId))) continue; seenIssueIds.add(String(issueId)); all.push(item); added++; } if (list.length < CFG.pageSize || added === 0) break; const nextCursor = getNextIssueCursor(list); if (!nextCursor || seenCursors.has(String(nextCursor))) break; seenCursors.add(String(nextCursor)); cursor = nextCursor; } return all; } function normalizeList(data) { if (Array.isArray(data)) return data; if (Array.isArray(data?.list)) return data.list; if (Array.isArray(data?.data)) return data.data; if (Array.isArray(data?.rows)) return data.rows; return []; } function getNextIssueCursor(list) { const last = list[list.length - 1] || {}; return last.latest_id || last.id || last.issue_id || 0; } async function analyzeIssueItem(item) { const id = item.issue_id || item.id; if (!id) throw new Error('工单缺少 issue_id'); const detail = await httpGet(`/apis/teacher/issue/${id}`); if (CFG.requestGap) await sleep(CFG.requestGap); const roomId = detail.room_id || item.room_id; let messages = []; if (roomId) { const chatData = await httpGet('/apis/teacher/chat-message', { room_id: roomId, page_size: CFG.chatSize, direction: 'backward', }); messages = normalizeList(chatData); if (CFG.requestGap) await sleep(CFG.requestGap); } const analysis = analyzeMessages(messages); log(`[${id}] ${item.student_name || ''} | ${analysis.label} | S${analysis.c.s} T${analysis.c.t} A${analysis.c.a} AI${analysis.c.ai}`); if (analysis.category === 'ok') return null; return { id, sn: detail.issue_sn || item.issue_sn || '', title: String(detail.issue_title || item.issue_title || '').replace(/\s+/g, ' ').trim(), name: detail.student_name || item.student_name || '未知学生', category: analysis.category, label: analysis.label, stuT: analysis.stuT, teaT: analysis.teaT, autoT: analysis.autoT, c: analysis.c, unread: item.unread_messages_count || 0, }; } async function mapWithConcurrency(items, limit, worker, onProgress) { const results = new Array(items.length); let nextIndex = 0; let finished = 0; const workerCount = Math.max(1, Math.min(limit, items.length)); async function runWorker() { while (nextIndex < items.length) { const index = nextIndex++; try { results[index] = await worker(items[index], index); } catch (err) { log(`[${items[index]?.issue_id || items[index]?.id || index}] 失败:${err.message}`); results[index] = null; } finally { finished++; onProgress?.(finished, items.length, items[index], index); } } } await Promise.all(Array.from({ length: workerCount }, runWorker)); return results; } async function doFetch() { if (RUNNING) return; if (!TOKEN) { scanToken(); if (!TOKEN) { setActiveTab('tickets'); setStatus('请先设置 Token', 'err'); return; } } RUNNING = true; hasFetched = true; ALL_RESULTS = []; setFetchButtonState(); renderList([]); updateCounts([]); setStatus('正在加载快捷回复模板...', 'run'); showProgress(0, 1, '准备中'); try { await loadTemplates(); const list = await fetchAllInProgressIssues(); if (!list.length) { setStatus('没有进行中的工单', 'ok'); hideProgress(); updateCounts([]); renderList([]); return; } const total = list.length; setStatus(`已获取 ${total} 条工单,正在并发检测(${CFG.concurrency} 路)...`, 'run'); showProgress(0, total, `并发检测 0/${total}`); const analyzed = await mapWithConcurrency( list, CFG.concurrency, analyzeIssueItem, (finished, count, item) => { showProgress(finished, count, `已检测 ${finished}/${count}:${item?.student_name || item?.issue_id || ''}`); } ); const results = analyzed.filter(Boolean); const order = { red: 0, yellow: 1, orange: 2 }; results.sort((a, b) => { const rank = (order[a.category] ?? 9) - (order[b.category] ?? 9); if (rank !== 0) return rank; return b.stuT - a.stuT; }); ALL_RESULTS = results; updateCounts(results); filterAndRender(); const red = results.filter(item => item.category === 'red').length; const yellow = results.filter(item => item.category === 'yellow').length; const orange = results.filter(item => item.category === 'orange').length; setStatus( results.length ? `共 ${total} 条,需处理 ${results.length} 条:未回复 ${red} / 未解答 ${yellow} / 追问 ${orange}` : `共 ${total} 条,均已回复`, results.length ? 'warn' : 'ok' ); } catch (err) { log('抓取失败:', err); setStatus(err.message, 'err'); hideProgress(); } finally { RUNNING = false; setFetchButtonState(); } } function setFetchButtonState() { if (!ui.fetchBtn) return; ui.fetchBtn.disabled = RUNNING; ui.fetchBtn.textContent = RUNNING ? '抓取中...' : '开始抓取'; } function setStatus(text, state = '') { if (!ui.status) return; ui.status.textContent = text; ui.status.dataset.state = state; } function showProgress(current, total, label) { if (!ui.progress) return; ui.progress.classList.add('is-visible'); const percent = total > 0 ? Math.round((current / total) * 100) : 0; ui.progressBar.style.width = `${Math.max(0, Math.min(percent, 100))}%`; ui.progressText.textContent = label || `${current}/${total}`; } function hideProgress() { ui.progress?.classList.remove('is-visible'); } function updateCounts(list) { const counts = { all: list.length, red: list.filter(item => item.category === 'red').length, yellow: list.filter(item => item.category === 'yellow').length, orange: list.filter(item => item.category === 'orange').length, }; Object.entries(counts).forEach(([key, value]) => { if (ui.counts?.[key]) ui.counts[key].textContent = value; }); } function filterAndRender() { const filtered = CURRENT_FILTER === 'all' ? ALL_RESULTS : ALL_RESULTS.filter(item => item.category === CURRENT_FILTER); renderList(filtered); document.querySelectorAll('#xtaPanel .xta-filter').forEach(btn => { btn.classList.toggle('is-active', btn.dataset.filter === CURRENT_FILTER); }); } function renderList(list) { if (!ui.list) return; ui.list.innerHTML = ''; if (!list.length) { const empty = document.createElement('div'); empty.className = 'xta-empty'; empty.textContent = hasFetched ? '当前筛选下无待处理工单' : '点击「开始抓取」获取待处理工单'; ui.list.appendChild(empty); return; } for (const item of list) { const row = document.createElement('button'); row.type = 'button'; row.className = `xta-ticket xta-ticket-${item.category}`; row.innerHTML = `
${escapeHtml(item.name)} ${escapeHtml(item.label)}
${escapeHtml(item.title || item.sn || '无标题')}
学生 ${escapeHtml(fmtTime(item.stuT))} · 老师 ${escapeHtml(item.teaT ? fmtTime(item.teaT) : (item.autoT ? `快捷 ${fmtTime(item.autoT)}` : '—'))} 学${item.c.s} 师${item.c.t} 快捷${item.c.a} AI${item.c.ai}
`; row.addEventListener('click', () => { window.open(`https://chath5.kaoshids.com/#/pages/chat/chat?issueId=${encodeURIComponent(item.id)}`, '_blank'); }); ui.list.appendChild(row); } } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[ch])); } function setupAIObserver() { if (mutationObserver) mutationObserver.disconnect(); if (!document.body) return; mutationObserver = new MutationObserver(mutations => { if (!AI_AUTO_COLLAPSE || !IS_CHAT_PAGE) return; const added = []; mutations.forEach(mutation => { mutation.addedNodes.forEach(node => added.push(node)); }); if (added.length) setTimeout(() => handleNewAINodes(added), 50); }); mutationObserver.observe(document.body, { childList: true, subtree: true }); } function setupVisibilityRefresh() { document.addEventListener('visibilitychange', () => { if (!document.hidden && IS_CHAT_PAGE && AI_AUTO_COLLAPSE) { setTimeout(() => toggleAllAI(true, { quiet: true }), 400); } }); } function handleNewAINodes(nodes) { const boxes = []; nodes.forEach(node => { if (node.nodeType !== 1) return; if (node.classList?.contains('ai-box')) boxes.push(node); node.querySelectorAll?.('.ai-box').forEach(box => boxes.push(box)); }); if (boxes.length) collapseAIBoxes(boxes, true); refreshAIUI(); } function collapseAIBoxes(boxes, collapsed) { let count = 0; boxes.forEach(box => { const chatItem = box.closest('.chat-item'); if (chatItem && chatItem.classList.contains('justify-end')) return; box.classList.toggle('xta-ai-collapsed', collapsed); count++; }); if (count) log(`${collapsed ? '折叠' : '展开'} ${count} 条 AI 回复`); return count; } function getAIBoxes() { return Array.from(document.querySelectorAll('.ai-box')); } function toggleAllAI(collapsed, opts = {}) { if (!IS_CHAT_PAGE) { if (!opts.quiet) setActiveTab('ai'); refreshAIUI('当前不是聊天页,AI 折叠功能暂不可用'); return 0; } const count = collapseAIBoxes(getAIBoxes(), collapsed); refreshAIUI(count ? `已${collapsed ? '折叠' : '展开'} ${count} 条 AI 回复` : '当前页面没有检测到 AI 回复'); return count; } function refreshAIUI(message) { if (ui.autoAiSwitch) ui.autoAiSwitch.checked = Boolean(AI_AUTO_COLLAPSE); if (!ui.aiSummary) return; if (!IS_CHAT_PAGE) { ui.aiSummary.textContent = '当前不是聊天页。打开具体工单聊天页后,可在这里折叠或展开 AI 回复。'; ui.collapseAiBtn.disabled = true; ui.expandAiBtn.disabled = true; ui.autoAiSwitch.disabled = true; return; } const boxes = getAIBoxes(); const hidden = boxes.filter(box => box.classList.contains('xta-ai-collapsed')).length; ui.collapseAiBtn.disabled = false; ui.expandAiBtn.disabled = false; ui.autoAiSwitch.disabled = false; ui.aiSummary.textContent = message || `已检测到 ${boxes.length} 条 AI 回复,当前折叠 ${hidden} 条。`; } })();