Loading... # 前言 最近又到了一学期就有一次的教师评价活动,每次点好几个老师真的是烦死了,于是我就搓了个脚本以及网站来实现懒人评价,你可以自定义自己的评价,或者你是同样的正方教务系统也可以使用,通过右上角设置按钮更改你的学校教务系统地址,如果你不放心自己的数据,可以使用我的油猴脚本 # 教程 没有教程,你可以前往以往文章查看油猴脚本部署教程,以往教程里面也带有油猴浏览器插件的下载地址,相关文章都有提供,或者互联网查询,或者使用AI工具如豆包、Deepseek查询如何部署自定义脚本到油猴脚本 # 在线使用 你可以直接使用内嵌模块进行一键提交 <div style="width:100%;max-width:1280px;margin:0 auto;"> <iframe src="https://tea.lminyo.com/" title="LMINYO 正方一键评价" style=" width:100%; height:92vh; min-height:760px; border:0; border-radius:18px; overflow:hidden; background:#0b1120; box-shadow:0 20px 60px rgba(0,0,0,.28); " loading="lazy" referrerpolicy="no-referrer" allow="clipboard-read; clipboard-write" ></iframe> </div> # 油猴脚本(复制粘贴应用到网站中) ```javascript // ==UserScript== // @name 正方一键评价 // @namespace https://trae.local/zfsoft/xspj // @version 1.0.0 // @description 正方教务学生评价一键填写、批量保存、批量提交,支持自定义评分和评语 // @author Trae // @match *://*/xspjgl/xspj_cxXspjIndex.html* // @run-at document-idle // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant unsafeWindow // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'zfsoft-xspj-config'; const PANEL_ID = 'zf-xspj-helper-panel'; const BLOG_URL = 'https://lminyo.com/'; const DEFAULT_CONFIG = { scoreMode: 'fixed', fixedScore: '100', scoreList: '100,95,90,85', commentMode: 'random', teacherComments: [ '老师讲解清晰,教学认真负责,课堂节奏合理,整体收获很大。', '教师准备充分,授课条理清楚,课堂互动自然,学习体验很好。' ].join('\n'), courseComments: [ '课程内容充实,知识脉络清晰,建议继续保持并适当增加案例讲解。', '整体教学效果很好,希望后续能增加更多实践或应用场景示例。' ].join('\n'), switchDelayMs: 1200, dialogDelayMs: 450, submitCooldownMs: 1400, autoConfirmDialog: true, actionMode: 'submit' }; const state = { running: false, stopRequested: false, lastBatchRowKey: '', sameRowRetry: 0, processedSaveRowKeys: new Set(), lastCommentPickMap: new Map(), blogVisitedForThisRun: false, taskItems: [] }; // #region debug-point A:init const __debugSessionId = 'auto-eval-bug'; const __debugServerUrl = 'http://127.0.0.1:7777/event'; const __debugEnabled = false; function debugReport(hypothesisId, location, msg, data) { if (!__debugEnabled) { return; } fetch(__debugServerUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: __debugSessionId, runId: 'post-fix-v1', hypothesisId, location, msg: `[DEBUG] ${msg}`, data: data || {}, ts: Date.now() }) }).catch(() => {}); } // #endregion function getPageWindow() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function getPageJQuery() { return getPageWindow().jQuery || window.jQuery; } function isReady() { return !!getPageJQuery() && !!document.querySelector('#tempGrid'); } function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } async function waitFor(checker, timeoutMs = 10000, intervalMs = 200) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const result = checker(); if (result) { return result; } await sleep(intervalMs); } throw new Error('等待页面状态超时'); } function parsePositiveInt(value, fallback) { const num = Number(String(value).trim()); return Number.isFinite(num) && num > 0 ? Math.floor(num) : fallback; } function normalizeConfig(rawConfig) { const config = Object.assign({}, DEFAULT_CONFIG, rawConfig || {}); config.fixedScore = String(config.fixedScore || DEFAULT_CONFIG.fixedScore).trim() || '100'; config.scoreMode = ['fixed', 'random', 'rotate'].includes(config.scoreMode) ? config.scoreMode : 'fixed'; config.commentMode = ['random', 'rotate'].includes(config.commentMode) ? config.commentMode : 'random'; config.switchDelayMs = parsePositiveInt(config.switchDelayMs, DEFAULT_CONFIG.switchDelayMs); config.dialogDelayMs = parsePositiveInt(config.dialogDelayMs, DEFAULT_CONFIG.dialogDelayMs); config.submitCooldownMs = parsePositiveInt(config.submitCooldownMs, DEFAULT_CONFIG.submitCooldownMs); config.autoConfirmDialog = Boolean(config.autoConfirmDialog); config.teacherComments = String(config.teacherComments || '').trim(); config.courseComments = String(config.courseComments || '').trim(); config.scoreList = String(config.scoreList || '').trim(); config.actionMode = ['save', 'submit'].includes(config.actionMode) ? config.actionMode : 'submit'; return config; } function loadConfig() { return normalizeConfig(GM_getValue(STORAGE_KEY, DEFAULT_CONFIG)); } function saveConfig(config) { const normalized = normalizeConfig(config); GM_setValue(STORAGE_KEY, normalized); return normalized; } function splitLines(text) { const raw = String(text || ''); let lines = raw .split(/\r?\n/) .map((item) => item.trim()) .filter(Boolean); if (lines.length <= 1) { lines = raw .split(/\s*(?:\|\|\||\||;|;)\s*/) .map((item) => item.trim()) .filter(Boolean); } // 用户经常把多条评语直接连着粘成一段,这里按中文句号等终止符再拆一次。 if (lines.length <= 1) { const sentenceParts = raw .split(/(?<=[。!?!?])/) .map((item) => item.trim()) .filter(Boolean); if (sentenceParts.length > 1) { lines = sentenceParts; } } // #region debug-point B:splitLines debugReport('B', '正方一键评价.user.js:splitLines', 'split lines result', { rawLength: raw.length, rawSample: raw.slice(0, 100), linesCount: lines.length, linesSample: lines.slice(0, 3) }); // #endregion if (!lines.length) { return ['整体评价良好']; } return lines; } function getScoreCandidates(config) { const list = splitLines(String(config.scoreList || '').replace(/,/g, '\n')) .map((item) => Number(item)) .filter((item) => Number.isFinite(item)); if (config.scoreMode === 'fixed') { const fixed = Number(config.fixedScore); return Number.isFinite(fixed) ? [fixed] : [100]; } return list.length ? list : [100, 95, 90, 85]; } function chooseFromList(list, index, mode) { if (!list.length) { return ''; } if (mode === 'rotate') { return list[index % list.length]; } if (list.length === 1) { return list[0]; } const key = `${mode}__${list.join('||')}`; let pickedIndex = Math.floor(Math.random() * list.length); const lastIndex = state.lastCommentPickMap.get(key); if (list.length > 1 && pickedIndex === lastIndex) { pickedIndex = (pickedIndex + 1) % list.length; } state.lastCommentPickMap.set(key, pickedIndex); return list[pickedIndex]; } function ensureCommentLength(comment, textarea) { const min = Number(textarea.dataset.zsxx || 1) || 1; const max = Number(textarea.dataset.zssx || textarea.getAttribute('maxlength') || 1000) || 1000; let value = String(comment || '').trim(); if (!value) { value = '整体体验很好。'; } const filler = '希望继续保持,感谢老师的付出。'; while (value.length < min) { value += filler; } if (value.length > max) { value = value.slice(0, max); } return value; } function getCurrentConfigFromPanel() { const panel = document.getElementById(PANEL_ID); if (!panel) { return loadConfig(); } return normalizeConfig({ scoreMode: panel.querySelector('[name="scoreMode"]').value, fixedScore: panel.querySelector('[name="fixedScore"]').value, scoreList: panel.querySelector('[name="scoreList"]').value, commentMode: panel.querySelector('[name="commentMode"]').value, teacherComments: panel.querySelector('[name="teacherComments"]').value, courseComments: panel.querySelector('[name="courseComments"]').value, switchDelayMs: panel.querySelector('[name="switchDelayMs"]').value, dialogDelayMs: panel.querySelector('[name="dialogDelayMs"]').value, submitCooldownMs: panel.querySelector('[name="submitCooldownMs"]').value, autoConfirmDialog: panel.querySelector('[name="autoConfirmDialog"]').checked, actionMode: panel.querySelector('[name="actionMode"]').value }); } function setStatus(message, isError = false) { const statusNode = document.querySelector(`#${PANEL_ID} .zf-status`); if (!statusNode) { return; } statusNode.textContent = message; statusNode.dataset.error = isError ? '1' : '0'; } function escapeHtml(text) { return String(text || '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function getRowTaskKey(row) { return `${row.__rowId || ''}__${row.jxb_id || ''}__${row.jgh_id || ''}`; } function getRowDisplayName(row) { const course = row.kcmc || row.jxbmc || '未命名课程'; const teacher = row.jzgmc || '未知教师'; return `${course} / ${teacher}`; } function getTaskStatusLabel(status) { if (status === 'done') { return '已完成'; } if (status === 'running') { return '进行中'; } if (status === 'error') { return '失败'; } return '待处理'; } function renderTaskBoard() { const panel = document.getElementById(PANEL_ID); if (!panel) { return; } const progressFill = panel.querySelector('.zf-progress-fill'); const progressText = panel.querySelector('.zf-progress-text'); const taskList = panel.querySelector('.zf-task-list'); const items = state.taskItems || []; const total = items.length; let finished = 0; for (const item of items) { if (item.status === 'done' || item.status === 'error') { finished += 1; } } const percent = total ? Math.round((finished / total) * 100) : 0; if (progressFill) { progressFill.style.width = `${percent}%`; } if (progressText) { progressText.textContent = total ? `任务进度 ${finished}/${total}` : '任务进度 0/0'; } if (taskList) { taskList.innerHTML = items.map((item) => ` <div class="zf-task-item" data-status="${escapeHtml(item.status)}"> <div class="zf-task-title">${escapeHtml(item.title)}</div> <div class="zf-task-meta"> <span>${escapeHtml(getTaskStatusLabel(item.status))}</span> <span>第 ${escapeHtml(item.page || '-')} 页</span> </div> <div class="zf-task-detail">${escapeHtml(item.detail || '')}</div> </div> `).join(''); } } function resetTaskBoard() { state.taskItems = []; renderTaskBoard(); } function setTaskItems(rows) { state.taskItems = []; for (const row of rows) { state.taskItems.push({ key: getRowTaskKey(row), title: getRowDisplayName(row), page: row.__page || 1, status: isSubmittedRow(row) ? 'done' : 'pending', detail: isSubmittedRow(row) ? (row.tjztmc || '已完成') : (row.tjztmc || '待处理') }); } renderTaskBoard(); } function updateTaskItem(taskKey, status, detail) { for (const item of state.taskItems) { if (item.key === taskKey) { item.status = status; item.detail = detail || item.detail || ''; break; } } renderTaskBoard(); } function updatePanelForm(config) { const panel = document.getElementById(PANEL_ID); if (!panel) { return; } panel.querySelector('[name="scoreMode"]').value = config.scoreMode; panel.querySelector('[name="fixedScore"]').value = config.fixedScore; panel.querySelector('[name="scoreList"]').value = config.scoreList; panel.querySelector('[name="commentMode"]').value = config.commentMode; panel.querySelector('[name="teacherComments"]').value = config.teacherComments; panel.querySelector('[name="courseComments"]').value = config.courseComments; panel.querySelector('[name="switchDelayMs"]').value = String(config.switchDelayMs); panel.querySelector('[name="dialogDelayMs"]').value = String(config.dialogDelayMs); panel.querySelector('[name="submitCooldownMs"]').value = String(config.submitCooldownMs); panel.querySelector('[name="autoConfirmDialog"]').checked = config.autoConfirmDialog; panel.querySelector('[name="actionMode"]').value = config.actionMode; } function buildPanelHtml() { return ` <div class="zf-head"> <span>正方一键评价</span> <button type="button" class="zf-collapse">收起</button> </div> <div class="zf-body"> <label>评分模式 <select name="scoreMode"> <option value="fixed">固定分</option> <option value="random">随机分</option> <option value="rotate">轮换分</option> </select> </label> <label>固定分 <input name="fixedScore" type="text" placeholder="100"> </label> <label>分值列表 <input name="scoreList" type="text" placeholder="100,95,90,85"> </label> <label>评语模式 <select name="commentMode"> <option value="random">随机抽取</option> <option value="rotate">顺序轮换</option> </select> </label> <label>教师评语 <textarea name="teacherComments" rows="3" placeholder="一行一条"></textarea> </label> <label>课程评语 <textarea name="courseComments" rows="3" placeholder="一行一条"></textarea> </label> <label>切课等待(ms) <input name="switchDelayMs" type="number" min="200" step="100"> </label> <label>弹窗等待(ms) <input name="dialogDelayMs" type="number" min="100" step="50"> </label> <label>提交冷却(ms) <input name="submitCooldownMs" type="number" min="300" step="100"> </label> <label class="zf-checkbox"> <input name="autoConfirmDialog" type="checkbox"> 自动确认弹窗 </label> <label>处理后动作 <select name="actionMode"> <option value="save">保存</option> <option value="submit">提交</option> </select> </label> <div class="zf-actions"> <button type="button" data-action="save-config">保存配置</button> <button type="button" data-action="start-auto" style="grid-column: 1 / -1; background: #2ea44f;">一键全自动</button> <button type="button" data-action="stop" class="zf-danger" style="grid-column: 1 / -1;">停止</button> </div> <div class="zf-progress"> <div class="zf-progress-text">任务进度 0/0</div> <div class="zf-progress-bar"><div class="zf-progress-fill"></div></div> </div> <div class="zf-task-list"></div> <div class="zf-license"> 版权说明:使用前需先访问一次 <a href="${BLOG_URL}" target="_blank" rel="noopener noreferrer">LMINYO 博客</a>。每次手动开始使用时触发一次,不会在批量切换课程时重复触发。 </div> <div class="zf-status">就绪</div> </div> `; } function injectStyles() { GM_addStyle(` #${PANEL_ID} { position: fixed; top: 88px; right: 18px; z-index: 99999; width: 320px; background: rgba(20, 27, 38, 0.96); color: #fff; border-radius: 12px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); font-size: 13px; overflow: hidden; } #${PANEL_ID} * { box-sizing: border-box; font-family: Arial, "Microsoft YaHei", sans-serif; } #${PANEL_ID} .zf-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: rgba(255, 255, 255, 0.08); font-weight: 700; } #${PANEL_ID} .zf-head button, #${PANEL_ID} .zf-actions button { border: 0; border-radius: 8px; cursor: pointer; } #${PANEL_ID} .zf-head button { padding: 4px 8px; background: #344055; color: #fff; } #${PANEL_ID}.zf-collapsed .zf-body { display: none; } #${PANEL_ID} .zf-body { padding: 12px; } #${PANEL_ID} label { display: block; margin-bottom: 8px; color: #d7dfeb; } #${PANEL_ID} input[type="text"], #${PANEL_ID} input[type="number"], #${PANEL_ID} select, #${PANEL_ID} textarea { width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid #53627a; border-radius: 8px; background: #0f1724; color: #fff; } #${PANEL_ID} textarea { resize: vertical; } #${PANEL_ID} .zf-checkbox { display: flex; align-items: center; gap: 8px; } #${PANEL_ID} .zf-checkbox input { width: auto; margin: 0; } #${PANEL_ID} .zf-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; } #${PANEL_ID} .zf-actions button { padding: 8px 10px; background: #2d7ef7; color: #fff; } #${PANEL_ID} .zf-actions .zf-danger { background: #e05050; } #${PANEL_ID} .zf-status { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.08); color: #9fe39f; } #${PANEL_ID} .zf-license { margin-top: 10px; font-size: 12px; line-height: 1.5; color: #cbd5e1; } #${PANEL_ID} .zf-progress { margin-top: 10px; } #${PANEL_ID} .zf-progress-text { font-size: 12px; color: #dbeafe; margin-bottom: 6px; } #${PANEL_ID} .zf-progress-bar { width: 100%; height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.12); overflow: hidden; } #${PANEL_ID} .zf-progress-fill { width: 0; height: 100%; background: linear-gradient(90deg, #34d399, #60a5fa); transition: width 0.25s ease; } #${PANEL_ID} .zf-task-list { margin-top: 10px; max-height: 220px; overflow: auto; padding-right: 4px; } #${PANEL_ID} .zf-task-item { padding: 8px 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); margin-bottom: 8px; } #${PANEL_ID} .zf-task-item[data-status="running"] { background: rgba(59, 130, 246, 0.18); } #${PANEL_ID} .zf-task-item[data-status="done"] { background: rgba(34, 197, 94, 0.18); } #${PANEL_ID} .zf-task-item[data-status="error"] { background: rgba(239, 68, 68, 0.18); } #${PANEL_ID} .zf-task-title { font-weight: 600; color: #f8fafc; } #${PANEL_ID} .zf-task-meta, #${PANEL_ID} .zf-task-detail { margin-top: 4px; font-size: 12px; color: #cbd5e1; } #${PANEL_ID} .zf-task-meta { display: flex; justify-content: space-between; gap: 8px; } #${PANEL_ID} .zf-license a { color: #7ec7ff; } #${PANEL_ID} .zf-status[data-error="1"] { color: #ff9d9d; } `); } function createPanel() { if (document.getElementById(PANEL_ID)) { return; } injectStyles(); const panel = document.createElement('div'); panel.id = PANEL_ID; panel.innerHTML = buildPanelHtml(); document.body.appendChild(panel); const collapseBtn = panel.querySelector('.zf-collapse'); collapseBtn.addEventListener('click', () => { panel.classList.toggle('zf-collapsed'); collapseBtn.textContent = panel.classList.contains('zf-collapsed') ? '展开' : '收起'; }); panel.querySelector('[data-action="save-config"]').addEventListener('click', () => { const config = saveConfig(getCurrentConfigFromPanel()); updatePanelForm(config); setStatus('配置已保存'); }); panel.querySelector('[data-action="start-auto"]').addEventListener('click', async () => { await safeRun(async () => { await ensureBlogVisited(); const config = saveConfig(getCurrentConfigFromPanel()); await processBatch(config.actionMode); }); }); panel.querySelector('[data-action="stop"]').addEventListener('click', () => { state.stopRequested = true; state.running = false; setStatus('已请求停止'); }); updatePanelForm(loadConfig()); renderTaskBoard(); } async function safeRun(task) { try { await task(); } catch (error) { console.error('[正方一键评价] ', error); setStatus(error.message || '执行失败', true); } } async function ensureBlogVisited() { if (state.blogVisitedForThisRun) { return; } window.open(BLOG_URL, '_blank', 'noopener,noreferrer'); state.blogVisitedForThisRun = true; setStatus('已打开 LMINYO 博客,本次使用已授权'); await sleep(300); } function getGridApi() { const $ = getPageJQuery(); if (!$) { throw new Error('页面 jQuery 尚未加载'); } const grid = $('#tempGrid'); if (!grid || !grid.length) { throw new Error('未找到评价课程列表'); } return { $, grid }; } function isElementVisible(node) { if (!node || typeof node !== 'object' || node.nodeType !== 1) { return false; } const style = window.getComputedStyle(node); return style.display !== 'none' && style.visibility !== 'hidden'; } function getCurrentGridPage() { const { grid } = getGridApi(); const value = typeof grid.jqGrid === 'function' ? grid.jqGrid('getGridParam', 'page') : 1; return Math.max(1, Number(value) || 1); } function getLastGridPage() { const { grid } = getGridApi(); const value = typeof grid.jqGrid === 'function' ? grid.jqGrid('getGridParam', 'lastpage') : 1; return Math.max(1, Number(value) || 1); } async function goToGridPage(targetPage) { const { grid } = getGridApi(); const expectedPage = Math.max(1, Number(targetPage) || 1); if (getCurrentGridPage() === expectedPage) { return; } if (typeof grid.jqGrid !== 'function') { throw new Error('当前表格不支持分页跳转'); } grid.jqGrid('setGridParam', { page: expectedPage }); grid.trigger('reloadGrid', [{ page: expectedPage }]); await waitFor(() => { const loadingNode = document.querySelector('#load_tempGrid, #lui_tempGrid'); return getCurrentGridPage() === expectedPage && !isElementVisible(loadingNode); }, 15000, 250); await sleep(300); } function getRows() { const { grid } = getGridApi(); const ids = typeof grid.getDataIDs === 'function' ? grid.getDataIDs() : []; const rows = []; const currentPage = getCurrentGridPage(); for (const id of ids) { const data = typeof grid.getRowData === 'function' ? grid.getRowData(id) : {}; rows.push(Object.assign({ __rowId: id, __page: currentPage }, data || {})); } return rows; } async function collectAllRowsAcrossPages() { const originalPage = getCurrentGridPage(); const lastPage = getLastGridPage(); const rowMap = new Map(); for (let page = 1; page <= lastPage; page += 1) { await goToGridPage(page); const pageRows = getRows(); for (const row of pageRows) { const taskKey = getRowTaskKey(row); if (!rowMap.has(taskKey)) { rowMap.set(taskKey, row); } } } await goToGridPage(originalPage); return Array.from(rowMap.values()); } function normalizeText(text) { return String(text || '').replace(/\s+/g, ''); } function isSubmittedRow(row) { if (row && row.tjzt !== undefined && row.tjzt !== null && String(row.tjzt).trim() !== '') { return String(row.tjzt).trim() === '1'; } const statusText = normalizeText(row && row.tjztmc); if (!statusText) { return false; } // 只有明确“已提交”或者“已评完”才算已提交,“未评完”不算 return /提交|已提交|已评完/.test(statusText) && !/未评|未评完/.test(statusText); } function isNeverSavedRow(row) { if (row && row.tjzt !== undefined && row.tjzt !== null && String(row.tjzt).trim() !== '') { return String(row.tjzt).trim() === '-1'; } const statusText = normalizeText(row && row.tjztmc); return /未评/.test(statusText) && !/已评完/.test(statusText); } function getCandidates(action) { const rows = getRows(); // #region debug-point C:candidate-scan debugReport('C', '正方一键评价.user.js:getCandidates', 'scan candidates', { action, totalRows: rows.length, sample: rows.slice(0, 3).map((row) => ({ rowId: row.__rowId, tjzt: row.tjzt, tjztmc: row.tjztmc, kcmc: row.kcmc, jzgmc: row.jzgmc })) }); // #endregion // 不管是保存还是提交,都处理所有未提交的课程(未评、未评完都算) const result = []; for (const row of rows) { if (!isSubmittedRow(row)) { result.push(row); } } // #region debug-point C:candidate-result debugReport('C', '正方一键评价.user.js:getCandidates', 'candidates resolved', { action, resultCount: result.length, firstRow: result[0] ? { rowId: result[0].__rowId, tjzt: result[0].tjzt, tjztmc: result[0].tjztmc, kcmc: result[0].kcmc, jzgmc: result[0].jzgmc } : null }); // #endregion return result; } function getCurrentEvaluationKey() { const currentBody = document.querySelector('.xspj-body[data-jxb_id]'); if (!currentBody) { return ''; } return currentBody.getAttribute('data-jxb_id') || ''; } function dispatchNativeEvent(element, type) { if (!element) { return; } element.dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); } function nativeClick(element) { if (!element) { return; } ['pointerdown', 'mousedown', 'mouseup', 'click'].forEach((type) => { try { element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); } catch (error) { dispatchNativeEvent(element, type); } }); } function getReadyScoreRows() { return Array.from(document.querySelectorAll('tr.tr-xspj')); } async function waitForEvaluationReady(timeoutMs = 15000) { return waitFor(() => { const loadingNode = document.querySelector('#loading_status'); if (loadingNode) { return false; } const rowCount = document.querySelectorAll('tr.tr-xspj').length; const totalRadioCount = document.querySelectorAll('input.radio-pjf, tr.tr-xspj input[type="radio"]').length; return rowCount > 0 && totalRadioCount > 0 ? getReadyScoreRows() : false; }, timeoutMs, 250); } async function selectRow(row) { const { $, grid } = getGridApi(); const beforeKey = getCurrentEvaluationKey(); const beforeSelectedRowId = getSelectedRowId(); const targetRowId = String(row.__rowId); const targetEvaluationKey = String(row.jxb_id || ''); // #region debug-point A:select-row-start debugReport('A', '正方一键评价.user.js:selectRow', 'select row start', { targetRowId, targetEvaluationKey, beforeKey, beforeSelectedRowId, course: row.kcmc, teacher: row.jzgmc }); // #endregion // 正方实际切课逻辑在 onSelectRow 内部通过 panel_content.load 完成。 // 直接走同一条 load 链,比模拟点击表格行更稳定。 // #region debug-point A:select-row-load-target debugReport('A', '正方一键评价.user.js:selectRow', 'loading target row directly', { targetRowId, targetEvaluationKey }); // #endregion if (typeof window !== 'undefined') { try { window.select_id = targetRowId; } catch (error) { // ignore } } const panelContent = $('#panel_content'); const loadingHtml = '<p id="loading_status" class="text-center header smaller lighter" style="margin-top:150px;"><i class="icon-spinner icon-spin orange bigger-500"></i><br>加载中...</p>'; panelContent.html(loadingHtml); $('#progress_loading').attr('aria-valuenow', 0).css('width', '0%'); $('#btn_xspj_bc').removeClass('disabled').prop({ disabled: false }); $('#btn_xspj_tj').removeClass('disabled').prop({ disabled: false }); await new Promise((resolve, reject) => { panelContent.load(`${window._path || ''}/xspjgl/xspj_cxXspjDisplay.html`, { jxb_id: row.jxb_id, kch_id: row.kch_id, xsdm: row.xsdm, jgh_id: row.jgh_id, tjzt: row.tjzt, pjmbmcb_id: row.pjmbmcb_id, sfcjlrjs: row.sfcjlrjs }, (responseText, textStatus) => { if (textStatus && textStatus !== 'success') { reject(new Error(`加载评价页面失败: ${textStatus}`)); return; } if ($('#xxdm').val() === '10076') { $('#jsxm').text(`${$.founded(row.jgmc) ? row.jgmc : '无学院'}/${$.founded(row.zcmc) ? row.zcmc : '无职称'}/${row.jzgmc}`); } else { $('#jsxm').text(row.jzgmc || ''); } $(panelContent).find('[data-toggle="tooltip"]').tooltip('destroy').tooltip({ container: panelContent }); resolve(); }); }); // 必须等到真正的评价行渲染完成,不能只看高亮行 await waitFor(() => { const loadingNode = document.querySelector('#loading_status'); const rowCount = document.querySelectorAll('tr.tr-xspj').length; const totalRadioCount = document.querySelectorAll('input.radio-pjf, tr.tr-xspj input[type="radio"]').length; const currentEvaluationKey = getCurrentEvaluationKey(); const keyMatched = !targetEvaluationKey || currentEvaluationKey === targetEvaluationKey; const ok = keyMatched && !loadingNode && rowCount > 0 && totalRadioCount > 0; // #region debug-point A:select-row-wait debugReport('A', '正方一键评价.user.js:selectRow', 'waiting check', { selectedRowId: getSelectedRowId(), targetRowId, currentEvaluationKey, targetEvaluationKey, keyMatched, radioCount: totalRadioCount, rowCount, hasLoadingNode: !!loadingNode, ok }); // #endregion return ok; }, 15000, 300); // 额外再等一小会儿,让页面完全渲染 await sleep(800); // #region debug-point A:select-row-done debugReport('A', '正方一键评价.user.js:selectRow', 'select row resolved', { targetRowId, afterKey: getCurrentEvaluationKey(), selectedRowId: getSelectedRowId(), radioCount: document.querySelectorAll('.radio-pjf').length }); // #endregion } function getSelectedRowId() { const selectedRow = document.querySelector('#tempGrid .ui-state-highlight, #tempGrid .success, #tempGrid tr[aria-selected="true"]'); return selectedRow ? selectedRow.id : ''; } function getRadioScoreValue(radio) { if (!radio) { return NaN; } const textCandidates = [ radio.getAttribute ? radio.getAttribute('data-dyf') : '', radio.dataset ? radio.dataset.dyf : '', radio.getAttribute ? radio.getAttribute('data-pjf') : '', radio.value, radio.getAttribute ? radio.getAttribute('value') : '', radio.closest ? (radio.closest('label') ? radio.closest('label').textContent : '') : '', radio.parentElement ? radio.parentElement.textContent : '', radio.textContent ]; for (const candidate of textCandidates) { const rawValue = String(candidate || '').trim(); if (!rawValue) { continue; } const matched = rawValue.match(/\d+(?:\.\d+)?/); if (matched) { return parseFloat(matched[0]); } } return NaN; } function pickRadioByScore(radios, expectedScore) { // #region debug-point B:pickRadio-start debugReport('B', '正方一键评价.user.js:pickRadioByScore', 'pick radio start', { expectedScore, radiosCount: radios.length, dyfValues: Array.from(radios).map((r) => ({ dyf: r && r.dataset ? r.dataset.dyf : '', attr: r && r.getAttribute ? r.getAttribute('data-dyf') : '', value: r ? r.value : '' })) }); // #endregion const radioList = Array.from(radios); let items = []; for (let index = 0; index < radioList.length; index += 1) { const radio = radioList[index]; const score = getRadioScoreValue(radio); if (!Number.isNaN(score)) { items.push({ element: radio, score, attrScore: radio && radio.getAttribute ? radio.getAttribute('data-dyf') : '', index }); } } items.sort((a, b) => b.score - a.score); if (!items.length) { items = []; for (let index = 0; index < radioList.length; index += 1) { const radio = radioList[index]; items.push({ element: radio, score: radioList.length - index, attrScore: radio && radio.getAttribute ? radio.getAttribute('data-dyf') : '', index }); } // #region debug-point B:pickRadio-no-items debugReport('B', '正方一键评价.user.js:pickRadioByScore', 'fallback to index-based score ordering', { radiosCount: radios.length }); // #endregion } let picked; const exact = items.find((item) => String(item.attrScore) === String(expectedScore) || item.score === expectedScore); if (exact) { picked = exact.element; } else { const lower = items.find((item) => item.score <= expectedScore); if (lower) { picked = lower.element; } else { picked = items[0].element; } } // #region debug-point B:pickRadio-picked debugReport('B', '正方一键评价.user.js:pickRadioByScore', 'picked radio', { expectedScore, pickedDyf: picked.dataset.dyf, pickedName: picked.name }); // #endregion return picked; } function getSortedScorePool(radios) { const pool = []; for (const radio of Array.from(radios)) { const score = getRadioScoreValue(radio); if (!Number.isNaN(score)) { pool.push(score); } } pool.sort((a, b) => b - a); if (pool.length) { return Array.from(new Set(pool)); } return Array.from({ length: Array.from(radios).length }, (_, index) => Array.from(radios).length - index); } function chooseScoreForRow(radios, config, rowTypeId, typeCounterMap) { const availableScores = getSortedScorePool(radios); if (!availableScores.length) { return Number(config.fixedScore) || 100; } const currentIndex = typeCounterMap.get(rowTypeId) || 0; typeCounterMap.set(rowTypeId, currentIndex + 1); if (config.scoreMode === 'fixed') { const fixedScore = Number(config.fixedScore); const exact = availableScores.find((score) => score === fixedScore); return exact || availableScores[0]; } const configuredScores = getScoreCandidates(config) .filter((score) => availableScores.includes(score)); const pool = configuredScores.length ? configuredScores : availableScores; if (config.scoreMode === 'rotate') { return pool[currentIndex % pool.length]; } return pool[Math.floor(Math.random() * pool.length)]; } function checkRadio(radio, $) { if (!radio) { // #region debug-point B:checkRadio-null debugReport('B', '正方一键评价.user.js:checkRadio', 'no radio to check', { radio }); // #endregion return; } const safeName = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(radio.name) : radio.name.replace(/"/g, '\\"'); const sameNameRadios = document.querySelectorAll(`input[type="radio"][name="${safeName}"]`); // #region debug-point B:checkRadio-sameName debugReport('B', '正方一键评价.user.js:checkRadio', 'checking radio', { name: radio.name, dyf: radio.dataset.dyf, sameNameCount: sameNameRadios.length }); // #endregion sameNameRadios.forEach((item) => { item.checked = false; item.removeAttribute('checked'); if ($) { $(item).prop('checked', false); $(item).removeAttr('checked'); } }); radio.checked = true; radio.setAttribute('checked', 'checked'); if ($) { $(radio).prop('checked', true); $(radio).attr('checked', 'checked'); } const label = radio.closest('label'); if (label) { nativeClick(label); } nativeClick(radio); if (typeof radio.click === 'function') { try { radio.click(); } catch (error) { // ignore and continue with manual events below } } if ($) { $(radio).trigger('click'); $(radio).trigger('input'); $(radio).trigger('change'); } dispatchNativeEvent(radio, 'input'); dispatchNativeEvent(radio, 'change'); const checkedRadio = document.querySelector(`input[type="radio"][name="${safeName}"]:checked`); // #region debug-point B:checkRadio-result debugReport('B', '正方一键评价.user.js:checkRadio', 'radio checked result', { radioChecked: radio.checked, radioPropChecked: $ ? $(radio).prop('checked') : 'no $', checkedDyfNow: checkedRadio ? checkedRadio.getAttribute('data-dyf') : '', checkedExistsNow: !!checkedRadio }); // #endregion } function fillCurrentEvaluation(config) { const $ = getPageJQuery(); const currentKey = getCurrentEvaluationKey(); // #region debug-point B:fill-start debugReport('B', '正方一键评价.user.js:fillCurrentEvaluation', 'fill start', { currentKey, config }); // #endregion if (!$) { throw new Error('页面 jQuery 不可用'); } const scoreRows = getReadyScoreRows(); const rowsWithRadios = []; for (const row of scoreRows) { if (row && typeof row.querySelectorAll === 'function' && row.querySelectorAll('input[type="radio"][data-dyf], input.radio-pjf, input[type="radio"]').length > 0) { rowsWithRadios.push(row); } } // #region debug-point B:fill-row-scan debugReport('B', '正方一键评价.user.js:fillCurrentEvaluation', 'row scan result', { rowCount: scoreRows.length, rowsWithRadiosCount: rowsWithRadios.length, rowSamples: scoreRows.slice(0, 3).map((row, index) => ({ index, className: row.className, nameViaQuerySelector: typeof row.querySelector === 'function' ? !!row.querySelector('input.radio-pjf') : 'no-querySelector', radioCountViaQuerySelectorAll: typeof row.querySelectorAll === 'function' ? row.querySelectorAll('input.radio-pjf').length : -1, radioCountViaBroadSelector: typeof row.querySelectorAll === 'function' ? row.querySelectorAll('input[type="radio"][data-dyf], input.radio-pjf, input[type="radio"]').length : -1, htmlSnippet: String(row.innerHTML || '').slice(0, 160) })) }); // #endregion if (!rowsWithRadios.length) { // #region debug-point B:fill-no-radio debugReport('B', '正方一键评价.user.js:fillCurrentEvaluation', 'no radio groups found', { rowCount: scoreRows.length, textareaCount: document.querySelectorAll('textarea.input-zgpj').length, currentKey: getCurrentEvaluationKey() }); // #endregion throw new Error('当前课程还没加载完成'); } const typeCounterMap = new Map(); let checkedCount = 0; for (let rowIndex = 0; rowIndex < rowsWithRadios.length; rowIndex += 1) { const row = rowsWithRadios[rowIndex]; const group = Array.from(row.querySelectorAll('input[type="radio"][data-dyf], input.radio-pjf')); const rowTypeId = row.getAttribute('data-pfdjdmb_id') || `row_${rowIndex}`; const wantedScore = chooseScoreForRow(group, config, rowTypeId, typeCounterMap); const targetRadio = pickRadioByScore(group, wantedScore); if (targetRadio) { checkRadio(targetRadio, $); const safeName = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(targetRadio.name) : targetRadio.name.replace(/"/g, '\\"'); const checkedRadio = document.querySelector(`input[type="radio"][name="${safeName}"]:checked`); if (checkedRadio) { checkedCount += 1; } } } // #region debug-point B:radio-fill-summary debugReport('B', '正方一键评价.user.js:fillCurrentEvaluation', 'radio fill summary', { groupCount: rowsWithRadios.length, checkedCount, wantedScores: getScoreCandidates(config), currentKey: getCurrentEvaluationKey() }); // #endregion const teacherComments = splitLines(config.teacherComments); const courseComments = splitLines(config.courseComments); let commentCount = 0; document.querySelectorAll('textarea.input-zgpj, textarea[name*="zgpj"], textarea[class*="zgpj"]').forEach((textarea, index) => { const panel = textarea.closest('.panel-pjdx'); const targetType = panel ? String(panel.getAttribute('data-pjdxdm') || '') : ''; const commentPool = targetType === '02' ? courseComments : teacherComments; const pickedComment = chooseFromList(commentPool, index, config.commentMode) || '整体体验很好,感谢老师的付出。'; const finalComment = ensureCommentLength(pickedComment, textarea); textarea.value = finalComment; dispatchNativeEvent(textarea, 'input'); dispatchNativeEvent(textarea, 'change'); $(textarea).trigger('input'); $(textarea).trigger('propertychange'); $(textarea).trigger('change'); commentCount += 1; }); // #region debug-point B:comment-fill-summary debugReport('B', '正方一键评价.user.js:fillCurrentEvaluation', 'comment fill summary', { commentCount, teacherCommentPool: teacherComments.length, courseCommentPool: courseComments.length, currentKey: getCurrentEvaluationKey() }); // #endregion } function findDialogConfirmButton() { const selectors = [ '.bootbox.modal.in .btn', '.modal.in .btn', '.modal[style*="display: block"] .btn', '.ui-dialog button', '.ui-jqdialog[aria-hidden="false"] button', '.ui-jqdialog[aria-hidden="false"] .ui-jqdialog-titlebar-close' ]; const buttons = selectors .flatMap((selector) => { try { return Array.from(document.querySelectorAll(selector)); } catch (error) { debugReport('D', '正方一键评价.user.js:findDialogConfirmButton', 'invalid dialog selector skipped', { selector, error: error.message }); return []; } }) .filter((button) => { const isElement = button && typeof button === 'object' && button.nodeType === 1; if (!isElement) { debugReport('D', '正方一键评价.user.js:findDialogConfirmButton', 'non-element dialog candidate skipped', { valueType: typeof button, valueString: String(button) }); return false; } try { const style = window.getComputedStyle(button); return style.display !== 'none' && style.visibility !== 'hidden'; } catch (error) { debugReport('D', '正方一键评价.user.js:findDialogConfirmButton', 'getComputedStyle failed for candidate', { tagName: button.tagName, className: button.className || '', error: error.message }); return false; } }); const positivePatterns = /确定|确认|继续|提交|保存|是|ok|yes|完成/i; const negativePatterns = /取消|关闭|否|no/i; const preferred = buttons.find((button) => { const text = normalizeText(button.textContent || button.getAttribute('title') || ''); return positivePatterns.test(text) && !negativePatterns.test(text); }); return preferred || null; } async function settleDialogs(config, maxWaitMs = 12000) { if (!config.autoConfirmDialog) { return; } const start = Date.now(); let lastActionAt = Date.now(); while (Date.now() - start < maxWaitMs) { const button = findDialogConfirmButton(); if (button) { button.click(); lastActionAt = Date.now(); await sleep(config.dialogDelayMs); continue; } if (Date.now() - lastActionAt > 1200) { break; } await sleep(200); } } function triggerActionButton(action) { const $ = getPageJQuery(); const selector = action === 'save' ? '#btn_xspj_bc' : '#btn_xspj_tj'; const button = document.querySelector(selector); if (!button) { throw new Error(`未找到${action === 'save' ? '保存' : '提交'}按钮`); } const directAction = action === 'save' ? window.doBc : window.doTj; if (typeof directAction === 'function') { directAction.call(window); // #region debug-point D:trigger-action debugReport('D', '正方一键评价.user.js:triggerActionButton', 'action triggered by page native function', { action, selector, currentKey: getCurrentEvaluationKey() }); // #endregion return; } $(button).data('enter', '1'); $(button).trigger('mouseenter'); dispatchNativeEvent(button, 'mouseenter'); nativeClick(button); $(button).trigger('click'); // #region debug-point D:trigger-action debugReport('D', '正方一键评价.user.js:triggerActionButton', 'action button triggered', { action, selector, disabled: !!button.disabled, enterFlag: $(button).data('enter'), currentKey: getCurrentEvaluationKey() }); // #endregion } async function processCurrent(action) { await ensureBlogVisited(); const config = saveConfig(getCurrentConfigFromPanel()); try { await waitForEvaluationReady(); fillCurrentEvaluation(config); setStatus(action === 'save' ? '正在保存当前课程...' : '正在提交当前课程...'); await sleep(180); triggerActionButton(action); await settleDialogs(config); await sleep(config.submitCooldownMs); setStatus(action === 'save' ? '当前课程已尝试保存' : '当前课程已尝试提交'); } finally { state.blogVisitedForThisRun = false; } } async function processBatch(action) { const config = saveConfig(getCurrentConfigFromPanel()); state.running = true; state.stopRequested = false; state.lastBatchRowKey = ''; state.sameRowRetry = 0; state.processedSaveRowKeys = new Set(); state.lastCommentPickMap = new Map(); try { setStatus('正在扫描全部课程...'); const allRows = await collectAllRowsAcrossPages(); setTaskItems(allRows); const candidates = []; for (const row of allRows) { if (!isSubmittedRow(row)) { candidates.push(row); } } if (!candidates.length) { setStatus('全部课程都已经完成'); return; } for (let index = 0; index < candidates.length; index += 1) { if (state.stopRequested) { break; } const row = candidates[index]; const currentKey = `${row.__rowId}__${row.jxb_id || ''}`; const taskKey = getRowTaskKey(row); const displayName = getRowDisplayName(row); updateTaskItem(taskKey, 'running', `处理中 ${index + 1}/${candidates.length}`); setStatus(`处理中 (${index + 1}/${candidates.length}): ${displayName}`); // #region debug-point E:batch-loop debugReport('E', '正方一键评价.user.js:processBatch', 'batch loop tick', { action, displayName, rowId: row.__rowId, jxbId: row.jxb_id, jghId: row.jgh_id, step: index + 1, remainingCandidates: candidates.length - index }); // #endregion try { await selectRow(row); await sleep(config.switchDelayMs); await waitForEvaluationReady(); fillCurrentEvaluation(config); await sleep(220); triggerActionButton(action); await settleDialogs(config); await sleep(config.submitCooldownMs); if (action === 'save') { state.processedSaveRowKeys.add(currentKey); } updateTaskItem(taskKey, 'done', action === 'save' ? '已保存' : '已提交'); } catch (error) { updateTaskItem(taskKey, 'error', error.message || '处理失败'); throw new Error(`${displayName} 处理失败:${error.message || '未知错误'}`); } } if (state.stopRequested) { setStatus('批量任务已停止'); return; } setStatus(action === 'save' ? '批量保存完成' : '批量提交完成'); } finally { state.running = false; state.blogVisitedForThisRun = false; } } function registerMenu() { GM_registerMenuCommand('打开/刷新 正方一键评价面板', () => { createPanel(); updatePanelForm(loadConfig()); setStatus('面板已刷新'); }); GM_registerMenuCommand('重置 正方一键评价配置', () => { saveConfig(DEFAULT_CONFIG); createPanel(); updatePanelForm(loadConfig()); setStatus('配置已重置'); }); } async function bootstrap() { await waitFor(() => isReady(), 15000, 250); createPanel(); registerMenu(); setStatus('已就绪,先点“保存配置”再使用更稳'); } bootstrap().catch((error) => { console.error('[正方一键评价] 初始化失败', error); }); })(); ``` 最后修改:2026 年 07 月 02 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 1 如果觉得我的文章对你有用,请随意赞赏