// ==UserScript==
// @name 山西农大教务评教自动化
// @namespace sxau-xspj-autopilot
// @version 1.2
// @description 自动完成山西农业大学教务系统的学生评教:差异化打分 + 主观题 + 自动提交 + 进度持久化 + 悬浮控制面板
// @author Dan-Lin616
// @match http://jwglxt.sxau.edu.cn/*
// @match https://jwglxt.sxau.edu.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @run-at document-idle
// @license MIT
// ==/UserScript==
/*
* ========== 使用说明 ==========
* 1. 把脚本保存成 .user.js,拖进 Tampermonkey(油猴)/ ScriptCat(脚本猫) 安装
* 2. 浏览器登录 https://jwglxt.sxau.edu.cn/jsxsd/
* 3. 进入「教学评价 → 学生评价」(首次需手动展开本期评教活动)
* 4. 列表页加载后,悬浮面板会自动出现,点击「开始」即可
* 5. 脚本会:扫待评课程 → 自动跳第一条 → 填表 → 提交 → 回列表 → 跳下一条
* 6. 主观评价话术在 CONFIG 里改;想暂停就点「急停」
*
* ⚠️ 关键规则(脚本已内置遵守):
* "不得将所有指标都评为相同等级,否则无法完成评教"
* → 策略:9 项选「优秀」+ 1 项(随机)选「良好」
*
* 跨页面状态存在 GM_setValue,崩了重开会自动续跑
* ==============================
*/
(() => {
'use strict';
// ============= 可调参数 =============
const CONFIG = {
// 主观评价话术池(每门课随机抽一句)
SUBJECTIVE_POOL: [
'老师认真负责',
'老师备课充分,重点突出,受益匪浅',
'老师授课条理清晰,课堂氛围活跃',
'老师注重启发式教学,引导我们思考',
'老师严格要求又关怀学生,教学效果显著'
],
// 评教选项:每条 [text, weight_label]
// 这里的 value 不重要(脚本按 id 后缀 _1/_2/_3/_4/_5 选)
OPTIONS: [
{ idx: 1, label: '优秀' },
{ idx: 2, label: '良好' },
{ idx: 3, label: '中等' },
{ idx: 4, label: '合格' },
{ idx: 5, label: '差' }
],
// 默认全选 OPTION 的 idx(1=优秀)
DEFAULT_LEVEL: 1,
// 随机把哪一项降到下一档(每门课随机一项 idx=2 = 良好)
// 这样保证:全 9 优 + 1 良 ≠ 全同,过"请不要选相同一项"校验
DIFFER_INDEX: 2,
// 操作间隔(毫秒):表单填好后,提交前等一下
ACTION_DELAY: () => 1500 + Math.random() * 1500, // 1.5~3.0s
// 跨页面跳转后,等下页面完全加载再开干的额外等待
NAV_DELAY: () => 2000 + Math.random() * 2000 // 2.0~4.0s
};
// ============= 工具函数 =============
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const rand = (n) => Math.floor(Math.random() * n);
const pick = (arr) => arr[rand(arr.length)];
const STATE_KEY = 'sxau_xspj_state';
const loadState = () => {
try {
const raw = GM_getValue(STATE_KEY, null);
return raw ? JSON.parse(raw) : null;
} catch (e) {
console.warn('[SXAU] loadState parse error', e);
return null;
}
};
const saveState = (s) => {
try {
GM_setValue(STATE_KEY, JSON.stringify(s));
} catch (e) { console.error('[SXAU] saveState error', e); }
};
const clearState = () => GM_deleteValue(STATE_KEY);
const log = (...args) => console.log('%c[SXAU评教]', 'color:#0a8;color:bold', ...args);
// ============= 悬浮控制面板 =============
GM_addStyle(`
#sxau-panel {
position: fixed; right: 16px; bottom: 16px; z-index: 999999;
background: #fff; color: #333;
border: 1px solid #e4e7eb; border-radius: 8px;
padding: 12px 14px; min-width: 220px; max-width: 300px;
font: 12px/1.5 -apple-system, "Microsoft YaHei", sans-serif;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
#sxau-panel.dragging { user-select: none; cursor: grabbing; box-shadow: 0 8px 24px rgba(0,0,0,0.15); }
#sxau-panel h4 {
margin: 0 0 8px; font-size: 13px; font-weight: 600; color: #222;
cursor: grab; padding: 2px 0; user-select: none;
}
#sxau-panel h4:active { cursor: grabbing; }
#sxau-panel .row { display: flex; gap: 6px; margin: 8px 0 0; flex-wrap: wrap; }
#sxau-panel button {
background: #f5f6f7; color: #333; border: 1px solid #dcdfe6; border-radius: 4px;
padding: 5px 10px; cursor: pointer; font-size: 12px; transition: all .15s;
}
#sxau-panel button:hover { background: #ecf5ff; color: #409eff; border-color: #c6e2ff; }
#sxau-panel button.primary { background: #409eff; color: #fff; border-color: #409eff; }
#sxau-panel button.primary:hover { background: #66b1ff; border-color: #66b1ff; color: #fff; }
#sxau-panel button.danger { color: #f56c6c; border-color: #fbc4c4; background: #fef0f0; }
#sxau-panel button.danger:hover { background: #f78989; color: #fff; border-color: #f78989; }
#sxau-panel .progress { font-size: 12px; color: #666; margin: 4px 0; }
#sxau-panel .progress b { color: #409eff; font-weight: 600; }
#sxau-panel .current { font-size: 12px; color: #333; margin: 4px 0; word-break: break-all; }
#sxau-panel .status { font-size: 11px; color: #999; margin-top: 4px; }
`);
// ============= 面板拖拽 =============
const enableDrag = (panel) => {
const handle = panel.querySelector('h4');
if (!handle) return;
let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0;
handle.addEventListener('mousedown', (e) => {
// 点标题区域才能拖(避免误触按钮)
if (e.target.tagName === 'BUTTON') return;
dragging = true;
panel.classList.add('dragging');
const rect = panel.getBoundingClientRect();
// 切换到 left/top 定位,便于拖拽
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
startX = e.clientX; startY = e.clientY;
startLeft = rect.left; startTop = rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let nx = startLeft + dx;
let ny = startTop + dy;
// 限制在视口内
const maxX = window.innerWidth - panel.offsetWidth;
const maxY = window.innerHeight - panel.offsetHeight;
nx = Math.max(0, Math.min(nx, maxX));
ny = Math.max(0, Math.min(ny, maxY));
panel.style.left = nx + 'px';
panel.style.top = ny + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
panel.classList.remove('dragging');
});
};
const renderPanel = (state) => {
const old = document.getElementById('sxau-panel');
if (old) old.remove();
const total = state?.total ?? 0;
const done = state?.done ?? 0;
const cur = state?.current || '—';
const div = document.createElement('div');
div.id = 'sxau-panel';
div.innerHTML = `
评教助手
进度 ${done} / ${total}
${cur}
${state?.phase || '空闲'}
`;
document.body.appendChild(div);
document.getElementById('sxau-start').onclick = () => bootstrap(true);
document.getElementById('sxau-stop').onclick = () => {
const s = loadState() || {};
s.stop = true; s.phase = '已急停';
saveState(s);
setStatus('已急停(点「开始」恢复)');
};
document.getElementById('sxau-reset').onclick = () => {
if (confirm('确定要清空所有进度?下次将从列表页重新开始扫描。')) {
clearState();
location.reload();
}
};
document.getElementById('sxau-dry').onclick = () => {
const s = loadState() || {};
s.dryRun = true; s.stop = false;
saveState(s);
bootstrap(true);
};
enableDrag(div);
};
const setStatus = (msg) => {
const el = document.getElementById('sxau-status');
if (el) el.textContent = '状态:' + msg;
log(msg);
};
// ============= 路由 =============
const ROUTES = {
isList: () => location.pathname.includes('/xspj/xspj_list.do'),
isEdit: () => location.pathname.includes('/xspj/xspj_edit.do'),
isSave: () => location.pathname.includes('/xspj/xspj_save.do')
};
// ============= 入口:开始 / 续跑 =============
const bootstrap = (userTriggered = false) => {
const s = loadState() || {};
s.stop = false;
if (!s.dryRun) s.dryRun = false;
if (ROUTES.isList()) {
// 列表页:扫待评课程,构建队列,跳第一条
const queue = scanUnfinishedCourses();
if (queue.length === 0) {
if (userTriggered) alert('本学期没有待评课程了 🎉');
s.phase = '全部完成';
s.current = '(无)';
saveState(s);
renderPanel(s);
return;
}
s.queue = queue;
s.idx = 0;
s.total = queue.length;
s.done = s.done || 0;
s.phase = '待跳首页';
saveState(s);
renderPanel(s);
jumpNext(s);
}
else if (ROUTES.isEdit()) {
// 编辑页:填表 → 提交
s.phase = '填表中';
s.current = decodeURIComponent(new URL(location.href).searchParams.get('jx0404id') || '') + ' · ' +
(document.querySelector('table.layui-table th')?.innerText?.match(/课程名称:([^\s]+)/)?.[1] || '');
saveState(s);
renderPanel(s);
fillAndSubmit();
}
else {
log('当前不在评教页面。请进入「教学评价 → 学生评价」列表页。');
}
};
// ============= 列表页:扫未评课程 =============
const scanUnfinishedCourses = () => {
// 未评链接的特征:href 含 xspj_edit.do 且不含 type=view
// 行内的隐藏 input issavestr == "否"
const links = Array.from(document.querySelectorAll('a[href*="xspj_edit.do"]'));
const list = [];
for (const a of links) {
const href = a.getAttribute('href') || '';
if (href.includes('type=view')) continue; // 已评
if (a.textContent.trim() !== '评价') continue; // 操作列文本必须为"评价"
// 反查行:找同一行内的 issavestr 是否为"否"
let tr = a.closest('tr');
if (!tr) continue;
const savedFlag = tr.querySelector('input[name="issavestr"]')?.value;
if (savedFlag === '是') continue;
// 提取课程名/教师,便于日志
const tds = tr.querySelectorAll('td');
const courseName = tds[2]?.innerText.trim() || '?';
const teacher = tds[3]?.innerText.trim() || '?';
list.push({ href, courseName, teacher });
}
log(`扫到 ${list.length} 门待评课程`);
return list;
};
const jumpNext = async (s) => {
if (s.stop) { setStatus('已急停'); return; }
if (s.idx >= s.queue.length) {
setStatus('✅ 全部完成!');
s.phase = '全部完成';
saveState(s);
renderPanel(s);
return;
}
const item = s.queue[s.idx];
s.current = `${item.courseName} · ${item.teacher}`;
s.phase = `跳转中 ${s.idx + 1}/${s.queue.length}`;
saveState(s);
renderPanel(s);
await sleep(CONFIG.NAV_DELAY());
if (s.stop) return;
log('跳转:', item.href);
location.href = item.href;
};
// ============= 编辑页:填表 + 提交 =============
const fillAndSubmit = async () => {
// 只读查看页(type=view)跳过
if (location.search.includes('type=view')) {
setStatus('这是只读查看页,跳过');
return;
}
await sleep(CONFIG.ACTION_DELAY());
const s = loadState() || {};
if (s.stop) { setStatus('已急停'); return; }
// 1) 收集所有指标序号(按 DOM 出现顺序)
const xhs = Array.from(document.querySelectorAll('input[name="pj06xh"]')).map(el => el.value);
if (xhs.length === 0) {
setStatus('⚠️ 未找到 pj06xh,可能表单已提交或结构变化');
return;
}
// 2) 默认全部优秀,再随机挑一项降为良好(满足差异化校验)
xhs.forEach(xh => {
const radio = document.getElementById(`pj0601id_${xh}_${CONFIG.DEFAULT_LEVEL}`);
if (radio && !radio.checked) {
radio.checked = true;
radio.dispatchEvent(new Event('click', { bubbles: true }));
radio.dispatchEvent(new Event('change', { bubbles: true }));
}
});
const diffIdx = xhs[rand(xhs.length)];
if (diffIdx !== undefined) {
const radio = document.getElementById(`pj0601id_${diffIdx}_${CONFIG.DIFFER_INDEX}`);
if (radio) {
radio.checked = true;
radio.dispatchEvent(new Event('click', { bubbles: true }));
radio.dispatchEvent(new Event('change', { bubbles: true }));
}
log(`差异化:第 ${diffIdx} 项选「良好」`);
}
// 3) 填主观题
const ta = document.querySelector('textarea[name="jynr"]');
if (ta) {
ta.value = pick(CONFIG.SUBJECTIVE_POOL);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
} else {
log('⚠️ 未找到 jynr 主观题框');
}
// 4) 客户端校验重做:保证不是全同(理论上不会触发,兜底)
const checked = xhs.map(xh => {
const all = Array.from(document.querySelectorAll(`input[name="pj0601id_${xh}"]`));
const c = all.find(r => r.checked);
return c ? c.id.split('_').pop() : null;
});
const distinct = new Set(checked).size;
if (distinct === 1) {
log('⚠️ 校验:全同,强制再降一项');
const k = rand(xhs.length);
const r = document.getElementById(`pj0601id_${xhs[k]}_${CONFIG.DIFFER_INDEX}`);
if (r) {
r.checked = true;
r.dispatchEvent(new Event('click', { bubbles: true }));
}
}
// 5) 提交
const issubmit = document.getElementById('issubmit');
if (issubmit) issubmit.value = '1';
const form = document.getElementById('Form1');
if (!form) {
setStatus('⚠️ 未找到 Form1,无法提交');
return;
}
if (s.dryRun) {
setStatus('【试跑】已填表,未提交。请点「重置」恢复。');
log('Dry run 模式:跳过提交');
return;
}
s.phase = '提交中';
saveState(s);
renderPanel(s);
// 用 fetch 代替 form.submit(),避免 ifrmHidden 加载服务端响应触发 alert
// 响应 HTML 不会被执行(fetch 只取文本),无人值守
const submitAsync = async () => {
try {
const fd = new FormData(form);
const body = new URLSearchParams();
for (const [k, v] of fd.entries()) body.append(k, v);
const resp = await fetch(form.action, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
credentials: 'same-origin',
redirect: 'follow'
});
log(`提交完成,HTTP ${resp.status}`);
} catch (e) {
log('提交异常:' + e.message);
}
// 跳回列表
const listUrl = s.listUrl || (
location.origin + '/jsxsd/xspj/xspj_list.do?' +
new URLSearchParams({
pj01id: document.getElementById('pj01id')?.value || '',
pj0502id: document.getElementById('pj0502id')?.value || '',
xnxq01id: document.getElementById('xnxq01id')?.value || '',
xstskz: '1', ifypjxx: '0', dqpageIndex: '1'
}).toString()
);
s.listUrl = listUrl;
s.idx = (s.idx || 0) + 1;
s.done = (s.done || 0) + 1;
saveState(s);
renderPanel(s);
setStatus('已提交,回列表');
setTimeout(() => location.href = listUrl, CONFIG.ACTION_DELAY());
};
await submitAsync();
};
// ============= 页面进入时:根据路由做事 =============
const init = () => {
// 拦截系统/服务端的弹窗,让流程无人值守继续
// 原因:xspj_save.do 的响应里嵌了 alert('提交成功'),会卡住自动化
window.alert = (msg) => { log('alert 拦截:' + msg); };
window.confirm = () => { log('confirm 拦截:自动确认'); return true; };
// 非评教页面(list/edit/save)一律静默退出,不在无关网页弹面板
if (!ROUTES.isList() && !ROUTES.isEdit() && !ROUTES.isSave()) {
return;
}
const s = loadState();
renderPanel(s);
if (ROUTES.isList()) {
if (!s) {
setStatus('空闲,点「开始」扫描');
return;
}
s.listUrl = location.href;
saveState(s);
renderPanel(s);
// 续跑:有队列、没急停、还有剩余课程
if (s.queue && !s.stop && s.idx < s.queue.length) {
setStatus('自动续跑中…');
setTimeout(() => jumpNext(s), CONFIG.NAV_DELAY());
} else if (s.queue && s.idx >= s.queue.length) {
setStatus('✅ 全部完成!');
}
}
else if (ROUTES.isEdit()) {
// 编辑页:有进行中的队列就自动填表提交
if (s && s.queue && !s.stop && s.idx < s.queue.length) {
setStatus('自动填表中…');
setTimeout(() => fillAndSubmit(), CONFIG.ACTION_DELAY());
}
}
};
// 等待 DOM 就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();