// ==UserScript==
// @name 学管答疑工单助手(合并版)
// @namespace https://chath5.kaoshids.com
// @version 5.1.3
// @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 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',
panelHeight: 'xta_panel_height_v1',
activeTab: 'xta_active_tab_v1',
aiAuto: 'xta_ai_auto_collapse_v1',
imageViewerSettings: 'xta_image_viewer_settings_v1',
shortcutSettingsOpen: 'xta_shortcut_settings_open_v1',
};
const LEGACY = {
token: 'xchat_token_v33',
template: 'xchat_tpl_v33',
aiAuto: 'defaultCollapse',
aiPanelHidden: 'panelHidden',
aiPanelMin: 'panelCollapsed',
aiLeft: 'panelLeft',
aiTop: 'panelTop',
};
const DEFAULT_IMAGE_VIEWER_SETTINGS = {
preserveZoom: true,
enhance: {
defaultEnabled: false,
strength: 55,
},
shortcuts: {
prev: 'ArrowLeft',
next: 'Space',
zoomIn: '=',
zoomOut: '-',
reset: '0',
enhance: 'e',
rotateLeft: 'q',
rotateRight: 'r',
close: 'Escape',
},
};
const VIEWER_SHORTCUT_LABELS = {
prev: '上一张',
next: '下一张',
zoomIn: '放大',
zoomOut: '缩小',
reset: '复位',
enhance: '增强',
rotateLeft: '左旋',
rotateRight: '右旋',
close: '关闭',
};
const PANEL_HEIGHT = {
min: 420,
max: 860,
step: 20,
default: 640,
};
let TOKEN = '';
let RUNNING = false;
let ALL_RESULTS = [];
let TEMPLATES = [];
let CURRENT_FILTER = 'all';
let CURRENT_TAB = normalizeActiveTab(gmGet(STORE.activeTab, 'tickets'));
let AI_AUTO_COLLAPSE = gmGet(STORE.aiAuto, gmGet(LEGACY.aiAuto, true));
let IMAGE_VIEWER_SETTINGS = normalizeImageViewerSettings(gmGet(STORE.imageViewerSettings, {}));
let PANEL_MINIMIZED = gmGet(STORE.panelMin, false);
let PANEL_HIDDEN = gmGet(STORE.panelHidden, gmGet(LEGACY.aiPanelHidden, false));
let PANEL_CURRENT_HEIGHT = clampNumber(gmGet(STORE.panelHeight, PANEL_HEIGHT.default), PANEL_HEIGHT.min, PANEL_HEIGHT.max, PANEL_HEIGHT.default);
let SHORTCUT_SETTINGS_OPEN = Boolean(gmGet(STORE.shortcutSettingsOpen, false));
let mutationObserver = null;
let hasFetched = false;
let imageViewer = null;
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 normalizeActiveTab(tab) {
if (tab === 'tickets') return 'tickets';
if (tab === 'manage' || tab === 'ai') return 'manage';
return 'tickets';
}
function normalizeImageViewerSettings(value) {
const settings = value && typeof value === 'object' ? value : {};
const legacyShortcuts = settings.shortcuts && typeof settings.shortcuts === 'object' ? settings.shortcuts : {};
const shortcuts = {
...DEFAULT_IMAGE_VIEWER_SETTINGS.shortcuts,
...legacyShortcuts,
};
if (legacyShortcuts.rotate && !legacyShortcuts.rotateRight) {
shortcuts.rotateRight = legacyShortcuts.rotate;
}
delete shortcuts.rotate;
return {
preserveZoom: settings.preserveZoom !== false,
enhance: {
defaultEnabled: Boolean(settings.enhance?.defaultEnabled),
strength: clampNumber(settings.enhance?.strength, 0, 100, DEFAULT_IMAGE_VIEWER_SETTINGS.enhance.strength),
},
shortcuts,
};
}
function clampNumber(value, min, max, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
}
function saveImageViewerSettings() {
IMAGE_VIEWER_SETTINGS = normalizeImageViewerSettings(IMAGE_VIEWER_SETTINGS);
gmSet(STORE.imageViewerSettings, IMAGE_VIEWER_SETTINGS);
renderImageViewerSettingsUI();
updateViewerTip();
}
function resetImageViewerSettings() {
IMAGE_VIEWER_SETTINGS = normalizeImageViewerSettings(DEFAULT_IMAGE_VIEWER_SETTINGS);
saveImageViewerSettings();
if (imageViewer?.visible) {
imageViewer.enhanceActive = IMAGE_VIEWER_SETTINGS.enhance.defaultEnabled;
applyViewerEnhancement();
}
}
function renderImageViewerSettingsUI() {
if (ui.preserveZoomSwitch) {
ui.preserveZoomSwitch.checked = Boolean(IMAGE_VIEWER_SETTINGS.preserveZoom);
}
if (ui.defaultEnhanceSwitch) {
ui.defaultEnhanceSwitch.checked = Boolean(IMAGE_VIEWER_SETTINGS.enhance.defaultEnabled);
}
if (ui.enhanceStrengthRange) {
ui.enhanceStrengthRange.value = IMAGE_VIEWER_SETTINGS.enhance.strength;
}
if (ui.enhanceStrengthValue) {
ui.enhanceStrengthValue.textContent = `${IMAGE_VIEWER_SETTINGS.enhance.strength}%`;
}
if (ui.shortcutDetails && ui.shortcutDetails.open !== SHORTCUT_SETTINGS_OPEN) {
ui.shortcutDetails.open = SHORTCUT_SETTINGS_OPEN;
}
ui.viewerShortcutBtns?.forEach(btn => {
const action = btn.dataset.viewerShortcut;
btn.classList.remove('is-capturing');
btn.textContent = formatShortcutKey(IMAGE_VIEWER_SETTINGS.shortcuts[action]);
btn.title = `点击后按键设置“${VIEWER_SHORTCUT_LABELS[action]}”快捷键`;
});
}
function startShortcutCapture(btn) {
ui.viewerShortcutBtns?.forEach(item => item.classList.remove('is-capturing'));
btn.classList.add('is-capturing');
btn.textContent = '按键...';
btn.focus();
}
function captureViewerShortcut(e, btn) {
if (!btn.classList.contains('is-capturing')) return;
e.preventDefault();
e.stopPropagation();
const key = normalizeShortcutKey(e);
if (!key) return;
assignViewerShortcut(btn.dataset.viewerShortcut, key);
saveImageViewerSettings();
btn.blur();
}
function assignViewerShortcut(action, key) {
for (const otherAction of Object.keys(IMAGE_VIEWER_SETTINGS.shortcuts)) {
if (otherAction !== action && shortcutKeyEquals(IMAGE_VIEWER_SETTINGS.shortcuts[otherAction], key)) {
IMAGE_VIEWER_SETTINGS.shortcuts[otherAction] = '';
}
}
IMAGE_VIEWER_SETTINGS.shortcuts[action] = key;
}
function shortcutKeyEquals(a, b) {
if (!a || !b) return false;
if (a.length === 1 && b.length === 1) return a.toLowerCase() === b.toLowerCase();
return a === b;
}
function normalizeShortcutKey(e) {
if (e.key === ' ') return 'Space';
if (e.key === 'Esc') return 'Escape';
return e.key || '';
}
function formatShortcutKey(key) {
const names = {
Space: '空格',
Escape: 'Esc',
ArrowLeft: '←',
ArrowRight: '→',
ArrowUp: '↑',
ArrowDown: '↓',
};
return names[key] || key || '未设置';
}
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();
setupImageViewer();
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 *, #xtaImageViewer, #xtaImageViewer * {
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));
height: min(var(--xta-panel-height, 640px), calc(100vh - 16px));
max-height: calc(100vh - 16px);
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));
height: auto;
max-height: none;
}
#xtaPanel.is-minimized .xta-body {
display: none;
}
#xtaPanel.is-minimized .xta-panel-resizer {
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;
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f8fafc;
}
.xta-tabs {
flex: 0 0 auto;
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: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
#xtaPanel[data-tab="tickets"] .xta-section-tickets,
#xtaPanel[data-tab="manage"] .xta-section-manage {
display: flex;
}
#xtaPanel[data-tab="manage"] .xta-section-manage {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.xta-toolbar {
flex: 0 0 auto;
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;
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: 0;
flex: 1 1 auto;
max-height: none;
overflow-y: auto;
background: #fff;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.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-manage-card {
flex: 0 0 auto;
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-manage-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-manage-help {
flex: 0 0 auto;
padding: 12px;
color: #64748b;
font-size: 12px;
line-height: 1.7;
}
.xta-settings-card {
flex: 0 0 auto;
padding: 14px 12px 16px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.xta-settings-title {
margin-bottom: 10px;
color: #111827;
font-size: 13px;
font-weight: 800;
}
.xta-collapsible {
margin-bottom: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.xta-collapsible summary {
min-height: 38px;
padding: 0 10px;
color: #111827;
cursor: pointer;
font-size: 13px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
list-style: none;
}
.xta-collapsible summary::-webkit-details-marker {
display: none;
}
.xta-collapsible summary::after {
content: "+";
width: 22px;
height: 22px;
border-radius: 7px;
background: #f1f5f9;
color: #475569;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 15px;
line-height: 1;
}
.xta-collapsible[open] summary::after {
content: "−";
}
.xta-collapsible-body {
padding: 0 10px 10px;
}
.xta-shortcut-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.xta-shortcut-item {
display: grid;
grid-template-columns: 52px 1fr;
align-items: center;
gap: 8px;
min-width: 0;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.xta-key-btn {
height: 30px;
min-width: 0;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #f8fafc;
color: #111827;
cursor: pointer;
font-size: 12px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 8px;
}
.xta-key-btn.is-capturing {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.xta-settings-footer {
margin-top: 10px;
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.xta-range-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
margin: 10px 0 12px;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.xta-range-row input[type="range"] {
width: 100%;
accent-color: #2563eb;
}
.xta-panel-resizer {
flex: 0 0 12px;
height: 12px;
border-top: 1px solid #e5e7eb;
background: linear-gradient(#fff, #f8fafc);
cursor: ns-resize;
position: relative;
}
.xta-panel-resizer::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 46px;
height: 4px;
border-radius: 999px;
background: #cbd5e1;
transform: translate(-50%, -50%);
}
.xta-panel-resizer:hover::before {
background: #94a3b8;
}
#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;
}
#xtaImageViewer {
position: fixed;
inset: 0;
z-index: 2147483647;
display: none;
overflow: hidden;
background: rgba(3, 7, 18, .88);
color: #fff;
user-select: none;
}
#xtaImageViewer.is-visible {
display: block;
}
.xta-image-stage {
position: absolute;
inset: 0;
cursor: grab;
touch-action: none;
}
.xta-image-stage.is-dragging {
cursor: grabbing;
}
#xtaViewerImg {
position: absolute;
left: 50%;
top: 50%;
max-width: none;
max-height: none;
width: auto;
height: auto;
transform: translate(-50%, -50%) scale(1);
transform-origin: center center;
transition: transform .12s ease, left .12s ease, top .12s ease;
box-shadow: 0 22px 70px rgba(0, 0, 0, .38);
-webkit-user-drag: none;
user-drag: none;
}
.xta-viewer-toolbar {
position: absolute;
left: 50%;
top: 14px;
transform: translateX(-50%);
min-height: 38px;
max-width: calc(100vw - 120px);
padding: 6px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 10px;
background: rgba(15, 23, 42, .72);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: 6px;
}
.xta-viewer-btn {
width: 34px;
height: 30px;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, .12);
color: #fff;
cursor: pointer;
font-size: 16px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.xta-viewer-btn:hover {
background: rgba(255, 255, 255, .22);
}
#xtaViewerEnhance {
width: 56px;
font-size: 12px;
font-weight: 800;
}
#xtaViewerRotateLeft,
#xtaViewerRotateRight {
width: 44px;
font-size: 12px;
font-weight: 800;
}
.xta-viewer-btn.is-active {
background: rgba(37, 99, 235, .88);
color: #fff;
}
.xta-viewer-count {
min-width: 70px;
color: rgba(255, 255, 255, .88);
font-size: 12px;
font-weight: 700;
text-align: center;
white-space: nowrap;
}
.xta-viewer-close {
position: absolute;
right: 16px;
top: 14px;
width: 38px;
height: 38px;
border: 0;
border-radius: 10px;
background: rgba(15, 23, 42, .72);
color: #fff;
cursor: pointer;
font-size: 24px;
line-height: 1;
}
.xta-viewer-nav {
position: absolute;
top: 50%;
width: 44px;
height: 62px;
transform: translateY(-50%);
border: 0;
border-radius: 10px;
background: rgba(15, 23, 42, .62);
color: #fff;
cursor: pointer;
font-size: 28px;
line-height: 1;
}
.xta-viewer-nav:hover,
.xta-viewer-close:hover {
background: rgba(15, 23, 42, .82);
}
.xta-viewer-prev {
left: 16px;
}
.xta-viewer-next {
right: 16px;
}
.xta-viewer-tip {
position: absolute;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
max-width: calc(100vw - 32px);
padding: 7px 10px;
border-radius: 8px;
background: rgba(15, 23, 42, .62);
color: rgba(255, 255, 255, .78);
font-size: 12px;
line-height: 1.45;
white-space: nowrap;
}
@media (max-width: 520px) {
#xtaPanel {
width: calc(100vw - 18px);
height: min(var(--xta-panel-height, 640px), calc(100vh - 18px));
max-height: calc(100vh - 18px);
}
.xta-viewer-toolbar {
top: 10px;
max-width: calc(100vw - 72px);
}
.xta-viewer-btn {
width: 32px;
}
.xta-viewer-count {
min-width: 58px;
}
.xta-viewer-nav {
width: 38px;
height: 56px;
}
.xta-viewer-prev {
left: 8px;
}
.xta-viewer-next {
right: 8px;
}
.xta-viewer-tip {
display: none;
}
.xta-shortcut-grid {
grid-template-columns: 1fr;
}
.xta-filters {
grid-template-columns: repeat(2, 1fr);
}
.xta-ticket-meta {
flex-direction: column;
gap: 2px;
}
}
`);
}
function createPanel() {
document.getElementById('xtaPanel')?.remove();
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.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.preserveZoomSwitch = document.getElementById('xtaPreserveZoomSwitch');
ui.defaultEnhanceSwitch = document.getElementById('xtaDefaultEnhanceSwitch');
ui.enhanceStrengthRange = document.getElementById('xtaEnhanceStrengthRange');
ui.enhanceStrengthValue = document.getElementById('xtaEnhanceStrengthValue');
ui.panelResizer = document.getElementById('xtaPanelResizer');
ui.shortcutDetails = document.getElementById('xtaShortcutDetails');
ui.viewerShortcutBtns = Array.from(document.querySelectorAll('[data-viewer-shortcut]'));
ui.resetViewerKeysBtn = document.getElementById('xtaResetViewerKeys');
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.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();
});
ui.preserveZoomSwitch.checked = Boolean(IMAGE_VIEWER_SETTINGS.preserveZoom);
ui.preserveZoomSwitch.addEventListener('change', () => {
IMAGE_VIEWER_SETTINGS.preserveZoom = ui.preserveZoomSwitch.checked;
saveImageViewerSettings();
});
ui.defaultEnhanceSwitch.addEventListener('change', () => {
IMAGE_VIEWER_SETTINGS.enhance.defaultEnabled = ui.defaultEnhanceSwitch.checked;
saveImageViewerSettings();
});
ui.enhanceStrengthRange.addEventListener('input', () => {
IMAGE_VIEWER_SETTINGS.enhance.strength = clampNumber(ui.enhanceStrengthRange.value, 0, 100, DEFAULT_IMAGE_VIEWER_SETTINGS.enhance.strength);
saveImageViewerSettings();
if (imageViewer?.visible) applyViewerEnhancement();
});
ui.shortcutDetails.addEventListener('toggle', () => {
SHORTCUT_SETTINGS_OPEN = ui.shortcutDetails.open;
gmSet(STORE.shortcutSettingsOpen, SHORTCUT_SETTINGS_OPEN);
});
ui.viewerShortcutBtns.forEach(btn => {
btn.addEventListener('click', () => startShortcutCapture(btn));
btn.addEventListener('keydown', e => captureViewerShortcut(e, btn));
});
ui.resetViewerKeysBtn.addEventListener('click', resetImageViewerSettings);
renderImageViewerSettingsUI();
applyPanelHeight(panel);
makeDraggable(panel, ui.header);
makePanelResizable(panel, ui.panelResizer);
window.addEventListener('resize', () => applyPanelHeight(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 makePanelResizable(panel, handle) {
if (!handle) return;
let resizing = false;
let startY = 0;
let startHeight = 0;
handle.addEventListener('pointerdown', e => {
if (PANEL_MINIMIZED) return;
const rect = panel.getBoundingClientRect();
resizing = true;
startY = e.clientY;
startHeight = rect.height;
handle.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
document.addEventListener('pointermove', e => {
if (!resizing) return;
const limits = getPanelHeightLimits();
PANEL_CURRENT_HEIGHT = clampNumber(startHeight + e.clientY - startY, limits.min, limits.max, PANEL_HEIGHT.default);
applyPanelHeight(panel);
});
document.addEventListener('pointerup', () => {
if (!resizing) return;
resizing = false;
gmSet(STORE.panelHeight, PANEL_CURRENT_HEIGHT);
});
}
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 getPanelHeightLimits() {
const viewportMax = Math.max(260, window.innerHeight - 16);
const max = Math.max(260, Math.min(PANEL_HEIGHT.max, viewportMax));
const min = Math.min(PANEL_HEIGHT.min, max);
return { min, max };
}
function applyPanelHeight(panel = ui.panel) {
if (!panel) return;
const limits = getPanelHeightLimits();
PANEL_CURRENT_HEIGHT = clampNumber(PANEL_CURRENT_HEIGHT, limits.min, limits.max, Math.min(PANEL_HEIGHT.default, limits.max));
panel.style.setProperty('--xta-panel-height', `${PANEL_CURRENT_HEIGHT}px`);
keepPanelInViewport(panel);
}
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() {
document.getElementById('xtaFab')?.remove();
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 = normalizeActiveTab(tab);
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 === 'manage') 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 = [];
}
}
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);
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 ticketSortTime(item) {
return item.stuT || item.autoT || item.teaT || Number.MAX_SAFE_INTEGER;
}
function compareTicketTimeAsc(a, b) {
const diff = ticketSortTime(a) - ticketSortTime(b);
if (diff !== 0) return diff;
return String(a.name || '').localeCompare(String(b.name || ''), 'zh-Hans-CN');
}
function filterAndRender() {
const filtered = CURRENT_FILTER === 'all'
? ALL_RESULTS
: ALL_RESULTS.filter(item => item.category === CURRENT_FILTER);
renderList([...filtered].sort(compareTicketTimeAsc));
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 setupImageViewer() {
createImageViewer();
document.addEventListener('click', handleImageClick, true);
document.addEventListener('keydown', handleViewerKeydown, true);
window.addEventListener('resize', () => {
if (imageViewer?.visible) fitViewerImage({ ratio: getCurrentZoomRatio() });
});
}
function createImageViewer() {
const existing = document.getElementById('xtaImageViewer');
if (existing) {
if (imageViewer?.root === existing) return;
existing.remove();
}
const viewer = document.createElement('div');
viewer.id = 'xtaImageViewer';
viewer.innerHTML = `
0 / 0
滚轮缩放 · 拖拽移动 · 双击复位 · 空格下一张 · Esc 关闭
`;
document.body.appendChild(viewer);
imageViewer = {
root: viewer,
stage: document.getElementById('xtaImageStage'),
img: document.getElementById('xtaViewerImg'),
count: document.getElementById('xtaViewerCount'),
tip: document.getElementById('xtaViewerTip'),
enhanceBtn: document.getElementById('xtaViewerEnhance'),
rotateLeftBtn: document.getElementById('xtaViewerRotateLeft'),
rotateRightBtn: document.getElementById('xtaViewerRotateRight'),
images: [],
index: 0,
scale: 1,
baseScale: 1,
rotation: 0,
panX: 0,
panY: 0,
visible: false,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragX: 0,
dragY: 0,
dragMoved: false,
suppressStageClick: false,
pendingZoomRatio: 1,
enhanceActive: false,
previousDocumentOverflow: '',
};
updateViewerTip();
document.getElementById('xtaViewerClose').addEventListener('click', closeImageViewer);
document.getElementById('xtaViewerPrev').addEventListener('click', () => showViewerImage(imageViewer.index - 1));
document.getElementById('xtaViewerNext').addEventListener('click', () => showViewerImage(imageViewer.index + 1));
document.getElementById('xtaViewerZoomOut').addEventListener('click', () => zoomViewer(0.82));
document.getElementById('xtaViewerZoomIn').addEventListener('click', () => zoomViewer(1.22));
document.getElementById('xtaViewerReset').addEventListener('click', fitViewerImage);
document.getElementById('xtaViewerEnhance').addEventListener('click', toggleViewerEnhancement);
document.getElementById('xtaViewerRotateLeft').addEventListener('click', () => rotateViewer(-90));
document.getElementById('xtaViewerRotateRight').addEventListener('click', () => rotateViewer(90));
imageViewer.stage.addEventListener('click', e => {
if (imageViewer.suppressStageClick || imageViewer.dragMoved) {
imageViewer.dragMoved = false;
e.preventDefault();
e.stopPropagation();
return;
}
if (e.target === imageViewer.stage) closeImageViewer();
});
imageViewer.stage.addEventListener('wheel', e => {
if (!imageViewer.visible) return;
e.preventDefault();
zoomViewer(e.deltaY > 0 ? 0.88 : 1.14);
}, { passive: false });
imageViewer.stage.addEventListener('dblclick', e => {
e.preventDefault();
fitViewerImage();
});
imageViewer.stage.addEventListener('pointerdown', e => {
if (!imageViewer.visible || e.button !== 0) return;
imageViewer.dragging = true;
imageViewer.dragMoved = false;
imageViewer.suppressStageClick = false;
imageViewer.dragStartX = e.clientX;
imageViewer.dragStartY = e.clientY;
imageViewer.dragX = e.clientX;
imageViewer.dragY = e.clientY;
imageViewer.stage.classList.add('is-dragging');
imageViewer.stage.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
imageViewer.stage.addEventListener('pointermove', e => {
if (!imageViewer.dragging) return;
if (Math.hypot(e.clientX - imageViewer.dragStartX, e.clientY - imageViewer.dragStartY) > 4) {
imageViewer.dragMoved = true;
}
imageViewer.panX += e.clientX - imageViewer.dragX;
imageViewer.panY += e.clientY - imageViewer.dragY;
imageViewer.dragX = e.clientX;
imageViewer.dragY = e.clientY;
applyViewerTransform();
});
document.addEventListener('pointerup', () => {
if (!imageViewer?.dragging) return;
if (imageViewer.dragMoved) {
imageViewer.suppressStageClick = true;
setTimeout(() => {
if (imageViewer) imageViewer.suppressStageClick = false;
}, 140);
}
imageViewer.dragging = false;
imageViewer.stage.classList.remove('is-dragging');
});
imageViewer.img.addEventListener('load', fitViewerImage);
imageViewer.img.addEventListener('error', () => {
imageViewer.count.textContent = '图片加载失败';
});
}
function handleImageClick(e) {
if (e.defaultPrevented || e.button !== 0) return;
if (e.target.closest?.('#xtaPanel, #xtaFab, #xtaGuideOverlay, #xtaImageViewer')) return;
if (!isChatRoute()) return;
const candidate = findChatImageAtPoint(e.clientX, e.clientY);
if (!candidate) return;
e.preventDefault();
e.stopImmediatePropagation?.();
e.stopPropagation();
openImageViewer(candidate);
}
function isChatRoute() {
return IS_CHAT_PAGE && (
document.body?.classList.contains('pages-chat-chat') ||
location.hash.includes('/pages/chat/chat') ||
Boolean(document.querySelector('.chat-item .chat-item-image'))
);
}
function findChatImageAtPoint(clientX, clientY) {
const images = getChatImageItems({ visibleOnly: true });
let best = null;
let bestArea = Infinity;
for (const item of images) {
const rect = item.el.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
const area = rect.width * rect.height;
if (area < bestArea) {
best = item;
bestArea = area;
}
}
}
return best;
}
function getChatImageItems(opts = {}) {
const seen = new Set();
const items = [];
document.querySelectorAll('uni-image.chat-item-image, .chat-item-image').forEach(el => {
if (el.closest?.('#xtaPanel, #xtaFab, #xtaGuideOverlay, #xtaImageViewer')) return;
const chatItem = el.closest?.('.chat-item');
if (!chatItem) return;
const rect = el.getBoundingClientRect();
if (rect.width < 40 || rect.height < 40) return;
if (opts.visibleOnly && (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth)) return;
const thumb = getChatImageUrl(el);
const url = toOriginalImageUrl(thumb);
if (!isPreviewableImageUrl(url) || seen.has(url)) return;
seen.add(url);
items.push({ el, url, thumb });
});
return items;
}
function getChatImageUrl(el) {
const img = el.querySelector?.('img');
const imgUrl = img?.currentSrc || img?.src || img?.getAttribute?.('src');
if (imgUrl) return imgUrl;
const bg = el.querySelector?.('div') ? getComputedStyle(el.querySelector('div')).backgroundImage : getComputedStyle(el).backgroundImage;
const match = bg?.match(/url\((['"]?)(.*?)\1\)/);
return match?.[2] || '';
}
function isPreviewableImageUrl(url) {
if (!url) return false;
return /^(https?:|blob:|data:image\/)/i.test(url) || url.startsWith('/') || url.startsWith('./');
}
function toOriginalImageUrl(url) {
const normalized = normalizeImageUrl(url);
try {
const parsed = new URL(normalized);
parsed.searchParams.delete('x-oss-process');
return parsed.href;
} catch (_) {
return normalized.replace(/\?x-oss-process=image\/resize[^#]*/i, '');
}
}
function normalizeImageUrl(url) {
try {
return new URL(url, location.href).href;
} catch (_) {
return url || '';
}
}
function openImageViewer(clickedItem) {
createImageViewer();
const items = getChatImageItems();
imageViewer.images = items.map(item => item.url);
imageViewer.index = Math.max(0, imageViewer.images.indexOf(clickedItem.url));
imageViewer.visible = true;
imageViewer.enhanceActive = Boolean(IMAGE_VIEWER_SETTINGS.enhance.defaultEnabled);
imageViewer.rotation = 0;
imageViewer.root.classList.add('is-visible');
imageViewer.previousDocumentOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
showViewerImage(imageViewer.index, { preserveZoom: false });
applyViewerEnhancement();
}
function closeImageViewer() {
if (!imageViewer?.visible) return;
imageViewer.visible = false;
imageViewer.root.classList.remove('is-visible');
document.documentElement.style.overflow = imageViewer.previousDocumentOverflow || '';
}
function showViewerImage(index, opts = {}) {
if (!imageViewer?.images.length) return;
const total = imageViewer.images.length;
const preserveZoom = opts.preserveZoom ?? IMAGE_VIEWER_SETTINGS.preserveZoom;
imageViewer.pendingZoomRatio = preserveZoom ? getCurrentZoomRatio() : 1;
imageViewer.index = (index + total) % total;
imageViewer.count.textContent = `${imageViewer.index + 1} / ${total}`;
const src = imageViewer.images[imageViewer.index];
imageViewer.panX = 0;
imageViewer.panY = 0;
imageViewer.rotation = 0;
imageViewer.scale = 1;
if (imageViewer.img.src !== src) {
imageViewer.img.src = src;
}
applyViewerTransform();
applyViewerEnhancement();
fitLoadedViewerImage();
}
function fitLoadedViewerImage() {
if (!imageViewer?.visible || !imageViewer.img.complete || !imageViewer.img.naturalWidth) return;
requestAnimationFrame(() => fitViewerImage());
}
function getCurrentZoomRatio() {
if (!imageViewer?.baseScale) return 1;
return imageViewer.scale / imageViewer.baseScale;
}
function fitViewerImage(opts = {}) {
if (!imageViewer?.visible) return;
const img = imageViewer.img;
const naturalW = img.naturalWidth || 1;
const naturalH = img.naturalHeight || 1;
const rotated = Math.abs(imageViewer.rotation % 180) === 90;
const fitW = rotated ? naturalH : naturalW;
const fitH = rotated ? naturalW : naturalH;
const maxW = Math.max(260, window.innerWidth - 72);
const maxH = Math.max(220, window.innerHeight - 96);
imageViewer.baseScale = Math.min(1, maxW / fitW, maxH / fitH);
const ratio = Math.max(0.35, Math.min(opts.ratio ?? imageViewer.pendingZoomRatio ?? 1, 8));
imageViewer.scale = imageViewer.baseScale * ratio;
imageViewer.pendingZoomRatio = 1;
imageViewer.panX = 0;
imageViewer.panY = 0;
applyViewerTransform();
}
function zoomViewer(multiplier) {
if (!imageViewer?.visible) return;
const min = Math.max(0.08, imageViewer.baseScale * 0.35);
const max = Math.max(5, imageViewer.baseScale * 8);
imageViewer.scale = Math.max(min, Math.min(max, imageViewer.scale * multiplier));
applyViewerTransform();
}
function applyViewerTransform() {
if (!imageViewer) return;
imageViewer.img.style.left = `calc(50% + ${imageViewer.panX}px)`;
imageViewer.img.style.top = `calc(50% + ${imageViewer.panY}px)`;
imageViewer.img.style.transform = `translate(-50%, -50%) rotate(${imageViewer.rotation}deg) scale(${imageViewer.scale})`;
}
function rotateViewer(delta = 90) {
if (!imageViewer?.visible) return;
const ratio = getCurrentZoomRatio();
imageViewer.rotation = (imageViewer.rotation + delta + 360) % 360;
fitViewerImage({ ratio });
}
function toggleViewerEnhancement() {
if (!imageViewer) return;
imageViewer.enhanceActive = !imageViewer.enhanceActive;
applyViewerEnhancement();
}
function applyViewerEnhancement() {
if (!imageViewer?.img) return;
if (!imageViewer.enhanceActive) {
imageViewer.img.style.filter = '';
imageViewer.img.style.imageRendering = '';
imageViewer.enhanceBtn?.classList.remove('is-active');
imageViewer.enhanceBtn && (imageViewer.enhanceBtn.textContent = '增强');
return;
}
const strength = clampNumber(IMAGE_VIEWER_SETTINGS.enhance.strength, 0, 100, DEFAULT_IMAGE_VIEWER_SETTINGS.enhance.strength);
const contrast = 1 + strength * 0.012;
const brightness = 1 + strength * 0.0025;
const saturate = Math.max(0.08, 1 - strength * 0.006);
const sharpenShadow = strength > 45 ? ' drop-shadow(0 0 .18px rgba(0,0,0,.75))' : '';
imageViewer.img.style.filter = `contrast(${contrast.toFixed(2)}) brightness(${brightness.toFixed(2)}) saturate(${saturate.toFixed(2)})${sharpenShadow}`;
imageViewer.img.style.imageRendering = strength >= 70 ? 'crisp-edges' : 'auto';
imageViewer.enhanceBtn?.classList.add('is-active');
imageViewer.enhanceBtn && (imageViewer.enhanceBtn.textContent = '已增强');
}
function updateViewerTip() {
if (!imageViewer?.tip) return;
const nextKey = formatShortcutKey(IMAGE_VIEWER_SETTINGS.shortcuts.next);
const closeKey = formatShortcutKey(IMAGE_VIEWER_SETTINGS.shortcuts.close);
const resetKey = formatShortcutKey(IMAGE_VIEWER_SETTINGS.shortcuts.reset);
const enhanceKey = formatShortcutKey(IMAGE_VIEWER_SETTINGS.shortcuts.enhance);
const rotateLeftKey = formatShortcutKey(IMAGE_VIEWER_SETTINGS.shortcuts.rotateLeft);
const rotateRightKey = formatShortcutKey(IMAGE_VIEWER_SETTINGS.shortcuts.rotateRight);
imageViewer.tip.textContent = `滚轮缩放 · 拖拽移动 · ${enhanceKey} 增强 · ${rotateLeftKey}/${rotateRightKey} 左右旋 · 双击或 ${resetKey} 复位 · ${nextKey} 下一张 · ${closeKey} 关闭`;
}
function getViewerShortcutAction(e) {
const shortcuts = IMAGE_VIEWER_SETTINGS.shortcuts;
const order = ['close', 'prev', 'next', 'zoomIn', 'zoomOut', 'reset', 'enhance', 'rotateLeft', 'rotateRight'];
return (
order.find(action => shortcutMatches(e, shortcuts[action], action, { allowAliases: false })) ||
order.find(action => shortcutMatches(e, shortcuts[action], action, { allowAliases: true }))
);
}
function shortcutMatches(e, key, action, opts = {}) {
const pressed = normalizeShortcutKey(e);
if (!key || !pressed) return false;
if (pressed === key) return true;
if (pressed.length === 1 && key.length === 1 && pressed.toLowerCase() === key.toLowerCase()) return true;
if (!opts.allowAliases) return false;
if (action === 'next' && key === 'Space' && pressed === 'ArrowRight') return true;
if (action === 'prev' && key === 'ArrowLeft' && pressed === 'ArrowLeft') return true;
if (action === 'zoomIn' && ((key === '=' && pressed === '+') || (key === '+' && pressed === '='))) return true;
if (action === 'zoomOut' && ((key === '-' && pressed === '_') || (key === '_' && pressed === '-'))) return true;
return false;
}
function handleViewerKeydown(e) {
if (!imageViewer?.visible) return;
const action = getViewerShortcutAction(e);
if (!action) return;
e.preventDefault();
e.stopPropagation();
if (action === 'close') closeImageViewer();
else if (action === 'prev') showViewerImage(imageViewer.index - 1);
else if (action === 'next') showViewerImage(imageViewer.index + 1);
else if (action === 'zoomIn') zoomViewer(1.22);
else if (action === 'zoomOut') zoomViewer(0.82);
else if (action === 'reset') fitViewerImage();
else if (action === 'enhance') toggleViewerEnhancement();
else if (action === 'rotateLeft') rotateViewer(-90);
else if (action === 'rotateRight') rotateViewer(90);
}
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('manage');
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} 条。`;
}
})();