// ==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 回复内容。隐藏后可用侧边圆形按钮重新打开面板。
`;
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 = `
方法 1:自动获取
刷新页面,脚本会尝试从页面请求或本地缓存中捕获 Token。面板显示“Token 已就绪”即成功。
方法 2:手动复制
打开浏览器开发者工具,在 Network 面板中找到 issue 请求,复制 Request Headers 里的 Authorization: Bearer eyJ...。
方法 3:控制台辅助复制
在 Console 粘贴下面代码执行,找到后会写入剪贴板。
`;
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} 条。`;
}
})();