// ==UserScript==
// @name 题库助手 - 保存与历史转发合并版
// @namespace pocketbase-answer-toolkit
// @version 2.2.3
// @description 在教师端统一完成当前工单解答保存到 PocketBase 题库、历史解答读取与转发。
// @author ChatGPT + hyb
// @match https://chath5.kaoshids.com/*
// @match https://chatteacher.kaoshids.com/*
// @connect chatteacher.kaoshids.com
// @connect pocketbase.hellomaggie.top
// @connect 127.0.0.1
// @connect localhost
// @connect *
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
PB_BASE_DEFAULT: 'https://pocketbase.hellomaggie.top',
PB_COLLECTION: 'issue_problems',
TEACHER_API_BASE: 'https://chatteacher.kaoshids.com',
SOCKET_URL: 'wss://chatteacher.kaoshids.com/socket.io/?EIO=4&transport=websocket',
GRADE_FIELD: 'grade',
CHAT_PAGE_SIZE: 100,
OCR_BASE_URL: 'https://pocketbase.hellomaggie.top',
OCR_API_PATH: '/api/xta/ocr',
PROBLEM_MATCH_API_PATH: '/api/xta/problem-matches',
OCR_DRAFTS_KEY: 'pbtOcrDrafts',
OCR_DRAFT_TTL_MS: 24 * 60 * 60 * 1000,
OCR_DRAFT_VERSION: 1,
MAX_OCR_IMAGES: 10,
DEFAULT_DELAY_MS: 800
};
const SUBJECT_NAME_MAP = {
1: '语文',
2: '数学',
3: '英语',
4: '物理',
5: '化学',
6: '生物',
7: '历史',
8: '地理',
9: '政治',
10: '道法',
11: '科学'
};
const state = {
pbBase: GM_getValue('pbBase', GM_getValue('pb_base', CONFIG.PB_BASE_DEFAULT)),
pbToken: GM_getValue('pbToken', ''),
teacherToken: GM_getValue('teacherHttpToken', ''),
teacherTokenSource: '',
delayMs: Number(GM_getValue('delayMs', CONFIG.DEFAULT_DELAY_MS)) || CONFIG.DEFAULT_DELAY_MS,
activeTab: GM_getValue('pbtActiveTab', 'save'),
collapsed: GM_getValue('pbtCollapsed', true),
currentIssueId: null,
currentRoomId: null,
currentIssueInfo: null,
profile: null,
messages: [],
saveImages: [],
existingOrder: null,
existingProblem: null,
existingProblems: [],
ocrResult: null,
ocrRunning: false,
isOcrRunning: false,
matchLoading: false,
matchError: '',
matchCandidates: [],
matchQuery: '',
matchRequestSeq: 0,
selectedMatchId: '',
historyLoadSeq: 0,
saveModalSeq: 0,
draftSaveTimer: null,
lastTeacherResponseCache: {},
oldProblemId: '',
oldProblemRecord: null,
solutionItems: [],
socketToken: '',
ws: null,
namespaceConnected: false,
ackSeq: 0,
ackMap: new Map(),
isSending: false,
stopSending: false
};
let imagePreviewPreviousFocus = null;
GM_addStyle(`
#pbt-panel,
.pbt-mask {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
color: #172033;
}
#pbt-panel *,
.pbt-mask * {
box-sizing: border-box;
}
#pbt-panel {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483645;
width: min(420px, calc(100vw - 24px));
max-height: min(88vh, 760px);
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 8px;
box-shadow: 0 18px 46px rgba(15, 23, 42, 0.2);
overflow: hidden;
}
.pbt-head {
min-height: 56px;
padding: 11px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: linear-gradient(135deg, #1d4ed8, #0f766e);
color: #fff;
user-select: none;
cursor: move;
}
.pbt-title {
font-size: 15px;
line-height: 1.2;
font-weight: 800;
}
.pbt-sub {
margin-top: 3px;
font-size: 12px;
line-height: 1.25;
color: rgba(255, 255, 255, .82);
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pbt-head-actions,
.pbt-actions,
.pbt-btn-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pbt-head-btn {
border: 1px solid rgba(255, 255, 255, .28);
background: rgba(255, 255, 255, .16);
color: #fff;
border-radius: 8px;
padding: 6px 9px;
font-size: 12px;
line-height: 1;
font-weight: 700;
cursor: pointer;
}
.pbt-head-btn:hover {
background: rgba(255, 255, 255, .24);
}
.pbt-body {
max-height: calc(min(88vh, 760px) - 56px);
overflow: auto;
background: #f6f8fb;
padding: 12px;
}
.pbt-hidden {
display: none !important;
}
.pbt-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 10px;
padding: 4px;
border: 1px solid #dde5ee;
border-radius: 8px;
background: #eef3f8;
}
.pbt-tab {
border: none;
border-radius: 6px;
background: transparent;
color: #475569;
padding: 8px 6px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.pbt-tab.active {
background: #ffffff;
color: #0f172a;
box-shadow: 0 1px 3px rgba(15, 23, 42, .08);
}
.pbt-card {
background: #fff;
border: 1px solid #dde5ee;
border-radius: 8px;
padding: 11px;
margin-bottom: 10px;
}
.pbt-card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 9px;
color: #243244;
font-size: 13px;
font-weight: 900;
}
.pbt-small {
font-size: 12px;
line-height: 1.55;
color: #64748b;
word-break: break-word;
}
.pbt-meta {
display: grid;
gap: 5px;
font-size: 12px;
line-height: 1.55;
color: #334155;
word-break: break-word;
}
.pbt-meta b {
color: #111827;
}
.pbt-field {
margin-bottom: 10px;
}
.pbt-label {
display: block;
margin-bottom: 5px;
font-size: 12px;
color: #475569;
font-weight: 800;
}
.pbt-input,
.pbt-select,
.pbt-textarea {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 9px 10px;
color: #111827;
background: #fff;
outline: none;
font-size: 13px;
line-height: 1.4;
}
.pbt-input:focus,
.pbt-select:focus,
.pbt-textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .13);
}
.pbt-textarea {
min-height: 84px;
resize: vertical;
}
.pbt-btn {
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
min-height: 34px;
padding: 7px 11px;
font-size: 13px;
line-height: 1.2;
font-weight: 800;
cursor: pointer;
}
.pbt-btn:hover {
background: #f8fafc;
}
.pbt-btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.pbt-btn-primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.pbt-btn-primary:hover {
background: #1d4ed8;
}
.pbt-btn-success {
border-color: #15803d;
background: #15803d;
color: #fff;
}
.pbt-btn-success:hover {
background: #166534;
}
.pbt-btn-danger {
border-color: #dc2626;
background: #dc2626;
color: #fff;
}
.pbt-btn-warning {
border-color: #f59e0b;
background: #f59e0b;
color: #fff;
}
.pbt-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #eef2f7;
color: #475569;
padding: 3px 8px;
min-height: 22px;
font-size: 12px;
line-height: 1;
font-weight: 800;
white-space: nowrap;
}
.pbt-badge-blue {
background: #e8f1ff;
color: #1d4ed8;
}
.pbt-badge-green {
background: #dcfce7;
color: #166534;
}
.pbt-badge-orange {
background: #fff7ed;
color: #c2410c;
}
.pbt-badge-red {
background: #fee2e2;
color: #b91c1c;
}
.pbt-preview {
display: grid;
gap: 8px;
}
.pbt-history-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 9px;
}
.pbt-history-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 7px;
}
.pbt-check-line {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
font-size: 13px;
color: #334155;
font-weight: 700;
}
.pbt-item-content {
max-height: 120px;
overflow: auto;
border-radius: 8px;
background: #f8fafc;
padding: 7px;
color: #334155;
font-size: 12px;
line-height: 1.55;
word-break: break-word;
white-space: pre-wrap;
}
.pbt-item-content img {
display: block;
max-width: 100%;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.pbt-history-preview-image {
cursor: zoom-in;
}
.pbt-image-preview {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, .88);
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
.pbt-image-preview-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.pbt-image-preview-image {
display: block;
max-width: 94vw;
max-height: 88vh;
object-fit: contain;
border-radius: 8px;
background: #fff;
box-shadow: 0 18px 48px rgba(0, 0, 0, .42);
}
.pbt-image-preview-close {
position: absolute;
top: 0;
right: 0;
z-index: 1;
min-width: 44px;
min-height: 38px;
border: 1px solid rgba(255, 255, 255, .45);
border-radius: 8px;
background: rgba(15, 23, 42, .78);
color: #fff;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.pbt-image-preview-close:hover,
.pbt-image-preview-close:focus-visible {
background: #fff;
color: #0f172a;
outline: none;
}
.pbt-image-preview-status {
position: absolute;
left: 50%;
bottom: 4px;
transform: translateX(-50%);
padding: 5px 10px;
border-radius: 999px;
background: rgba(15, 23, 42, .78);
font-size: 12px;
white-space: nowrap;
}
.pbt-status {
font-size: 12px;
color: #64748b;
font-weight: 800;
white-space: nowrap;
}
.pbt-status.ok {
color: #15803d;
}
.pbt-status.err {
color: #dc2626;
}
.pbt-status.wait {
color: #d97706;
}
.pbt-log {
height: 128px;
overflow: auto;
border-radius: 8px;
background: #111827;
color: #dbeafe;
padding: 9px;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
}
.pbt-empty {
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
padding: 12px;
text-align: center;
font-size: 13px;
line-height: 1.55;
}
.pbt-toast {
position: fixed;
right: 22px;
bottom: 88px;
z-index: 2147483647;
max-width: min(420px, calc(100vw - 36px));
border-radius: 8px;
background: #111827;
color: #fff;
padding: 10px 13px;
font-size: 13px;
line-height: 1.45;
box-shadow: 0 16px 40px rgba(15, 23, 42, .28);
}
.pbt-mask {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, .46);
}
.pbt-modal {
width: min(1120px, 96vw);
height: min(880px, 92vh);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
background: #f6f8fb;
box-shadow: 0 26px 80px rgba(15, 23, 42, .32);
}
.pbt-modal-head,
.pbt-modal-footer {
background: #fff;
border-color: #dde5ee;
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pbt-modal-head {
border-bottom: 1px solid #dde5ee;
}
.pbt-modal-footer {
border-top: 1px solid #dde5ee;
}
.pbt-modal-title {
color: #111827;
font-size: 16px;
font-weight: 900;
line-height: 1.2;
}
.pbt-modal-sub {
margin-top: 3px;
color: #64748b;
font-size: 12px;
line-height: 1.35;
}
.pbt-save-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 370px minmax(0, 1fr);
}
.pbt-save-left,
.pbt-save-right {
min-height: 0;
overflow: auto;
padding: 14px;
}
.pbt-save-left {
border-right: 1px solid #dde5ee;
background: #f6f8fb;
}
.pbt-save-right {
background: #fff;
}
.pbt-grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.pbt-value {
min-height: 32px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 7px 9px;
font-size: 12px;
line-height: 1.45;
color: #334155;
word-break: break-word;
}
.pbt-imgs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pbt-img {
width: 86px;
height: 86px;
border: 1px solid #e2e8f0;
border-radius: 8px;
object-fit: cover;
cursor: zoom-in;
background: #fff;
}
.pbt-ocr-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.pbt-img-card {
width: 168px;
border: 1px solid #dde5ee;
border-radius: 8px;
background: #fff;
padding: 8px;
}
.pbt-img-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 7px;
font-size: 12px;
font-weight: 800;
color: #334155;
}
.pbt-img-card .pbt-img {
width: 100%;
height: 108px;
margin-bottom: 8px;
}
.pbt-ocr-status {
min-height: 22px;
margin: 7px 0;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.pbt-ocr-status.ok {
color: #15803d;
}
.pbt-ocr-status.err {
color: #dc2626;
}
.pbt-ocr-status.wait {
color: #d97706;
}
.pbt-ocr-text {
max-height: 80px;
overflow: auto;
border-radius: 8px;
background: #f8fafc;
padding: 6px;
color: #334155;
font-size: 12px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.pbt-ocr-result-panel {
margin-top: 10px;
border-top: 1px solid #e2e8f0;
padding-top: 10px;
}
.pbt-ocr-result-panel .pbt-textarea {
min-height: 110px;
background: #fbfdff;
}
.pbt-match-list {
display: grid;
gap: 10px;
margin-top: 10px;
}
.pbt-match-card {
border: 1px solid #dbe4ee;
border-radius: 8px;
background: #fff;
padding: 10px;
}
.pbt-match-card.active {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, .1);
}
.pbt-match-problem {
margin: 8px 0;
color: #1e293b;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.pbt-match-images {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.pbt-match-images img {
width: 76px;
height: 76px;
border: 1px solid #e2e8f0;
border-radius: 6px;
object-fit: cover;
cursor: zoom-in;
}
.pbt-match-solution {
margin-top: 12px;
border-top: 1px solid #e2e8f0;
padding-top: 12px;
}
.pbt-crop-mask {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, .58);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
.pbt-crop-panel {
width: min(960px, 96vw);
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
background: #fff;
box-shadow: 0 26px 80px rgba(15, 23, 42, .34);
}
.pbt-crop-head,
.pbt-crop-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-color: #dde5ee;
background: #fff;
}
.pbt-crop-head {
border-bottom: 1px solid #dde5ee;
}
.pbt-crop-footer {
border-top: 1px solid #dde5ee;
}
.pbt-crop-stage-wrap {
overflow: auto;
padding: 14px;
background: #f6f8fb;
}
.pbt-crop-stage {
position: relative;
width: max-content;
max-width: 100%;
margin: 0 auto;
user-select: none;
}
.pbt-crop-img {
display: block;
max-width: min(880px, calc(96vw - 56px));
max-height: 68vh;
border-radius: 8px;
border: 1px solid #dde5ee;
background: #fff;
}
.pbt-crop-box {
position: absolute;
border: 2px solid #2563eb;
background: rgba(37, 99, 235, .12);
box-shadow: 0 0 0 9999px rgba(15, 23, 42, .32);
cursor: move;
}
.pbt-crop-handle {
position: absolute;
right: -7px;
bottom: -7px;
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 50%;
background: #2563eb;
cursor: nwse-resize;
}
.pbt-msg-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.pbt-msg {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 9px;
border: 1px solid #dde5ee;
border-radius: 8px;
background: #fff;
padding: 10px;
cursor: pointer;
}
.pbt-msg.checked {
border-color: #2563eb;
background: #f0f7ff;
}
.pbt-msg.disabled {
opacity: .55;
cursor: not-allowed;
background: #f8fafc;
}
.pbt-msg-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 5px;
color: #64748b;
font-size: 12px;
}
.pbt-msg-content {
color: #111827;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 760px) {
#pbt-panel {
right: 10px;
bottom: 10px;
width: calc(100vw - 20px);
}
.pbt-save-layout {
grid-template-columns: 1fr;
}
.pbt-save-left {
border-right: none;
border-bottom: 1px solid #dde5ee;
}
.pbt-modal-head,
.pbt-modal-footer {
align-items: flex-start;
flex-direction: column;
}
}
`);
function ready(fn) {
if (document.body) {
fn();
return;
}
const observer = new MutationObserver(() => {
if (!document.body) return;
observer.disconnect();
fn();
});
observer.observe(document.documentElement || document, {
childList: true,
subtree: true
});
}
function qs(selector, root = document) {
return root.querySelector(selector);
}
function qsa(selector, root = document) {
return Array.from(root.querySelectorAll(selector));
}
function cleanText(text) {
return String(text || '')
.replace(/\r/g, '')
.replace(/[ \t]+\n/g, '\n')
.trim();
}
function escapeHtml(value) {
return String(value ?? '').replace(/[<>&"']/g, char => ({
'<': '<',
'>': '>',
'&': '&',
'"': '"',
"'": '''
}[char]));
}
function normalizeUrl(url) {
if (!url) return '';
return String(url).trim().replace(/^http:\/\//i, 'https://');
}
function safeJsonParse(text, fallback = null) {
try {
return JSON.parse(text);
} catch (_) {
return fallback;
}
}
function toArray(value) {
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
const parsed = safeJsonParse(value, []);
return Array.isArray(parsed) ? parsed : [];
}
return [];
}
function toNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function formatTime(ts) {
if (!ts) return '';
const numeric = Number(ts);
const date = Number.isFinite(numeric)
? new Date(numeric * 1000)
: new Date(ts);
return Number.isNaN(date.getTime()) ? '' : date.toLocaleString();
}
function toast(text, duration = 2600) {
const old = qs('.pbt-toast');
if (old) old.remove();
const el = document.createElement('div');
el.className = 'pbt-toast';
el.textContent = text;
document.body.appendChild(el);
setTimeout(() => el.remove(), duration);
}
function handleImagePreviewKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault();
closeImagePreview();
}
}
function closeImagePreview() {
const preview = qs('.pbt-image-preview');
if (!preview) return;
preview.remove();
document.removeEventListener('keydown', handleImagePreviewKeydown);
if (imagePreviewPreviousFocus?.isConnected) {
imagePreviewPreviousFocus.focus();
}
imagePreviewPreviousFocus = null;
}
function openImagePreview(imageUrl) {
const url = cleanText(imageUrl);
if (!url) return;
closeImagePreview();
imagePreviewPreviousFocus = document.activeElement;
const preview = document.createElement('div');
preview.className = 'pbt-image-preview';
preview.setAttribute('role', 'dialog');
preview.setAttribute('aria-modal', 'true');
preview.setAttribute('aria-label', '图片预览');
preview.innerHTML = `
图片加载中…
`;
const image = qs('.pbt-image-preview-image', preview);
const status = qs('.pbt-image-preview-status', preview);
const closeButton = qs('.pbt-image-preview-close', preview);
const content = qs('.pbt-image-preview-content', preview);
image.addEventListener('load', () => {
status.textContent = '点击背景或按 Esc 关闭';
}, { once: true });
image.addEventListener('error', () => {
status.textContent = '图片加载失败';
}, { once: true });
image.src = url;
preview.addEventListener('click', event => {
if (event.target === preview || event.target === content || event.target === closeButton) {
closeImagePreview();
}
});
document.body.appendChild(preview);
document.addEventListener('keydown', handleImagePreviewKeydown);
closeButton.focus();
}
function log(message, type = 'info') {
const el = qs('#pbt-log');
if (!el) return;
const prefix = {
info: '信息',
success: '成功',
warn: '提醒',
error: '错误'
}[type] || '信息';
const time = new Date().toLocaleTimeString();
el.textContent += `[${time}] ${prefix}:${message}\n`;
el.scrollTop = el.scrollHeight;
}
function saveLog(message, type = 'info') {
const el = qs('#pbt-save-log');
if (el) {
if (el.textContent === '等待操作...') el.textContent = '';
el.textContent += `${message}\n`;
el.scrollTop = el.scrollHeight;
}
log(message, type);
}
function getIssueIdFromUrl() {
const text = String(location.href || '');
const match =
text.match(/issueId[=/](\d+)/i) ||
text.match(/issue_id[=/](\d+)/i) ||
text.match(/issue\/(\d+)/i);
return match ? Number(match[1]) : null;
}
function getUrlSubjectId() {
const text = String(location.href || '');
const match =
text.match(/subjectId[=/](\d+)/i) ||
text.match(/subject_id[=/](\d+)/i);
return match ? Number(match[1]) : 0;
}
function decodeJwtPayload(token) {
try {
const raw = String(token || '').replace(/^Bearer\s+/i, '');
const parts = raw.split('.');
if (parts.length < 2) return null;
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(
atob(payload)
.split('')
.map(char => '%' + ('00' + char.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(json);
} catch (_) {
return null;
}
}
function collectJwtCandidates() {
const root = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const stores = [];
try { stores.push(root.localStorage); } catch (_) {}
try { stores.push(root.sessionStorage); } catch (_) {}
try { stores.push(window.localStorage); } catch (_) {}
try { stores.push(window.sessionStorage); } catch (_) {}
const result = [];
for (const store of stores) {
if (!store) continue;
for (let index = 0; index < store.length; index++) {
const key = store.key(index);
const value = store.getItem(key) || '';
const matches = value.match(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g);
if (!matches) continue;
for (const token of matches) {
result.push({
key,
token,
payload: decodeJwtPayload(token)
});
}
}
}
return result;
}
function isExpiredJwtPayload(payload) {
if (!payload || !payload.exp) return false;
return Number(payload.exp) < Math.floor(Date.now() / 1000) - 60;
}
function normalizeToken(token) {
return String(token || '').replace(/^Bearer\s+/i, '').trim();
}
function scoreJwtCandidate(candidate) {
const key = String(candidate.key || '');
const payload = candidate.payload || {};
let score = 0;
if (!candidate.token || isExpiredJwtPayload(payload)) return -1;
if (payload.teacherId || payload.teacher_id) score += 120;
if (payload.memberId || payload.member_id) score += 80;
if (Number(payload.memberType ?? payload.member_type) === 1) score += 40;
if (/teacher/i.test(key)) score += 25;
if (/token|auth|authorization|access/i.test(key)) score += 10;
if (payload.exp) score += 5;
if (payload.studentId || payload.student_id) score -= 100;
return score;
}
function getPageTeacherTokenCandidate() {
return collectJwtCandidates()
.map(candidate => ({
...candidate,
score: scoreJwtCandidate(candidate)
}))
.filter(candidate => candidate.score > 0)
.sort((a, b) => b.score - a.score)[0] || null;
}
function applyTeacherToken(token, source = 'auto') {
const cleaned = normalizeToken(token);
if (!cleaned) return '';
if (isExpiredJwtPayload(decodeJwtPayload(cleaned))) return '';
state.teacherToken = cleaned;
state.teacherTokenSource = source;
GM_setValue('teacherHttpToken', state.teacherToken);
const input = qs('#pbt-teacher-token');
if (input) input.value = state.teacherToken;
const sourceEl = qs('#pbt-token-source');
if (sourceEl) {
const payload = decodeJwtPayload(state.teacherToken);
const teacherId = payload?.teacherId || payload?.teacher_id || payload?.memberId || payload?.member_id || '';
const label =
source === 'saved' ? '已保存 Token' :
source === 'manual' ? '手动 Token' :
source || '自动获取';
sourceEl.textContent = `已获取 Token${teacherId ? `,ID=${teacherId}` : ''},来源:${label}`;
}
return state.teacherToken;
}
function autoRefreshTeacherTokenFromPage() {
const candidate = getPageTeacherTokenCandidate();
if (!candidate?.token) return '';
const previous = state.teacherToken;
const token = applyTeacherToken(candidate.token, `storage:${candidate.key || 'unknown'}`);
if (token && token !== previous) {
log(`已自动获取当前页面教师 Token(来源:${candidate.key || 'storage'})`, 'success');
}
return token;
}
function readSettingsFromPanel() {
const pbBase = cleanText(qs('#pbt-pb-base')?.value || state.pbBase || CONFIG.PB_BASE_DEFAULT);
const pbToken = cleanText(qs('#pbt-pb-token')?.value || state.pbToken || '');
const teacherToken = cleanText(qs('#pbt-teacher-token')?.value || state.teacherToken || '');
const delayMs = Math.max(300, Number(qs('#pbt-delay')?.value || state.delayMs || CONFIG.DEFAULT_DELAY_MS));
const previousTeacherToken = state.teacherToken;
const nextTeacherToken = normalizeToken(teacherToken);
state.pbBase = pbBase;
state.pbToken = pbToken.replace(/^Bearer\s+/i, '');
state.teacherToken = nextTeacherToken;
if (!state.teacherToken) {
state.teacherTokenSource = '';
} else if (state.teacherToken !== previousTeacherToken) {
state.teacherTokenSource = 'manual';
}
state.delayMs = delayMs;
GM_setValue('pbBase', state.pbBase);
GM_setValue('pb_base', state.pbBase);
GM_setValue('pbToken', state.pbToken);
GM_setValue('teacherHttpToken', state.teacherToken);
GM_setValue('delayMs', state.delayMs);
}
function findTeacherToken() {
if (/header/i.test(state.teacherTokenSource || '') && state.teacherToken && state.teacherToken.length > 40) {
return applyTeacherToken(state.teacherToken, state.teacherTokenSource);
}
const pageToken = autoRefreshTeacherTokenFromPage();
if (pageToken) return pageToken;
if (state.teacherToken && state.teacherToken.length > 40) {
return applyTeacherToken(state.teacherToken, 'manual');
}
const saved = GM_getValue('teacherHttpToken', '');
if (saved && saved.length > 40) {
return applyTeacherToken(saved, 'saved');
}
return '';
}
function ensureTeacherToken(ask = false) {
const token = findTeacherToken();
if (token) return token;
if (!ask) return '';
const manual = prompt('没有自动找到教师端 Token,请粘贴 Bearer Token:') || '';
return applyTeacherToken(manual, 'manual');
}
function readAuthorizationFromHeaders(headers) {
if (!headers) return '';
try {
if (typeof headers.get === 'function') {
return headers.get('Authorization') || headers.get('authorization') || '';
}
if (Array.isArray(headers)) {
const found = headers.find(item => String(item?.[0] || '').toLowerCase() === 'authorization');
return found?.[1] || '';
}
if (typeof headers === 'object') {
return headers.Authorization || headers.authorization || '';
}
} catch (_) {}
return '';
}
function normalizeTeacherResponseSoft(json) {
if (typeof json === 'string') {
json = safeJsonParse(json, null);
}
if (!json) return null;
if (json.errcode === 1) return json.data;
if (json.data && json.data.errcode === 1) return json.data.data;
return null;
}
function rememberTeacherApiResponse(url, text) {
url = String(url || '');
if (!url.includes('/apis/teacher/')) return;
const json = typeof text === 'string' ? safeJsonParse(text, null) : text;
const data = normalizeTeacherResponseSoft(json);
if (!data) return;
if (url.includes('/apis/teacher/profile')) {
state.lastTeacherResponseCache.profile = data;
}
const issueMatch = url.match(/\/apis\/teacher\/issue\/(\d+)/);
if (issueMatch) {
state.lastTeacherResponseCache.issue = data;
state.lastTeacherResponseCache[`issue:${issueMatch[1]}`] = data;
}
if (url.includes('/apis/teacher/chat-message')) {
const roomMatch = url.match(/[?&]room_id=(\d+)/i);
state.lastTeacherResponseCache.chatMessage = data;
if (roomMatch) {
state.lastTeacherResponseCache[`chatMessage:${roomMatch[1]}`] = data;
}
}
}
function installRequestInterceptor() {
const root = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
try {
const rawFetch = root.fetch;
if (typeof rawFetch === 'function' && !rawFetch.__pbtPatched) {
const patchedFetch = function (...args) {
try {
const input = args[0];
const init = args[1] || {};
const url = typeof input === 'string' ? input : input?.url || '';
const auth = readAuthorizationFromHeaders(init.headers) || readAuthorizationFromHeaders(input?.headers);
if (auth) applyTeacherToken(auth, 'fetch-header');
return rawFetch.apply(this, args).then(async response => {
try {
const cloned = response.clone();
const text = await cloned.text();
rememberTeacherApiResponse(url, text);
} catch (_) {}
return response;
});
} catch (_) {
return rawFetch.apply(this, args);
}
};
patchedFetch.__pbtPatched = true;
root.fetch = patchedFetch;
}
} catch (_) {}
try {
const XHR = root.XMLHttpRequest;
if (XHR && XHR.prototype && !XHR.prototype.__pbtPatched) {
const rawOpen = XHR.prototype.open;
const rawSetHeader = XHR.prototype.setRequestHeader;
const rawSend = XHR.prototype.send;
XHR.prototype.open = function (method, url) {
this.__pbtUrl = url;
return rawOpen.apply(this, arguments);
};
XHR.prototype.setRequestHeader = function (name, value) {
if (String(name || '').toLowerCase() === 'authorization') {
applyTeacherToken(value, 'xhr-header');
}
return rawSetHeader.apply(this, arguments);
};
XHR.prototype.send = function () {
this.addEventListener('load', function () {
try {
rememberTeacherApiResponse(this.__pbtUrl, this.responseText);
} catch (_) {}
});
return rawSend.apply(this, arguments);
};
XHR.prototype.__pbtPatched = true;
}
} catch (_) {}
}
function gmRequestJson(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
headers: options.headers || {},
data: options.data === undefined ? undefined : JSON.stringify(options.data),
timeout: options.timeout || 30000,
onload: response => {
const text = response.responseText || '';
let json = null;
if (response.response && typeof response.response === 'object') {
json = response.response;
} else if (typeof response.response === 'string' && response.response.trim()) {
json = safeJsonParse(response.response, null);
}
if (!json && text) {
json = safeJsonParse(text, null);
}
if (response.status < 200 || response.status >= 300) {
reject(new Error(`HTTP ${response.status}: ${options.url}\n${text.slice(0, 500)}`));
return;
}
if (!json) {
reject(new Error(`返回不是 JSON:${options.url}\n${text.slice(0, 500)}`));
return;
}
resolve(json);
},
onerror: err => reject(new Error(`网络请求失败:${options.url}\n${JSON.stringify(err)}`)),
ontimeout: () => reject(new Error('请求超时:' + options.url))
});
});
}
function normalizeTeacherResponse(json) {
if (typeof json === 'string') {
json = safeJsonParse(json, json);
}
if (json && json.errcode === 1) return json.data;
if (json && json.data && json.data.errcode === 1) return json.data.data;
throw new Error('教师端接口返回异常:' + JSON.stringify(json).slice(0, 500));
}
function getTeacherCache(cacheKey) {
const keys = Array.isArray(cacheKey)
? cacheKey
: String(cacheKey || '').split(',').map(key => key.trim()).filter(Boolean);
for (const key of keys) {
if (state.lastTeacherResponseCache[key]) {
return {
key,
data: state.lastTeacherResponseCache[key]
};
}
}
return null;
}
async function teacherGet(path, options = {}) {
const normalizedOptions = typeof options === 'object'
? options
: { askForToken: Boolean(options) };
const askForToken = Boolean(normalizedOptions.askForToken);
const allowCache = normalizedOptions.allowCache !== false;
const cacheKey = normalizedOptions.cacheKey || '';
const token = ensureTeacherToken(askForToken);
if (!token) {
const cached = allowCache ? getTeacherCache(cacheKey) : null;
if (cached) {
log(`没有 Token,使用页面缓存:${cached.key}`, 'warn');
return cached.data;
}
throw new Error('没有教师端 Token,无法请求教师端接口。');
}
try {
const raw = await gmRequestJson({
method: 'GET',
url: `${CONFIG.TEACHER_API_BASE}${path}`,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const data = normalizeTeacherResponse(raw);
rememberTeacherApiResponse(path, raw);
return data;
} catch (err) {
const message = err.message || String(err);
if (/HTTP 401|HTTP 403|未认证/i.test(message)) {
GM_setValue('teacherHttpToken', '');
state.teacherToken = '';
state.teacherTokenSource = '';
}
if (/HTTP 0|HTTP 401|HTTP 403|未认证|status"?\s*:0|网络请求失败/i.test(message)) {
const refreshed = findTeacherToken();
if (refreshed && refreshed !== token) {
const raw = await gmRequestJson({
method: 'GET',
url: `${CONFIG.TEACHER_API_BASE}${path}`,
headers: {
Authorization: `Bearer ${refreshed}`,
'Content-Type': 'application/json'
}
});
const data = normalizeTeacherResponse(raw);
rememberTeacherApiResponse(path, raw);
return data;
}
}
const cached = allowCache ? getTeacherCache(cacheKey) : null;
if (cached) {
log(`接口失败,使用页面缓存:${cached.key}`, 'warn');
return cached.data;
}
throw err;
}
}
function pbBase() {
return String(state.pbBase || CONFIG.PB_BASE_DEFAULT).replace(/\/+$/, '');
}
function pbHeaders() {
const headers = {
'Content-Type': 'application/json'
};
if (state.pbToken) {
headers.Authorization = `Bearer ${state.pbToken.replace(/^Bearer\s+/i, '')}`;
}
return headers;
}
function pbAuthHeaders() {
const headers = {};
if (state.pbToken) {
headers.Authorization = `Bearer ${state.pbToken.replace(/^Bearer\s+/i, '')}`;
}
return headers;
}
function parseGmJsonResponse(response) {
const text = response.responseText || '';
let json = null;
if (response.response && typeof response.response === 'object' && !(response.response instanceof Blob)) {
json = response.response;
} else if (typeof response.response === 'string' && response.response.trim()) {
json = safeJsonParse(response.response, null);
}
if (!json && text) {
json = safeJsonParse(text, null);
}
return json;
}
function gmFormJson({ method = 'POST', url, headers = {}, data, timeout = 60000 }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
data,
timeout,
onload: response => {
const text = response.responseText || '';
const json = parseGmJsonResponse(response);
if (response.status < 200 || response.status >= 300) {
reject(new Error(`HTTP ${response.status}: ${text.slice(0, 500)}`));
return;
}
if (!json) {
reject(new Error(`返回不是 JSON:${text.slice(0, 500)}`));
return;
}
resolve(json);
},
onerror: err => reject(new Error('网络请求失败:' + JSON.stringify(err))),
ontimeout: () => reject(new Error('请求超时:' + url))
});
});
}
function gmRequestFormData(options) {
return gmFormJson(options);
}
function gmGetBlob(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'blob',
timeout: 30000,
onload: response => {
if (response.status < 200 || response.status >= 300) {
reject(new Error(`图片读取失败:HTTP ${response.status}`));
return;
}
if (response.response instanceof Blob) {
resolve(response.response);
return;
}
reject(new Error('图片读取失败:返回内容不是 Blob'));
},
onerror: err => reject(new Error('图片读取失败:' + JSON.stringify(err))),
ontimeout: () => reject(new Error('图片读取超时'))
});
});
}
async function pbGetList(collection, filter) {
const url = `${pbBase()}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}`;
const json = await gmRequestJson({
method: 'GET',
url,
headers: pbHeaders()
});
return json.items || [];
}
async function pbGetOne(collection, id) {
return gmRequestJson({
method: 'GET',
url: `${pbBase()}/api/collections/${collection}/records/${encodeURIComponent(id)}`,
headers: pbHeaders()
});
}
async function pbCreate(collection, payload) {
return gmRequestJson({
method: 'POST',
url: `${pbBase()}/api/collections/${collection}/records`,
headers: pbHeaders(),
data: payload
});
}
async function pbUpdate(collection, id, payload) {
return gmRequestJson({
method: 'PATCH',
url: `${pbBase()}/api/collections/${collection}/records/${id}`,
headers: pbHeaders(),
data: payload
});
}
function syncSavePbBase() {
const inputValue = cleanText(qs('#pbt-save-pb-base')?.value || '');
const panelValue = cleanText(qs('#pbt-pb-base')?.value || '');
const next = inputValue || panelValue || state.pbBase || CONFIG.PB_BASE_DEFAULT;
state.pbBase = next;
GM_setValue('pbBase', state.pbBase);
GM_setValue('pb_base', state.pbBase);
return state.pbBase;
}
async function ocrHealthCheck() {
syncSavePbBase();
return gmRequestJson({
method: 'GET',
url: `${CONFIG.OCR_BASE_URL}${CONFIG.OCR_API_PATH}/health`,
headers: pbHeaders(),
timeout: 20000
});
}
async function pbOcrByFile(blob) {
syncSavePbBase();
const form = new FormData();
form.append('file', blob, 'question.jpg');
const json = await gmRequestFormData({
method: 'POST',
url: `${CONFIG.OCR_BASE_URL}${CONFIG.OCR_API_PATH}`,
headers: pbAuthHeaders(),
data: form,
timeout: 90000
});
if (!json.ok) {
throw new Error(json.errmsg || 'OCR 识别失败');
}
return json;
}
async function pbOcrByFiles(blobs) {
syncSavePbBase();
if (!Array.isArray(blobs) || !blobs.length) {
throw new Error('请至少选择一张题目图片。');
}
if (blobs.length > CONFIG.MAX_OCR_IMAGES) {
throw new Error(`一次最多识别 ${CONFIG.MAX_OCR_IMAGES} 张题图。`);
}
const form = new FormData();
blobs.forEach((blob, index) => {
const extension = blob.type === 'image/png'
? 'png'
: blob.type === 'image/webp'
? 'webp'
: 'jpg';
form.append('files', blob, `question-${index + 1}.${extension}`);
});
const json = await gmRequestFormData({
method: 'POST',
url: `${CONFIG.OCR_BASE_URL}${CONFIG.OCR_API_PATH}`,
headers: pbAuthHeaders(),
data: form,
timeout: 120000
});
if (!json.ok) {
throw new Error(json.errmsg || 'OCR 识别失败');
}
return json;
}
async function fetchProblemMatches(problemText) {
const json = await gmRequestJson({
method: 'POST',
url: `${CONFIG.OCR_BASE_URL}${CONFIG.PROBLEM_MATCH_API_PATH}`,
headers: {
...pbAuthHeaders(),
'Content-Type': 'application/json'
},
data: {
problem_text: problemText,
limit: 5
},
timeout: 30000
});
if (!json.ok) {
throw new Error(json.errmsg || '历史题目匹配失败');
}
return Array.isArray(json.matches) ? json.matches : [];
}
async function ocrImageUrl(imageUrl) {
return pbOcrByFile(await gmGetBlob(imageUrl));
}
async function ocrImageBlob(blob) {
return pbOcrByFile(blob);
}
function isAiMessage(message) {
return (
message?.msg_type === 'ai' ||
message?.sender_id === 1 ||
message?.teacher_sender?.nickname === 'AI'
);
}
function isTeacherTemplateMessage(message) {
const body = message?.msg_body || '';
return /老师已经收到你的题目|15-30\s*分钟|转接人工|已为你转接|请你检查并补充完整|会在\s*15/.test(body);
}
function isValidSolutionMessage(message) {
if (!message) return false;
if (!['text', 'photo', 'voice', 'video'].includes(message.msg_type)) return false;
if (isAiMessage(message)) return false;
return true;
}
function isDefaultChecked(message) {
if (!isValidSolutionMessage(message)) return false;
if (message.sender_type !== 1) return false;
if (isTeacherTemplateMessage(message)) return false;
return true;
}
function getSenderName(message) {
if (isAiMessage(message)) return 'AI';
if (message.sender_type === 1) return message.teacher_sender?.nickname || `老师${message.sender_id || ''}`;
if (message.sender_type === 2) return message.student_sender?.nickname || `学生${message.sender_id || ''}`;
return `发送者${message.sender_id || ''}`;
}
function getTypeName(type) {
return {
text: '文字',
photo: '图片',
voice: '语音',
video: '视频',
ai: 'AI'
}[type] || type || '未知';
}
function previewBody(message) {
let body = message.msg_body || '';
if (message.msg_type === 'photo') body = '[图片] ' + body;
if (message.msg_type === 'voice') body = '[语音] ' + body;
if (message.msg_type === 'video') body = '[视频] ' + body;
if (message.msg_type === 'ai') body = '[AI] ' + body;
if (body.length > 160) body = body.slice(0, 160) + '...';
return body;
}
function inferSubject(issue, profile) {
const urlSubjectId = getUrlSubjectId();
let subjectId =
toNumber(issue?.subject_id, 0) ||
toNumber(issue?.subjectId, 0) ||
urlSubjectId ||
0;
let subjectName = cleanText(issue?.subject_name || issue?.subjectName || '');
const profileSubjects = Array.isArray(profile?.subjects) ? profile.subjects : [];
if (!subjectId && profileSubjects.length === 1) {
subjectId = toNumber(profileSubjects[0].subject_id || profileSubjects[0].id, 0);
}
if (!subjectName && subjectId) {
const matched = profileSubjects.find(item => {
return toNumber(item.subject_id || item.id, 0) === subjectId;
});
subjectName = cleanText(matched?.subject_name || matched?.name || '') || SUBJECT_NAME_MAP[subjectId] || '';
}
if (!subjectName && profileSubjects.length === 1) {
subjectName = cleanText(profileSubjects[0].subject_name || profileSubjects[0].name || '');
}
if (!subjectName && subjectId) {
subjectName = SUBJECT_NAME_MAP[subjectId] || '';
}
return {
subject_id: subjectId,
subject_name: subjectName
};
}
function getTeacherId(issue, messages, profile = state.profile) {
const fromIssue =
issue?.assignments?.[0]?.teacher_id ||
issue?.teacher_id ||
issue?.teacherId;
if (fromIssue) return toNumber(fromIssue, 0);
const fromMessage = messages.find(message => message.sender_type === 1 && !isAiMessage(message) && message.sender_id)?.sender_id;
if (fromMessage) return toNumber(fromMessage, 0);
const payload = decodeJwtPayload(state.teacherToken);
return (
toNumber(
payload?.teacherId ||
payload?.teacher_id ||
payload?.memberId ||
payload?.member_id ||
profile?.teacher_id ||
profile?.id,
0
) ||
0
);
}
function getTeacherName(messages, profile) {
const message = messages.find(item => item.sender_type === 1 && !isAiMessage(item) && item.teacher_sender?.nickname);
return cleanText(message?.teacher_sender?.nickname) || cleanText(profile?.nickname) || cleanText(profile?.real_name) || '';
}
function buildProblemImages(issue, messages) {
const result = [];
const seen = new Set();
function add(url) {
if (!url) return;
const normalized = normalizeUrl(url);
if (seen.has(normalized)) return;
seen.add(normalized);
result.push({
index: result.length + 1,
msg_type: 'photo',
msg_body: normalized
});
}
add(issue?.issue_pic);
messages
.filter(message => message.sender_type === 2 && message.msg_type === 'photo' && message.msg_body)
.forEach(message => add(message.msg_body));
return result;
}
function readOcrDrafts() {
const raw = GM_getValue(CONFIG.OCR_DRAFTS_KEY, {});
const drafts = raw && typeof raw === 'object' && !Array.isArray(raw)
? { ...raw }
: {};
const now = Date.now();
let changed = false;
Object.keys(drafts).forEach(issueId => {
const draft = drafts[issueId];
if (
!draft ||
draft.version !== CONFIG.OCR_DRAFT_VERSION ||
Number(draft.expiresAt || 0) <= now
) {
delete drafts[issueId];
changed = true;
}
});
if (changed) {
GM_setValue(CONFIG.OCR_DRAFTS_KEY, drafts);
}
return drafts;
}
function getCurrentOcrDraft(issueIdValue) {
const issueId = String(issueIdValue || state.currentIssueId || state.currentIssueInfo?.issue_id || '');
if (!issueId) return null;
return readOcrDrafts()[issueId] || null;
}
function saveCurrentOcrDraft(issueIdValue) {
const issueId = String(issueIdValue || state.currentIssueId || state.currentIssueInfo?.issue_id || '');
const problemTextarea = qs('#pbt-save-problem-text');
const problemText = cleanText(
problemTextarea
? problemTextarea.value
: state.ocrResult?.problem_text || ''
);
if (!issueId) return;
const now = Date.now();
const selectedImages = getSelectedOcrImages().map(({ image, index }) => ({
index,
url: image.msg_body
}));
const drafts = readOcrDrafts();
if (!problemText && !drafts[issueId]) return;
drafts[issueId] = {
version: CONFIG.OCR_DRAFT_VERSION,
issueId,
problemText,
problemLatex: cleanText(
state.ocrResult?.problem_latex ||
state.ocrResult?.raw_text ||
''
),
rawText: cleanText(state.ocrResult?.raw_text || ''),
images: selectedImages,
updatedAt: now,
expiresAt: now + CONFIG.OCR_DRAFT_TTL_MS
};
GM_setValue(CONFIG.OCR_DRAFTS_KEY, drafts);
}
function scheduleCurrentOcrDraftSave() {
clearTimeout(state.draftSaveTimer);
const issueId = String(state.currentIssueId || state.currentIssueInfo?.issue_id || '');
const modalSeq = state.saveModalSeq;
state.draftSaveTimer = setTimeout(() => {
state.draftSaveTimer = null;
if (isActiveSaveContext(issueId, modalSeq)) {
saveCurrentOcrDraft(issueId);
}
}, 400);
}
function deleteCurrentOcrDraft(issueIdValue) {
clearTimeout(state.draftSaveTimer);
state.draftSaveTimer = null;
const issueId = String(issueIdValue || state.currentIssueId || state.currentIssueInfo?.issue_id || '');
if (!issueId) return;
const drafts = readOcrDrafts();
if (!drafts[issueId]) return;
delete drafts[issueId];
GM_setValue(CONFIG.OCR_DRAFTS_KEY, drafts);
}
function isActiveSaveContext(issueId, modalSeq) {
const textarea = qs('.pbt-mask #pbt-save-problem-text');
return Boolean(
textarea &&
String(state.currentIssueId || state.currentIssueInfo?.issue_id || '') === String(issueId) &&
state.saveModalSeq === modalSeq
);
}
function restoreCurrentOcrDraft() {
const draft = getCurrentOcrDraft();
if (!draft) return null;
state.ocrResult = {
problem_text: draft.problemText,
problem_latex: draft.problemLatex,
raw_text: draft.rawText || draft.problemLatex
};
const selectedKeys = new Set(
(Array.isArray(draft.images) ? draft.images : [])
.map(item => `${Number(item.index)}|${normalizeUrl(item.url || '')}`)
);
qsa('.pbt-save-image-check').forEach(input => {
const index = Number(input.dataset.index);
const image = getSaveImageByIndex(index);
input.checked = selectedKeys.size
? selectedKeys.has(`${index}|${normalizeUrl(image?.msg_body || '')}`)
: input.checked;
});
const textarea = qs('#pbt-save-problem-text');
if (textarea) textarea.value = draft.problemText;
const combined = qs('#pbt-ocr-combined');
if (combined) combined.value = draft.problemText;
updateSelectedImageCount();
const countEl = qs('#pbt-ocr-done-count');
if (countEl) countEl.textContent = String(draft.images?.length || 0);
return draft;
}
function initSaveImages(issue, messages) {
state.ocrResult = null;
state.ocrRunning = false;
state.isOcrRunning = false;
state.matchLoading = false;
state.matchError = '';
state.matchCandidates = [];
state.matchQuery = '';
state.selectedMatchId = '';
state.matchRequestSeq += 1;
state.historyLoadSeq += 1;
state.saveImages = buildProblemImages(issue, messages).map(image => ({
...image,
selected: true,
ocr: null,
ocrStatus: 'idle',
ocrStatusText: '未识别',
ocrError: ''
}));
return state.saveImages;
}
function getSaveImageByIndex(index) {
return state.saveImages[Number(index)] || null;
}
function getSelectedProblemImages() {
const selected = qsa('.pbt-save-image-check:checked')
.map(input => getSaveImageByIndex(input.dataset.index))
.filter(Boolean);
return selected.map((image, index) => ({
index: index + 1,
msg_type: 'photo',
msg_body: image.msg_body
}));
}
function getSelectedOcrImages() {
return qsa('.pbt-save-image-check:checked')
.map(input => ({
image: getSaveImageByIndex(input.dataset.index),
index: Number(input.dataset.index)
}))
.filter(item => item.image);
}
function getOcrText(image, field = 'problem') {
if (!image?.ocr) return '';
if (field === 'latex') {
return cleanText(image.ocr.raw_text || image.ocr.problem_latex || image.ocr.problem_text || '');
}
if (field === 'raw') {
return cleanText(image.ocr.raw_text || image.ocr.problem_latex || image.ocr.problem_text || '');
}
return cleanText(image.ocr.problem_text || image.ocr.raw_text || '');
}
function getCombinedOcrText(field = 'problem') {
if (state.ocrResult) {
if (field === 'problem') {
return cleanText(state.ocrResult.problem_text || state.ocrResult.raw_text || '');
}
if (field === 'latex') {
return cleanText(state.ocrResult.problem_latex || state.ocrResult.raw_text || '');
}
return cleanText(state.ocrResult.raw_text || state.ocrResult.problem_latex || state.ocrResult.problem_text || '');
}
const recognized = (state.saveImages || [])
.filter(image => image.ocr && getOcrText(image, field))
.sort((a, b) => Number(a.index || 0) - Number(b.index || 0));
if (!recognized.length) return '';
return recognized.map(image => {
const text = getOcrText(image, field);
return recognized.length > 1
? `[图片${image.index}]\n${text}`
: text;
}).join('\n\n');
}
function updateSelectedImageCount() {
const count = qsa('.pbt-save-image-check:checked').length;
const countEl = qs('#pbt-save-image-count');
if (countEl) countEl.textContent = String(count);
}
function updateOcrCombinedPreview() {
if (!state.ocrResult) {
state.ocrResult = {
problem_text: getCombinedOcrText('problem'),
raw_text: getCombinedOcrText('raw'),
problem_latex: getCombinedOcrText('latex')
};
}
const textarea = qs('#pbt-ocr-combined');
if (textarea) textarea.value = state.ocrResult.problem_text;
const count = (state.saveImages || []).filter(image => image.ocrStatus === 'done').length;
const countEl = qs('#pbt-ocr-done-count');
if (countEl) countEl.textContent = String(count);
}
function updateOcrImageView(index) {
const image = getSaveImageByIndex(index);
if (!image) return;
const statusEl = qs(`#pbt-ocr-status-${index}`);
if (statusEl) {
statusEl.className = `pbt-ocr-status ${image.ocrStatus === 'done' ? 'ok' : image.ocrStatus === 'error' ? 'err' : image.ocrStatus === 'working' ? 'wait' : ''}`;
statusEl.textContent = image.ocrStatusText || '未识别';
}
const textEl = qs(`#pbt-ocr-text-${index}`);
if (textEl) {
const text = getOcrText(image, 'problem');
textEl.textContent = text;
textEl.classList.toggle('pbt-hidden', !text);
}
updateOcrCombinedPreview();
}
function clearOcrResults() {
(state.saveImages || []).forEach((image, index) => {
image.ocr = null;
image.ocrStatus = 'idle';
image.ocrStatusText = '未识别';
image.ocrError = '';
updateOcrImageView(index);
});
state.ocrResult = null;
const combined = qs('#pbt-ocr-combined');
if (combined) combined.value = '';
const countEl = qs('#pbt-ocr-done-count');
if (countEl) countEl.textContent = '0';
state.matchCandidates = [];
state.matchError = '';
state.matchQuery = '';
state.selectedMatchId = '';
state.matchRequestSeq += 1;
state.historyLoadSeq += 1;
state.oldProblemRecord = null;
state.solutionItems = [];
const solutionContainer = qs('#pbt-match-solution');
if (solutionContainer) {
solutionContainer.classList.add('pbt-hidden');
solutionContainer.innerHTML = '';
}
renderProblemMatches();
}
function fillProblemTextFromOcr(mode = 'overwrite') {
const text = getCombinedOcrText('problem');
const textarea = qs('#pbt-save-problem-text');
if (!text) {
toast('还没有可填入的 OCR 结果。');
return;
}
if (!textarea) return;
if (mode === 'append' && cleanText(textarea.value)) {
textarea.value = cleanText(textarea.value) + '\n\n' + text;
} else {
textarea.value = text;
}
textarea.dispatchEvent(new Event('input', { bubbles: true }));
toast(mode === 'append' ? '已追加 OCR 结果' : '已覆盖题干为 OCR 结果');
}
async function runOcrForImage(index, options = {}) {
const image = getSaveImageByIndex(index);
if (!image) return null;
const issueId = String(state.currentIssueId || state.currentIssueInfo?.issue_id || '');
const modalSeq = state.saveModalSeq;
image.ocrStatus = 'working';
image.ocrStatusText = options.blob ? '正在识别裁剪图...' : '正在识别原图...';
image.ocrError = '';
updateOcrImageView(index);
try {
const json = options.blob
? await ocrImageBlob(options.blob)
: await ocrImageUrl(image.msg_body);
if (!isActiveSaveContext(issueId, modalSeq)) return null;
(state.saveImages || []).forEach((item, itemIndex) => {
item.ocr = null;
if (itemIndex !== index && item.ocrStatus === 'done') {
item.ocrStatus = 'idle';
item.ocrStatusText = '未识别';
}
});
state.ocrResult = json;
image.ocr = json;
image.ocrStatus = 'done';
image.ocrStatusText = `识别完成:${cleanText(json.problem_text || json.raw_text || '').length} 字`;
saveLog(`OCR 图片${image.index}完成`, 'success');
updateOcrImageView(index);
return json;
} catch (err) {
if (!isActiveSaveContext(issueId, modalSeq)) return null;
image.ocr = null;
image.ocrStatus = 'error';
image.ocrError = err.message || String(err);
image.ocrStatusText = '识别失败:' + image.ocrError;
saveLog(`OCR 图片${image.index}失败:${image.ocrError}`, 'error');
updateOcrImageView(index);
throw err;
}
}
async function runOcrForSelectedImages(button) {
const selected = getSelectedOcrImages();
const issueId = String(state.currentIssueId || state.currentIssueInfo?.issue_id || '');
const modalSeq = state.saveModalSeq;
if (!selected.length) {
toast('请先勾选要识别的题目图片。');
return;
}
if (selected.length > CONFIG.MAX_OCR_IMAGES) {
toast(`一次最多识别 ${CONFIG.MAX_OCR_IMAGES} 张题图。`);
return;
}
state.ocrRunning = true;
state.isOcrRunning = true;
if (button) {
button.disabled = true;
button.textContent = '识别中...';
}
try {
selected.forEach(({ image, index }) => {
image.ocr = null;
image.ocrStatus = 'working';
image.ocrStatusText = '正在合并识别...';
image.ocrError = '';
updateOcrImageView(index);
});
const blobs = await Promise.all(
selected.map(({ image }) => gmGetBlob(image.msg_body))
);
if (!isActiveSaveContext(issueId, modalSeq)) return;
const json = await pbOcrByFiles(blobs);
if (!isActiveSaveContext(issueId, modalSeq)) return;
(state.saveImages || []).forEach(image => {
image.ocr = null;
if (image.ocrStatus === 'done') {
image.ocrStatus = 'idle';
image.ocrStatusText = '未识别';
}
});
state.ocrResult = json;
selected.forEach(({ image, index }, selectedIndex) => {
image.ocr = selectedIndex === 0 ? json : null;
image.ocrStatus = 'done';
image.ocrStatusText = `合并识别完成:${cleanText(json.problem_text || json.raw_text || '').length} 字`;
updateOcrImageView(index);
});
fillProblemTextFromOcr('overwrite');
saveCurrentOcrDraft(issueId);
await runProblemMatch(getCombinedOcrText('problem'));
saveLog(`OCR 已选 ${selected.length} 张图片完成`, 'success');
} catch (err) {
if (!isActiveSaveContext(issueId, modalSeq)) return;
selected.forEach(({ image, index }) => {
image.ocr = null;
image.ocrStatus = 'error';
image.ocrError = err.message || String(err);
image.ocrStatusText = '识别失败:' + image.ocrError;
updateOcrImageView(index);
});
saveLog('多图 OCR 失败:' + (err.message || String(err)), 'error');
throw err;
} finally {
state.ocrRunning = false;
state.isOcrRunning = false;
if (button) {
button.disabled = false;
button.textContent = '识别全部已选题图';
}
}
}
async function completeSingleImageOcr() {
const issueId = String(state.currentIssueId || state.currentIssueInfo?.issue_id || '');
fillProblemTextFromOcr('overwrite');
saveCurrentOcrDraft(issueId);
await runProblemMatch(getCombinedOcrText('problem'));
}
async function runProblemMatch(problemText) {
const query = cleanText(problemText);
const issueId = String(state.currentIssueId || state.currentIssueInfo?.issue_id || '');
const modalSeq = state.saveModalSeq;
const requestSeq = ++state.matchRequestSeq;
state.historyLoadSeq += 1;
state.oldProblemRecord = null;
state.solutionItems = [];
const solutionContainer = qs('#pbt-match-solution');
if (solutionContainer) {
solutionContainer.classList.add('pbt-hidden');
solutionContainer.innerHTML = '';
}
if (!query) {
state.matchLoading = false;
state.matchError = '';
state.matchCandidates = [];
state.matchQuery = '';
renderProblemMatches();
return;
}
state.matchLoading = true;
state.matchError = '';
state.matchQuery = query;
state.selectedMatchId = '';
renderProblemMatches();
try {
const matches = await fetchProblemMatches(query);
if (
requestSeq !== state.matchRequestSeq ||
!isActiveSaveContext(issueId, modalSeq)
) return;
state.matchCandidates = matches.slice(0, 5);
} catch (err) {
if (
requestSeq !== state.matchRequestSeq ||
!isActiveSaveContext(issueId, modalSeq)
) return;
state.matchCandidates = [];
state.matchError = err.message || String(err);
saveLog('历史题目匹配失败:' + state.matchError, 'warn');
} finally {
if (
requestSeq === state.matchRequestSeq &&
isActiveSaveContext(issueId, modalSeq)
) {
state.matchLoading = false;
renderProblemMatches();
}
}
}
function getProblemImageUrl(item) {
if (typeof item === 'string') return normalizeUrl(item);
return normalizeUrl(item?.msg_body || item?.url || '');
}
function renderProblemMatches() {
const container = qs('#pbt-match-area');
if (!container) return;
const currentText = cleanText(qs('#pbt-save-problem-text')?.value || '');
const queryChanged = Boolean(
state.matchQuery &&
currentText &&
currentText !== state.matchQuery
);
if (state.matchLoading) {
container.innerHTML = '正在按题干文字查找历史题目...
';
return;
}
if (state.matchError) {
container.innerHTML = `
${escapeHtml(state.matchError)}
`;
return;
}
if (!state.matchQuery) {
container.innerHTML = 'OCR 完成后会自动按题干文字匹配历史正式题目。
';
return;
}
const changedHtml = queryChanged
? `
题干已修改,当前候选仍基于修改前文字。
`
: '';
if (!state.matchCandidates.length) {
container.innerHTML = `
${changedHtml}
没有找到足够相似的历史正式题目,可以直接重新作答。
`;
return;
}
container.innerHTML = `
${changedHtml}
系统只提供候选,请由老师确认是否为同题。
${state.matchCandidates.map(candidate => {
const images = toArray(candidate.problem_images)
.map(getProblemImageUrl)
.filter(Boolean);
const teacher = cleanText(candidate.teacher_name || '') ||
(candidate.teacher_id ? `老师 ID ${candidate.teacher_id}` : '未注明老师');
return `
${escapeHtml(teacher)}
${escapeHtml(formatTime(candidate.created))}
${escapeHtml(candidate.problem_text || '')}
${images.length
? `
${images.map(url => (
`
})
`
)).join('')}
`
: '
该历史题目没有保存图片。
'}
`;
}).join('')}
`;
}
async function runOcrForCurrentIssueImage(button) {
const originalText = button?.textContent || '';
if (button) {
button.disabled = true;
button.textContent = 'OCR 识别中...';
}
try {
await runOcrForSelectedImages(null);
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText || 'OCR 识别勾选题图';
}
}
}
function canvasToBlob(canvas, type = 'image/jpeg', quality = 0.92) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('裁剪图片生成失败'));
}
}, type, quality);
});
}
async function createCroppedBlobFromElements(img, box) {
const imgRect = img.getBoundingClientRect();
const boxRect = box.getBoundingClientRect();
const scaleX = img.naturalWidth / imgRect.width;
const scaleY = img.naturalHeight / imgRect.height;
const sx = Math.max(0, Math.round((boxRect.left - imgRect.left) * scaleX));
const sy = Math.max(0, Math.round((boxRect.top - imgRect.top) * scaleY));
const sw = Math.max(1, Math.round(boxRect.width * scaleX));
const sh = Math.max(1, Math.round(boxRect.height * scaleY));
const canvas = document.createElement('canvas');
canvas.width = Math.min(sw, img.naturalWidth - sx);
canvas.height = Math.min(sh, img.naturalHeight - sy);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, sx, sy, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
return canvasToBlob(canvas);
}
function positionCropBox(stage, box, rect) {
const maxW = stage.clientWidth;
const maxH = stage.clientHeight;
const minSize = 28;
const width = Math.max(minSize, Math.min(rect.width, maxW));
const height = Math.max(minSize, Math.min(rect.height, maxH));
const left = Math.max(0, Math.min(rect.left, maxW - width));
const top = Math.max(0, Math.min(rect.top, maxH - height));
box.style.left = left + 'px';
box.style.top = top + 'px';
box.style.width = width + 'px';
box.style.height = height + 'px';
}
async function openImageCropper(image) {
const blob = await gmGetBlob(image.msg_body);
const objectUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const mask = document.createElement('div');
mask.className = 'pbt-crop-mask';
mask.innerHTML = `
裁剪识别图片${escapeHtml(image.index)}
拖动蓝色区域选择题干范围,裁剪图只用于 OCR,不会替换入库原图。
`;
const closeCropper = () => {
URL.revokeObjectURL(objectUrl);
mask.remove();
};
const cancelCropper = () => {
closeCropper();
resolve(null);
};
const finishCropper = value => {
closeCropper();
resolve(value);
};
document.body.appendChild(mask);
const stage = qs('#pbt-crop-stage', mask);
const img = qs('#pbt-crop-img', mask);
const box = qs('#pbt-crop-box', mask);
let dragMode = '';
let startX = 0;
let startY = 0;
let startRect = null;
const resetBox = () => {
positionCropBox(stage, box, {
left: Math.round(stage.clientWidth * 0.05),
top: Math.round(stage.clientHeight * 0.05),
width: Math.round(stage.clientWidth * 0.9),
height: Math.round(stage.clientHeight * 0.9)
});
};
box.addEventListener('mousedown', event => {
dragMode = event.target?.dataset?.cropHandle === 'resize' ? 'resize' : 'move';
startX = event.clientX;
startY = event.clientY;
startRect = {
left: box.offsetLeft,
top: box.offsetTop,
width: box.offsetWidth,
height: box.offsetHeight
};
event.preventDefault();
});
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
const failLoad = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
closeCropper();
reject(new Error('裁剪图片加载失败'));
};
if (img.complete && img.naturalWidth) {
setTimeout(resetBox, 30);
} else {
img.addEventListener('load', () => setTimeout(resetBox, 30), { once: true });
img.addEventListener('error', failLoad, { once: true });
}
function onMove(event) {
if (!dragMode || !startRect) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (dragMode === 'resize') {
positionCropBox(stage, box, {
left: startRect.left,
top: startRect.top,
width: startRect.width + dx,
height: startRect.height + dy
});
} else {
positionCropBox(stage, box, {
left: startRect.left + dx,
top: startRect.top + dy,
width: startRect.width,
height: startRect.height
});
}
}
function onUp() {
dragMode = '';
startRect = null;
}
mask.addEventListener('click', async event => {
const action = event.target?.dataset?.cropAction;
if (!action) return;
if (action === 'cancel') {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
cancelCropper();
return;
}
if (action === 'reset') {
resetBox();
return;
}
if (action === 'confirm') {
const button = event.target;
button.disabled = true;
button.textContent = '处理中...';
try {
const croppedBlob = await createCroppedBlobFromElements(img, box);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
finishCropper(croppedBlob);
} catch (err) {
toast(err.message || String(err), 3000);
button.disabled = false;
button.textContent = '识别裁剪区域';
}
}
});
});
}
function buildSolution(messages) {
return messages.map((message, index) => {
const item = {
index: index + 1,
msg_type: message.msg_type,
msg_body: message.msg_type === 'text'
? cleanText(message.msg_body)
: normalizeUrl(message.msg_body),
explain:
message.msg_type === 'text' ? '老师文字解答' :
message.msg_type === 'photo' ? '老师解答图片' :
message.msg_type === 'voice' ? '老师语音讲解' :
message.msg_type === 'video' ? '老师视频讲解' :
'老师解答记录'
};
if (['voice', 'video'].includes(message.msg_type) && message.duration) {
item.duration = toNumber(message.duration);
}
if (message.msg_id) {
item.source_msg_id = message.msg_id;
}
return item;
});
}
async function fetchCurrentIssueInfo(options = {}) {
const askForToken = Boolean(options.askForToken);
const withMessages = Boolean(options.withMessages);
const readExisting = Boolean(options.readExisting);
const issueId = getIssueIdFromUrl();
if (!issueId) {
state.currentIssueId = null;
state.currentRoomId = null;
state.currentIssueInfo = null;
renderCurrentInfo();
throw new Error('当前页面 URL 中没有识别到 issueId,请确认你在教师端工单或聊天页。');
}
state.currentIssueId = issueId;
const issue = await teacherGet(`/apis/teacher/issue/${issueId}`, {
askForToken,
cacheKey: `issue:${issueId}`
});
state.currentIssueInfo = issue;
state.currentRoomId = issue.room_id;
if (!state.currentRoomId) {
renderCurrentInfo();
throw new Error('当前工单没有返回 room_id,无法确定聊天房间。');
}
if (withMessages) {
const [chatData, profile] = await Promise.all([
teacherGet(`/apis/teacher/chat-message?room_id=${issue.room_id}&page_size=${CONFIG.CHAT_PAGE_SIZE}&direction=backward`, {
askForToken,
cacheKey: `chatMessage:${issue.room_id}`
}),
teacherGet('/apis/teacher/profile', {
askForToken,
cacheKey: 'profile'
}).catch(err => {
log('教师 profile 读取失败,不阻断保存:' + (err.message || String(err)), 'warn');
return null;
})
]);
state.profile = profile;
state.messages = Array.isArray(chatData)
? chatData.slice().sort((a, b) => (a.client_time || 0) - (b.client_time || 0))
: [];
}
if (readExisting) {
try {
const orders = await pbGetList('work_orders', `issue_id=${Number(issue.issue_id)}`);
state.existingOrder = orders[0] || null;
if (state.existingOrder?.id) {
const problems = await pbGetList(CONFIG.PB_COLLECTION, `issue="${state.existingOrder.id}"`);
state.existingProblems = problems || [];
state.existingProblem = null;
} else {
state.existingProblem = null;
state.existingProblems = [];
}
} catch (err) {
state.existingOrder = null;
state.existingProblem = null;
state.existingProblems = [];
log('读取 PocketBase 已有记录失败,不影响继续操作:' + err.message, 'warn');
}
}
renderCurrentInfo();
return issue;
}
function renderCurrentInfo() {
const info = state.currentIssueInfo || {};
const student = info.student_name || info.studentName || '-';
const title = cleanText(info.issue_title || '').replace(/\s+/g, ' ');
const html = `
`;
qsa('.pbt-current-info').forEach(el => {
el.innerHTML = state.currentIssueInfo
? html
: '尚未识别当前工单。点击“刷新目标”或“初始化当前页面”。
';
});
const headSub = qs('#pbt-head-sub');
if (headSub) {
headSub.textContent = state.currentIssueId
? `issue ${state.currentIssueId} / ${student}`
: '未识别当前工单';
}
}
function createPanel() {
if (qs('#pbt-panel')) return;
const panel = document.createElement('div');
panel.id = 'pbt-panel';
panel.innerHTML = `
当前页面目标
自动识别
保存流程
读取当前工单、聊天记录和题目图片后,会打开一个大面板供你补充年级、题干、标签和备注,再选择要保存为 solution 的老师解答。
历史解答
尚未读取历史解答。
历史解答预览
读取后会显示文字、图片和语音解答,可勾选要转发的内容。
`;
document.body.appendChild(panel);
bindPanelEvents();
makeDraggable(panel, qs('#pbt-drag-head'));
showTab(state.activeTab);
renderCurrentInfo();
setTimeout(() => {
fetchCurrentIssueInfo().catch(err => log(err.message, 'warn'));
}, 1000);
}
function bindPanelEvents() {
const panel = qs('#pbt-panel');
if (!panel) return;
panel.addEventListener('click', async event => {
const action = event.target?.dataset?.action;
if (!action) {
if (event.target?.dataset?.img) {
openImagePreview(event.target.dataset.img);
}
return;
}
try {
if (action === 'toggle-panel') {
togglePanel();
return;
}
if (action === 'switch-tab') {
showTab(event.target.dataset.tab);
return;
}
if (action === 'refresh-target' || action === 'init-target') {
readSettingsFromPanel();
await fetchCurrentIssueInfo({ askForToken: action === 'init-target' });
log('当前页面目标已更新', 'success');
return;
}
if (action === 'open-save') {
await openSaveCollector();
return;
}
if (action === 'load-history') {
readSettingsFromPanel();
await fetchIssueProblemRecord(qs('#pbt-problem-id')?.value || '');
return;
}
if (action === 'history-select-all') {
state.solutionItems.forEach(item => { item.checked = true; });
qsa('.pbt-history-check').forEach(input => { input.checked = true; });
return;
}
if (action === 'history-select-none') {
state.solutionItems.forEach(item => { item.checked = false; });
qsa('.pbt-history-check').forEach(input => { input.checked = false; });
return;
}
if (action === 'send-history') {
readSettingsFromPanel();
await forwardSelectedSolution();
return;
}
if (action === 'stop-history') {
state.stopSending = true;
log('正在停止,当前消息发送完后会中断。', 'warn');
return;
}
if (action === 'save-settings') {
readSettingsFromPanel();
toast('设置已保存');
log('设置已保存', 'success');
return;
}
if (action === 'auto-token') {
const token = autoRefreshTeacherTokenFromPage();
if (!token) {
toast('当前页面没有找到可用教师 Token');
log('当前页面没有找到可用教师 Token,请确认已登录教师端。', 'warn');
} else {
toast('已自动获取当前页面教师 Token');
}
return;
}
if (action === 'clear-log') {
const logEl = qs('#pbt-log');
if (logEl) logEl.textContent = '';
}
} catch (err) {
console.error(err);
toast(err.message || String(err), 3600);
log(err.message || String(err), 'error');
}
});
panel.addEventListener('change', event => {
if (event.target?.classList?.contains('pbt-history-check')) {
const index = Number(event.target.dataset.idx);
if (state.solutionItems[index]) {
state.solutionItems[index].checked = event.target.checked;
}
}
});
}
function togglePanel() {
state.collapsed = !state.collapsed;
GM_setValue('pbtCollapsed', state.collapsed);
qs('#pbt-body')?.classList.toggle('pbt-hidden', state.collapsed);
const btn = qs('#pbt-toggle-btn');
if (btn) btn.textContent = state.collapsed ? '展开' : '收起';
}
function showTab(tab) {
const next = ['save', 'forward', 'settings'].includes(tab) ? tab : 'save';
state.activeTab = next;
GM_setValue('pbtActiveTab', next);
qsa('.pbt-tab').forEach(button => {
button.classList.toggle('active', button.dataset.tab === next);
});
qsa('.pbt-tab-panel').forEach(panel => {
panel.classList.toggle('pbt-hidden', panel.dataset.panel !== next);
});
}
function makeDraggable(panel, handle) {
if (!panel || !handle) return;
let startX = 0;
let startY = 0;
let startRight = 0;
let startBottom = 0;
let dragging = false;
handle.addEventListener('mousedown', event => {
if (event.target && event.target.tagName === 'BUTTON') return;
dragging = true;
startX = event.clientX;
startY = event.clientY;
const rect = panel.getBoundingClientRect();
startRight = window.innerWidth - rect.right;
startBottom = window.innerHeight - rect.bottom;
event.preventDefault();
});
document.addEventListener('mousemove', event => {
if (!dragging) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
panel.style.right = Math.max(8, startRight - dx) + 'px';
panel.style.bottom = Math.max(8, startBottom - dy) + 'px';
});
document.addEventListener('mouseup', () => {
dragging = false;
});
}
async function openSaveCollector() {
readSettingsFromPanel();
if (!getIssueIdFromUrl()) {
toast('当前页面没有检测到 issueId,请在工单详情页打开。');
return;
}
try {
showSaveLoading();
await fetchCurrentIssueInfo({
askForToken: true,
withMessages: true,
readExisting: true
});
renderSaveModal();
} catch (err) {
console.error(err);
showSaveError(err);
}
}
function closeSaveModal(saveDraft = true) {
const mask = qs('.pbt-mask');
const hasSaveForm = Boolean(mask?.querySelector('#pbt-save-problem-text'));
if (
saveDraft &&
hasSaveForm &&
(state.ocrResult || getCurrentOcrDraft())
) {
saveCurrentOcrDraft();
}
mask?.remove();
if (hasSaveForm) {
state.saveModalSeq += 1;
state.matchRequestSeq += 1;
state.historyLoadSeq += 1;
}
}
function showSaveLoading() {
closeSaveModal();
const mask = document.createElement('div');
mask.className = 'pbt-mask';
mask.innerHTML = `
正在读取当前工单
正在请求教师端接口和 PocketBase...
`;
mask.addEventListener('click', event => {
if (event.target?.dataset?.saveAction === 'close') closeSaveModal();
});
document.body.appendChild(mask);
}
function showSaveError(err) {
closeSaveModal();
const mask = document.createElement('div');
mask.className = 'pbt-mask';
mask.innerHTML = `
读取失败
请检查 Token、PocketBase 地址、字段或权限。
${escapeHtml(err.message || String(err))}
`;
mask.addEventListener('click', event => {
if (event.target?.dataset?.saveAction === 'close') closeSaveModal();
});
document.body.appendChild(mask);
}
function renderImageList(images) {
if (!images.length) return '未检测到题目图片
';
return `
${images.map((image, index) => `
入库
未识别
`).join('')}
OCR 合并结果
已识别 0 张
`;
}
function renderSaveMessages(messages) {
if (!messages.length) return '没有读取到聊天记录
';
return messages.map((message, index) => {
const valid = isValidSolutionMessage(message);
const checked = isDefaultChecked(message);
const isTeacher = message.sender_type === 1 && !isAiMessage(message);
return `
`;
}).join('');
}
function updateSaveMessageCheckedStyle() {
qsa('.pbt-msg').forEach(row => {
const input = qs('.pbt-save-msg-check', row);
row.classList.toggle('checked', Boolean(input?.checked));
});
const count = qsa('.pbt-save-msg-check:checked').length;
const countEl = qs('#pbt-save-selected-count');
if (countEl) countEl.textContent = String(count);
}
function renderSaveModal() {
closeSaveModal();
state.saveModalSeq += 1;
const issue = state.currentIssueInfo;
const messages = state.messages;
const images = initSaveImages(issue, messages);
const teacherId = getTeacherId(issue, messages, state.profile);
const teacherName = getTeacherName(messages, state.profile);
const subject = inferSubject(issue, state.profile);
const oldGrade = state.existingOrder?.grade || '';
const existingProblemCount = Array.isArray(state.existingProblems) ? state.existingProblems.length : 0;
const oldProblemText = '';
const oldNote = '';
const oldTags = '';
const mask = document.createElement('div');
mask.className = 'pbt-mask';
mask.innerHTML = `
保存当前解答到 PocketBase 题库
issue_id:${escapeHtml(issue.issue_id)} room_id:${escapeHtml(issue.room_id)}
${state.existingOrder
? `已有 ${existingProblemCount} 题,本次新建`
: '新工单,新建题目'}
工单信息
${escapeHtml(issue.issue_id)}
${escapeHtml(issue.issue_sn || '')}
${escapeHtml(issue.student_name || '')}
${escapeHtml(teacherName || '')} / ${escapeHtml(teacherId || '')}
${escapeHtml(issue.course_id || '')}
${escapeHtml(subject.subject_id || issue.subject_id || '')}
${escapeHtml(issue.course_name || '空')}
${escapeHtml(subject.subject_name || issue.subject_name || '空')}
${escapeHtml(cleanText(issue.issue_title || ''))}
题目图片与 OCR
已选 ${images.length} / ${images.length} 张
勾选的图片会保存到当前题目;OCR 可识别原图,也可先裁剪再识别。
${renderImageList(images)}
人工补充
后续检索主要靠这个字段;题图会上传到后端并由视觉大模型识别,工单标题会自动保存在 work_orders.issue_title。
选择保存为 solution 的解答记录
已选 0 条
${renderSaveMessages(messages)}
`;
document.body.appendChild(mask);
updateSaveMessageCheckedStyle();
mask.addEventListener('click', event => {
handleSaveModalClick(event).catch(err => {
console.error(err);
saveLog(err.message || String(err), 'error');
toast(err.message || String(err), 3500);
});
});
mask.addEventListener('input', event => {
if (event.target?.id === 'pbt-save-problem-text') {
if (state.ocrResult || getCurrentOcrDraft()) {
scheduleCurrentOcrDraftSave();
}
renderProblemMatches();
}
});
mask.addEventListener('change', event => {
if (event.target?.classList?.contains('pbt-save-msg-check')) {
updateSaveMessageCheckedStyle();
}
if (event.target?.classList?.contains('pbt-save-image-check')) {
updateSelectedImageCount();
if (state.ocrResult || getCurrentOcrDraft()) {
scheduleCurrentOcrDraftSave();
}
}
if (event.target?.classList?.contains('pbt-match-history-check')) {
const index = Number(event.target.dataset.idx);
if (state.solutionItems[index]) {
state.solutionItems[index].checked = event.target.checked;
}
}
});
const restoredDraft = restoreCurrentOcrDraft();
renderProblemMatches();
if (restoredDraft?.problemText) {
runProblemMatch(restoredDraft.problemText).catch(err => {
saveLog('恢复草稿后匹配失败:' + (err.message || String(err)), 'warn');
});
}
}
async function handleSaveModalClick(event) {
const action = event.target?.dataset?.saveAction;
if (!action) {
if (event.target?.dataset?.img) {
openImagePreview(event.target.dataset.img);
}
return;
}
if (action === 'close') {
closeSaveModal();
return;
}
if (action === 'reload') {
await openSaveCollector();
return;
}
if (action === 'select-teacher') {
qsa('.pbt-save-msg-check').forEach(input => {
const message = state.messages[Number(input.dataset.index)];
input.checked = isDefaultChecked(message);
});
updateSaveMessageCheckedStyle();
return;
}
if (action === 'select-valid') {
qsa('.pbt-save-msg-check:not(:disabled)').forEach(input => {
input.checked = true;
});
updateSaveMessageCheckedStyle();
return;
}
if (action === 'select-none') {
qsa('.pbt-save-msg-check').forEach(input => {
input.checked = false;
});
updateSaveMessageCheckedStyle();
return;
}
if (action === 'ocr-health') {
try {
const json = await ocrHealthCheck();
const ok = json?.ok ? '正常' : '异常';
saveLog(`OCR 服务检查:${ok}`, json?.ok ? 'success' : 'warn');
toast(`OCR 服务:${ok}`);
} catch (err) {
saveLog('OCR 服务检查失败:' + (err.message || String(err)), 'error');
toast('OCR 服务检查失败', 3500);
}
return;
}
if (action === 'ocr-selected') {
try {
await runOcrForSelectedImages(event.target);
} catch (err) {
toast(err.message || String(err), 3500);
}
return;
}
if (action === 'ocr-issue-image') {
try {
await runOcrForCurrentIssueImage(event.target);
} catch (err) {
toast(err.message || String(err), 3500);
}
return;
}
if (action === 'ocr-original') {
const index = Number(event.target.dataset.index);
const button = event.target;
button.disabled = true;
button.textContent = '识别中...';
try {
const result = await runOcrForImage(index);
if (result) await completeSingleImageOcr();
} catch (err) {
toast(err.message || String(err), 3500);
} finally {
button.disabled = false;
button.textContent = '识别原图';
}
return;
}
if (action === 'ocr-crop') {
const index = Number(event.target.dataset.index);
const image = getSaveImageByIndex(index);
if (!image) return;
try {
const blob = await openImageCropper(image);
if (!blob) return;
const button = event.target;
button.disabled = true;
button.textContent = '识别中...';
try {
const result = await runOcrForImage(index, { blob });
if (result) await completeSingleImageOcr();
} finally {
button.disabled = false;
button.textContent = '裁剪识别';
}
} catch (err) {
saveLog('裁剪识别失败:' + (err.message || String(err)), 'error');
toast(err.message || String(err), 3500);
}
return;
}
if (action === 'ocr-clear') {
clearOcrResults();
toast('已清空 OCR 结果');
return;
}
if (action === 'ocr-overwrite') {
fillProblemTextFromOcr('overwrite');
return;
}
if (action === 'ocr-append') {
fillProblemTextFromOcr('append');
return;
}
if (action === 'match-retry') {
await runProblemMatch(qs('#pbt-save-problem-text')?.value || '');
return;
}
if (action === 'match-expand') {
const id = cleanText(event.target.dataset.id || '');
const issueId = String(state.currentIssueId || state.currentIssueInfo?.issue_id || '');
const modalSeq = state.saveModalSeq;
const loadSeq = ++state.historyLoadSeq;
state.selectedMatchId = id;
event.target.disabled = true;
event.target.textContent = '正在读取解答...';
try {
const record = await fetchIssueProblemRecord(id, {
isCurrent: () => (
loadSeq === state.historyLoadSeq &&
state.selectedMatchId === id &&
isActiveSaveContext(issueId, modalSeq)
)
});
if (!record) return;
renderProblemMatches();
renderMatchSolutionPreview();
} finally {
if (event.target.isConnected) {
event.target.disabled = false;
event.target.textContent = '展开完整解答';
}
}
return;
}
if (action === 'match-select-all') {
state.solutionItems.forEach(item => { item.checked = true; });
qsa('.pbt-match-history-check').forEach(input => { input.checked = true; });
return;
}
if (action === 'match-select-none') {
state.solutionItems.forEach(item => { item.checked = false; });
qsa('.pbt-match-history-check').forEach(input => { input.checked = false; });
return;
}
if (action === 'match-send') {
const completed = await forwardSelectedSolution();
if (completed) {
state.ocrResult = null;
closeSaveModal(false);
}
return;
}
if (action === 'match-stop') {
state.stopSending = true;
saveLog('正在停止,当前消息发送完后会中断。', 'warn');
return;
}
if (action === 'save') {
await saveCurrentIssueToPocketBase(event.target);
}
}
function getSaveValues() {
const pbBaseValue = cleanText(qs('#pbt-save-pb-base')?.value || state.pbBase || CONFIG.PB_BASE_DEFAULT);
const grade = cleanText(qs('#pbt-save-grade')?.value || '');
const problemText = cleanText(qs('#pbt-save-problem-text')?.value || '');
const problemLatex = cleanText(state.ocrResult?.problem_latex || state.ocrResult?.raw_text || getCombinedOcrText('latex'));
const problemImages = getSelectedProblemImages();
const note = cleanText(qs('#pbt-save-note')?.value || '');
const tags = cleanText(qs('#pbt-save-tags')?.value || '')
.split(/[,,;;\s]+/)
.map(item => cleanText(item))
.filter(Boolean);
return {
pbBaseValue,
grade,
problemText,
problemLatex,
problemImages,
note,
tags
};
}
async function saveCurrentIssueToPocketBase(saveButton) {
const issue = state.currentIssueInfo;
const values = getSaveValues();
if (!issue) {
toast('没有工单数据,请重新读取。');
return;
}
if (!values.grade) {
toast('请选择 grade:初中 或 高中。');
qs('#pbt-save-grade')?.focus();
return;
}
if (!values.problemText) {
const ok = confirm('problem_text 为空,后续只能靠图片和标签检索。确定继续保存吗?');
if (!ok) {
qs('#pbt-save-problem-text')?.focus();
return;
}
}
const selectedMessages = qsa('.pbt-save-msg-check:checked')
.map(input => state.messages[Number(input.dataset.index)])
.filter(Boolean)
.filter(isValidSolutionMessage)
.sort((a, b) => (a.client_time || 0) - (b.client_time || 0));
if (!selectedMessages.length) {
toast('请至少选择一条解答记录。');
return;
}
state.pbBase = values.pbBaseValue;
GM_setValue('pbBase', state.pbBase);
GM_setValue('pb_base', state.pbBase);
saveButton.disabled = true;
saveButton.textContent = '保存中...';
try {
const teacherId = getTeacherId(issue, state.messages, state.profile);
const teacherName = getTeacherName(state.messages, state.profile);
const subject = inferSubject(issue, state.profile);
saveLog('[1] 查询 work_orders...', 'info');
let orders = await pbGetList('work_orders', `issue_id=${Number(issue.issue_id)}`);
let order = orders[0];
const orderPayload = {
issue_id: Number(issue.issue_id),
issue_sn: issue.issue_sn || '',
issue_title: cleanText(issue.issue_title || ''),
room_id: Number(issue.room_id),
student_id: toNumber(issue.student_id),
teacher_id: toNumber(teacherId),
student_name: issue.student_name || '',
teacher_name: teacherName || '',
course_id: toNumber(issue.course_id),
subject_id: toNumber(subject.subject_id),
subject_name: subject.subject_name || '',
course_name: issue.course_name || '',
[CONFIG.GRADE_FIELD]: values.grade
};
if (order) {
saveLog(`[2] work_orders 已存在,更新:${order.id}`, 'info');
order = await pbUpdate('work_orders', order.id, orderPayload);
} else {
saveLog('[2] work_orders 不存在,创建...', 'info');
order = await pbCreate('work_orders', orderPayload);
}
saveLog('[3] 组装 issue_problems...', 'info');
const problemPayload = {
issue: order.id,
problem_text: values.problemText,
problem_latex: values.problemLatex || '',
tags: values.tags,
problem_images: values.problemImages,
solution: buildSolution(selectedMessages),
teacher_id: toNumber(teacherId),
teacher_name: teacherName || '',
note: values.note,
is_proofread: Boolean(values.problemText),
is_reviewed: false
};
saveLog('[4] 创建 issue_problems 新题目记录...', 'info');
const problem = await pbCreate(CONFIG.PB_COLLECTION, problemPayload);
state.existingOrder = order;
state.existingProblem = null;
state.existingProblems = [
...(Array.isArray(state.existingProblems) ? state.existingProblems : []),
problem
];
saveLog('[完成] 保存成功', 'success');
saveLog(`work_order_id: ${order.id}`, 'success');
saveLog(`problem_id: ${problem.id}`, 'success');
deleteCurrentOcrDraft(issue.issue_id);
state.ocrResult = null;
saveLog('当前工单本地 OCR 草稿已删除', 'success');
toast('保存成功');
saveButton.textContent = '已保存';
renderCurrentInfo();
} catch (err) {
console.error(err);
saveLog('[错误] ' + (err.message || String(err)), 'error');
if (/grade|Unknown field|validation/i.test(err.message || '')) {
saveLog('提示:请确认 work_orders 已新增 grade 字段,字段名必须是 grade。', 'warn');
}
toast('保存失败,请查看日志。', 3600);
saveButton.disabled = false;
saveButton.textContent = '保存到 PocketBase';
}
}
async function fetchIssueProblemRecord(issueProblemId, options = {}) {
const id = cleanText(issueProblemId);
if (!id) {
throw new Error('请先输入 issue_problems 记录 ID。');
}
const data = await pbGetOne(CONFIG.PB_COLLECTION, id);
if (options.isCurrent && !options.isCurrent()) return null;
const solution = toArray(data.solution)
.map((item, index) => normalizeSolutionItem(item, index))
.filter(item => item.msg_type && item.msg_body)
.filter(item => ['text', 'photo', 'voice', 'video'].includes(item.msg_type))
.sort((a, b) => Number(a.index || 0) - Number(b.index || 0))
.map((item, index) => ({
...item,
index: Number(item.index || index + 1)
}));
if (!solution.length) {
throw new Error('这条 issue_problems 记录里没有可发送的 solution。');
}
state.oldProblemId = id;
state.oldProblemRecord = data;
state.solutionItems = solution;
renderHistoryPreview();
log(`已读取历史解答:${id},共 ${solution.length} 条`, 'success');
return data;
}
function normalizeSolutionItem(item, index) {
const msgType = cleanText(item?.msg_type || '');
let msgBody = item?.msg_body;
if (msgType === 'photo' || msgType === 'voice' || msgType === 'video') {
msgBody = normalizeUrl(msgBody);
} else {
msgBody = cleanText(msgBody);
}
return {
index: Number(item?.index || index + 1),
msg_type: msgType,
msg_body: msgBody,
explain: cleanText(item?.explain || ''),
duration: item?.duration,
source_msg_id: item?.source_msg_id || '',
checked: true
};
}
function renderSolutionItemBody(item) {
if (item.msg_type === 'photo') {
return `
`;
}
if (item.msg_type === 'voice') {
return `
${escapeHtml(item.msg_body)}
`;
}
if (item.msg_type === 'video') {
return `
${escapeHtml(item.msg_body)}
`;
}
return escapeHtml(item.msg_body);
}
function renderHistoryPreview() {
const metaEl = qs('#pbt-history-meta');
const previewEl = qs('#pbt-preview');
if (!metaEl || !previewEl) return;
const record = state.oldProblemRecord || {};
const items = state.solutionItems || [];
const textCount = items.filter(item => item.msg_type === 'text').length;
const photoCount = items.filter(item => item.msg_type === 'photo').length;
const voiceCount = items.filter(item => item.msg_type === 'voice').length;
const videoCount = items.filter(item => item.msg_type === 'video').length;
metaEl.innerHTML = `
`;
previewEl.innerHTML = items.map((item, index) => {
return `
待发送
${item.explain ? `
说明:${escapeHtml(item.explain)}
` : ''}
${renderSolutionItemBody(item)}
`;
}).join('');
}
function renderMatchSolutionPreview() {
const container = qs('#pbt-match-solution');
if (!container) return;
const record = state.oldProblemRecord || {};
const items = state.solutionItems || [];
const teacher = cleanText(record.teacher_name || '') ||
(record.teacher_id ? `老师 ID ${record.teacher_id}` : '未注明老师');
container.classList.remove('pbt-hidden');
container.innerHTML = `
${escapeHtml(teacher)}的完整解答
${items.length} 条消息
${escapeHtml(record.problem_text || '')}
全部解答消息默认选中;发送前会再次确认。
${items.map((item, index) => `
待发送
${item.explain ? `
说明:${escapeHtml(item.explain)}
` : ''}
${renderSolutionItemBody(item)}
`).join('')}
`;
}
function setSendStatus(text) {
qsa('#pbt-send-status, #pbt-match-send-status').forEach(el => {
el.textContent = text || '';
});
}
function setHistoryItemStatus(index, text, cls = '') {
qsa(`#pbt-history-status-${index}, #pbt-match-history-status-${index}`).forEach(el => {
el.className = `pbt-status ${cls}`;
el.textContent = text;
});
}
function getSelectedHistoryItems() {
return (state.solutionItems || [])
.filter(item => item.checked)
.sort((a, b) => Number(a.index || 0) - Number(b.index || 0));
}
async function fetchSocketToken() {
if (!state.teacherToken) {
ensureTeacherToken(true);
}
if (!state.teacherToken) {
throw new Error('没有教师 HTTP Token,无法获取 socket token。');
}
const data = await teacherGet('/apis/teacher/socket', true);
if (!data || !data.token) {
throw new Error('获取 socket token 失败:' + JSON.stringify(data).slice(0, 300));
}
state.socketToken = data.token;
log('已获取 socket token', 'success');
return state.socketToken;
}
function closeSocket() {
try {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send('41/sim/room-talk,');
state.ws.close();
}
} catch (_) {}
state.ws = null;
state.namespaceConnected = false;
state.ackMap.clear();
}
async function connectSocket(roomId = state.currentRoomId) {
if (!roomId) {
throw new Error('缺少当前 room_id,不能连接房间。');
}
if (!state.socketToken) {
await fetchSocketToken();
}
closeSocket();
return new Promise((resolve, reject) => {
const ws = new WebSocket(CONFIG.SOCKET_URL);
state.ws = ws;
state.namespaceConnected = false;
const timer = setTimeout(() => {
reject(new Error('WebSocket 连接超时'));
}, 15000);
ws.onopen = () => {
log('WebSocket 已打开,等待 Engine.IO open');
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('WebSocket 连接错误'));
};
ws.onclose = () => {
state.namespaceConnected = false;
log('WebSocket 已关闭', 'warn');
};
ws.onmessage = event => {
const data = event.data;
if (typeof data === 'string' && data.startsWith('0{')) {
ws.send('40');
setTimeout(() => {
ws.send(`40/sim/room-talk,{"token":"${state.socketToken}"}`);
}, 180);
return;
}
if (data === '2') {
ws.send('3');
return;
}
if (typeof data === 'string' && data.startsWith('40/sim/room-talk,')) {
state.namespaceConnected = true;
ws.send(`42/sim/room-talk,["teacher-join-room",{"room_id":${Number(roomId)}}]`);
clearTimeout(timer);
log(`已连接 /sim/room-talk,并加入 room_id=${roomId}`, 'success');
resolve(true);
return;
}
if (typeof data === 'string' && data.startsWith('43/sim/room-talk,')) {
handleAck(data);
return;
}
if (typeof data === 'string' && data.startsWith('42/sim/room-talk,')) {
log('收到服务端推送事件');
}
};
});
}
function handleAck(frame) {
const match = frame.match(/^43\/sim\/room-talk,(\d+)(.*)$/);
if (!match) {
log(`收到未知 ack:${frame}`, 'warn');
return;
}
const ackId = Number(match[1]);
const payloadText = match[2] || '';
const payload = safeJsonParse(payloadText, null);
const task = state.ackMap.get(ackId);
if (!task) return;
clearTimeout(task.timer);
state.ackMap.delete(ackId);
task.resolve(payload);
}
function sendTeacherMessage({ room_id, msg_type, msg_body }) {
return new Promise((resolve, reject) => {
if (!state.ws || state.ws.readyState !== WebSocket.OPEN || !state.namespaceConnected) {
reject(new Error('WebSocket 未连接,不能发送。'));
return;
}
const ackId = state.ackSeq++;
const payload = JSON.stringify([
'teacher-send-message',
{
room_id: Number(room_id),
msg_type,
msg_body
}
]);
const frame = `42/sim/room-talk,${ackId}${payload}`;
const timer = setTimeout(() => {
state.ackMap.delete(ackId);
reject(new Error(`发送超时:ackId=${ackId}`));
}, 20000);
state.ackMap.set(ackId, {
resolve,
reject,
timer
});
state.ws.send(frame);
});
}
function getSuccessfulAckMessageId(ack) {
const payload = Array.isArray(ack) ? ack[0] : ack;
const msgId = cleanText(payload?.msg_id || payload?.data?.msg_id || '');
if (!msgId) {
throw new Error('服务端未返回 msg_id,不能确认消息发送成功。');
}
return msgId;
}
async function forwardSelectedSolution() {
if (state.isSending) return;
const selected = getSelectedHistoryItems();
if (!state.currentIssueId || !state.currentRoomId) {
await fetchCurrentIssueInfo({ askForToken: true });
}
const targetIssueId = String(state.currentIssueId || '');
const targetRoomId = Number(state.currentRoomId || 0);
if (!state.oldProblemRecord || !state.solutionItems.length) {
throw new Error('请先读取历史 issue_problems 解答。');
}
if (!selected.length) {
throw new Error('没有勾选任何要发送的解答内容。');
}
const info = state.currentIssueInfo || {};
const confirmText = [
'确认把历史解答发送给当前页面学生?',
'',
`当前学生:${info.student_name || '-'}`,
`当前 issueId:${targetIssueId}`,
`当前 room_id:${targetRoomId}`,
`历史解答 ID:${state.oldProblemId}`,
`勾选内容:${selected.length} 条`,
'',
'本次只发送勾选的历史解答内容,不发送开场提示和结尾提示。'
].join('\n');
if (!window.confirm(confirmText)) return false;
state.isSending = true;
state.stopSending = false;
const sendButtons = qsa('#pbt-send-btn, [data-save-action="match-send"]');
const stopButtons = qsa('#pbt-stop-btn, [data-save-action="match-stop"]');
sendButtons.forEach(button => { button.disabled = true; });
stopButtons.forEach(button => { button.disabled = false; });
let sentCount = 0;
try {
setSendStatus('正在连接 WebSocket...');
await connectSocket(targetRoomId);
setSendStatus('正在发送...');
for (const item of selected) {
if (state.stopSending) {
log('用户已停止发送', 'warn');
break;
}
const index = state.solutionItems.indexOf(item);
setHistoryItemStatus(index, '发送中', 'wait');
log(`发送 #${item.index} ${item.msg_type}`);
try {
const ack = await sendTeacherMessage({
room_id: targetRoomId,
msg_type: item.msg_type,
msg_body: item.msg_body
});
const msgId = getSuccessfulAckMessageId(ack);
item.checked = false;
qsa(`.pbt-history-check[data-idx="${index}"], .pbt-match-history-check[data-idx="${index}"]`).forEach(input => {
input.checked = false;
input.disabled = true;
});
setHistoryItemStatus(index, `已发送 ${msgId}`, 'ok');
log(`#${item.index} 已发送 msg_id=${msgId}`, 'success');
sentCount += 1;
} catch (err) {
setHistoryItemStatus(index, '发送失败', 'err');
log(`#${item.index} 发送失败:${err.message}`, 'error');
throw err;
}
if (sentCount < selected.length) {
await sleep(state.delayMs);
}
}
if (sentCount === selected.length) {
deleteCurrentOcrDraft(targetIssueId);
setSendStatus('发送完成,本地 OCR 草稿已删除');
log('历史解答全部发送成功,已删除当前工单本地 OCR 草稿', 'success');
return true;
}
setSendStatus(`发送已停止:完成 ${sentCount} / ${selected.length},本地草稿已保留`);
log('发送未全部完成,本地 OCR 草稿已保留', 'warn');
return false;
} finally {
state.isSending = false;
sendButtons.forEach(button => { button.disabled = false; });
stopButtons.forEach(button => { button.disabled = true; });
closeSocket();
}
}
function boot() {
createPanel();
}
installRequestInterceptor();
ready(boot);
let lastHref = location.href;
setInterval(() => {
if (location.href === lastHref) return;
lastHref = location.href;
state.currentIssueId = null;
state.currentRoomId = null;
state.currentIssueInfo = null;
state.profile = null;
state.messages = [];
state.existingOrder = null;
state.existingProblem = null;
state.existingProblems = [];
delete state.lastTeacherResponseCache.issue;
delete state.lastTeacherResponseCache.chatMessage;
renderCurrentInfo();
log('检测到页面切换,已重置当前目标。', 'warn');
setTimeout(() => {
fetchCurrentIssueInfo().catch(err => log(err.message, 'warn'));
}, 800);
}, 1000);
})();