// ==UserScript==
// @name 自动评教by ittel:适用于江苏大学
// @namespace
// @version 4.0.0
// @author ITTEL
// @description 全自动评教:填充→提交→确认→下一课程→翻页→多教师tab,全程无人值守
// @license MIT
// @icon https://ts3.tc.mm.bing.net/th/id/ODF.OBdTb_bnewqEd7HjDCi4mg?w=32&h=32&qlt=90&pcl=fffffa&o=6&pid=1.2
// @match *://*.mycospxk.com/*
// @match *://*.edu.cn/*
// @require https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
// @run-at document-start
// ==/UserScript==
(function ($) {
'use strict';
const CFG = {
// 权重随机:同意(4) > 非常同意(5) > 一般(3),不选 1 和 2
weights: { 3: 15, 4: 50, 5: 35 },
comment: "我对本课程非常满意。",
submitDelay: 600,
actionDelay: 1800,
formWaitRetries: 20,
formWaitInterval: 500
};
const ST = { running: false, stopped: false, total: 0, done: 0, log: [] };
/* ==================== 工具函数 ==================== */
const fillNative = (el, val) => {
const proto = el.tagName === 'TEXTAREA'
? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
};
const weightedPick = (obj) => {
const entries = Object.entries(obj).map(([k, v]) => [Number(k), Math.max(0, v)]);
const total = entries.reduce((s, e) => s + e[1], 0);
if (!total) return entries[0][0];
let r = Math.random() * total;
for (const [k, v] of entries) { r -= v; if (r <= 0) return k; }
return entries[entries.length - 1][0];
};
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/* ==================== 填充逻辑 ==================== */
const fillRadios = () => {
let filled = 0;
$('.ant-radio-group').each((_, group) => {
const $g = $(group);
// 跳过已禁用(已评教)
if ($g.find('input:disabled').length === $g.find('input').length) return;
const $opts = $g.find('.ant-radio-wrapper');
if (!$opts.length) return;
// 按文本精确匹配 → 按 value 数值匹配
const map = {};
$opts.each((_, o) => {
const txt = $(o).text().trim();
const v = $(o).find('input').attr('value');
if (v) map[v] = { txt, $el: $(o) };
});
// 选择目标:优先从权重里选一个 value,再点击对应选项
const targetVal = String(weightedPick(CFG.weights));
const target = map[targetVal];
if (target) {
// 用原生 click 确保 React/Ant Design 响应
const input = target.$el.find('input[type=radio]')[0];
if (input) input.click();
else target.$el.trigger('click');
filled++;
}
});
return filled;
};
const fillCheckboxes = () => {
let filled = 0;
$('.ant-checkbox-group').each((_, group) => {
$(group).find('.ant-checkbox-input').each((_, input) => {
if (!input.checked && !input.disabled) { input.click(); filled++; }
});
});
return filled;
};
const fillTextarea = () => {
let filled = 0;
$('textarea.ant-input').each((_, el) => {
if (el.disabled || el.readOnly) return;
fillNative(el, CFG.comment);
filled++;
});
return filled;
};
/* ==================== 页面操作 ==================== */
const findSubmitBtn = () => {
return $('button.ant-btn-primary').filter(function () {
const t = $(this).text().replace(/\s+/g, '');
return /提交/.test(t) && !$(this).hasClass('ujs-auto-btn');
}).first();
};
const clickSubmit = () => {
const $btn = findSubmitBtn();
if ($btn.length) { $btn[0].click(); return true; }
return false;
};
const dismissModal = async () => {
// 等待确认弹窗出现
for (let i = 0; i < 20; i++) {
await sleep(300);
const $modal = $('div.ant-modal-body');
if (!$modal.length) continue;
const $btn = $modal.find('button.ant-btn-primary');
if ($btn.length) { $btn[0].click(); return true; }
const $ok = $modal.find('button').filter(function () {
return /确[定认]|OK|ok/.test($(this).text().trim());
});
if ($ok.length) { $ok[0].click(); return true; }
}
return false;
};
const clickNextCourse = async () => {
await sleep(CFG.actionDelay);
// 优先找"下一课程"按钮
const $next = $('button').filter(function () {
return /下一课程/.test($(this).text().trim());
});
if ($next.length && !$next.prop('disabled')) {
$next[0].click();
return true;
}
return false;
};
const clickNextPage = () => {
const $next = $('.ant-pagination-next:not(.ant-pagination-disabled)');
if ($next.length) { $next[0].click(); return true; }
return false;
};
const switchToNextTab = () => {
const $tabs = $('.ant-tabs-tab');
const $active = $tabs.filter('.ant-tabs-tab-active');
const idx = $tabs.index($active);
if (idx < $tabs.length - 1) {
$tabs.eq(idx + 1)[0].click();
return true;
}
return false;
};
const waitForForm = async () => {
for (let i = 0; i < CFG.formWaitRetries; i++) {
await sleep(CFG.formWaitInterval);
if ($('.ant-radio-group').length || $('textarea.ant-input').length) return true;
}
return false;
};
/* ==================== 列表页课程点击 ==================== */
const clickFirstUnfinished = () => {
const $rows = $('tbody tr.ant-table-row, .ant-table-row');
for (let i = 0; i < $rows.length; i++) {
const $row = $rows.eq(i);
const text = $row.text();
// 跳过已完成的行
if (text.includes('已评') || text.includes('已完成')) continue;
// 找到"评价"链接并点击
const $link = $row.find('td:last-child span, td:last-child a');
if ($link.length) { $link[0].click(); return true; }
}
return false;
};
const countListItems = () => {
return $('tbody tr.ant-table-row, .ant-table-row').filter(function () {
return $(this).text().includes('进行中') || $(this).text().includes('未完成');
}).length;
};
/* ==================== 状态面板 ==================== */
const $panel = (() => {
const $p = $(`
⚡ 全自动评教引擎 v4.0
等待启动...
`);
$p.find('.ujs-stop-btn').on('click', () => { ST.stopped = true; });
return $p;
})();
const panelApi = {
show() { $panel.appendTo('body'); },
hide() { $panel.detach(); },
text(t) { $panel.find('.ujs-panel-text').text(t); },
progress(pct) { $panel.find('.ujs-panel-bar-inner').css('width', pct + '%'); },
log(msg) {
ST.log.push(msg);
const $log = $panel.find('.ujs-panel-log');
$log.append(`${msg}
`);
$log.scrollTop($log[0].scrollHeight);
}
};
/* ==================== 核心主循环 ==================== */
const mainLoop = async () => {
if (ST.running) return;
ST.running = true;
ST.stopped = false;
ST.done = 0;
ST.total = countListItems();
panelApi.show();
panelApi.log('🚀 开始全自动评教...');
const processOneCourse = async () => {
if (ST.stopped) return;
// 0. 如果在列表页,先点击一门未完成的课程
if (isDetailsPage()) {
const clicked = clickFirstUnfinished();
if (!clicked) {
panelApi.log('⚠️ 未找到未完成的课程');
return;
}
panelApi.log('📖 已点击课程,等待表单加载...');
await sleep(CFG.actionDelay);
}
// 1. 等待表单加载
panelApi.text(`正在评教 (${ST.done}/${ST.total})...`);
const hasForm = await waitForForm();
if (!hasForm) {
panelApi.log('⚠️ 未检测到评价表单,可能已全部完成');
return;
}
// 2. 处理所有教师 tab
const $tabs = $('.ant-tabs-tab');
const tabCount = $tabs.length || 1;
for (let t = 0; t < tabCount; t++) {
if (ST.stopped) return;
if (t > 0) {
// 切换到下一个教师 tab
const switched = switchToNextTab();
if (!switched) break;
await sleep(1000);
await waitForForm();
panelApi.log(` 📋 切换到教师 tab ${t + 1}/${tabCount}`);
}
// 3. 填充
const rCount = fillRadios();
fillCheckboxes();
fillTextarea();
panelApi.log(` ✓ 已填充: ${rCount} 道单选 + 文本评价`);
// 4. 提交
await sleep(CFG.submitDelay);
const submitted = clickSubmit();
if (!submitted) {
panelApi.log(' ⚠️ 未找到提交按钮');
continue;
}
panelApi.log(' ⏳ 已点击提交...');
// 5. 确认弹窗
const confirmed = await dismissModal();
if (confirmed) panelApi.log(' ✓ 已确认提交弹窗');
await sleep(800);
}
// 6. 一门课完成
ST.done++;
const pct = ST.total > 0 ? Math.round((ST.done / ST.total) * 100) : 0;
panelApi.progress(pct);
panelApi.log(`✅ 第 ${ST.done} 门评教完成 (${pct}%)`);
};
// 主循环:不断找下一门课
while (!ST.stopped) {
await processOneCourse();
if (ST.stopped) break;
// 优先:在答题页找"下一课程"按钮
if (isAnswerPage()) {
const hasNext = await clickNextCourse();
if (hasNext) {
panelApi.log('→ 点击「下一课程」');
await sleep(CFG.actionDelay);
// 等待新表单加载
await waitForForm();
continue;
}
// 没有"下一课程"了,回列表页
panelApi.log('→ 返回列表页...');
history.back();
await sleep(CFG.actionDelay * 2);
}
// 在列表页:尝试翻页
if (isDetailsPage()) {
const remaining = countListItems();
if (remaining > 0) {
// 当前页还有未完成的,继续
continue;
}
const hasNextPage = clickNextPage();
if (hasNextPage) {
panelApi.log('→ 翻到下一页');
await sleep(CFG.actionDelay * 2);
ST.total = ST.total + countListItems();
continue;
}
// 没有下一页了
break;
}
}
// 完成
ST.running = false;
if (ST.stopped) {
panelApi.text('已手动停止');
panelApi.log('⛔ 用户手动停止');
} else {
panelApi.text('全部完成 ✓');
panelApi.progress(100);
panelApi.log(`🎉 全部评教完成!共 ${ST.done} 门课`);
}
};
/* ==================== 列表页检测 ==================== */
const isDetailsPage = () => location.hash.includes('/my-task/details/');
const isAnswerPage = () => location.hash.includes('/my-task/answer/');
const addButtons = () => {
if ($('#ujs-auto-btn').length) return;
if (!isDetailsPage()) return;
const $btn = $(``);
$btn.on('click', () => { mainLoop(); });
// 插入到表格上方
const $wrapper = $('.ant-table-wrapper');
if ($wrapper.length) {
$wrapper.first().before($btn);
} else {
const $content = $('.ant-layout-content').first() || $('main').first();
if ($content.length) $content.prepend($btn);
else $('body').append($btn.css({ position: 'fixed', bottom: '80px', left: '20px', zIndex: 999999 }));
}
};
/* ==================== 样式注入 ==================== */
const injectStyles = () => {
if (document.getElementById('ujs-auto-style')) return;
const s = document.createElement('style');
s.id = 'ujs-auto-style';
s.textContent = `
#ujs-auto-btn {
margin: 12px 0;
border-radius: 8px;
font-weight: 700;
font-size: 14px;
padding: 8px 24px;
background: linear-gradient(135deg, #10b981, #14b8a6);
border-color: #10b981;
color: #fff;
box-shadow: 0 4px 14px rgba(16,185,129,0.3);
transition: all .2s;
}
#ujs-auto-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(16,185,129,0.4);
}
#ujs-panel {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 999999;
background: rgba(15,23,42,0.95);
backdrop-filter: blur(16px);
border: 1px solid rgba(139,92,246,0.3);
border-radius: 16px;
padding: 16px 20px;
min-width: 300px;
color: #e2e8f0;
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
}
.ujs-panel-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
@keyframes ujs-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.ujs-spin { display: inline-block; animation: ujs-spin 1.5s linear infinite; }
.ujs-panel-bar {
height: 5px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
margin: 8px 0;
overflow: hidden;
}
.ujs-panel-bar-inner {
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #a855f7);
border-radius: 3px;
transition: width .5s ease;
width: 0%;
}
.ujs-panel-text { color: #cbd5e1; font-size: 12px; margin-bottom: 6px; }
.ujs-panel-log {
max-height: 140px;
overflow-y: auto;
font-size: 12px;
color: #94a3b8;
line-height: 1.7;
}
.ujs-panel-log::-webkit-scrollbar { width: 4px; }
.ujs-panel-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; }
.ujs-panel-actions { display: flex; gap: 8px; margin-top: 10px; }
.ujs-panel-btn {
flex: 1;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.08);
color: #e2e8f0;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background .2s;
}
.ujs-panel-btn:hover { background: rgba(255,255,255,0.15); }
.ujs-stop-btn { border-color: rgba(239,68,68,0.4); color: #fca5a5; }
`;
(document.head || document.documentElement).appendChild(s);
};
/* ==================== 初始化 ==================== */
const init = () => {
injectStyles();
// URL 变化监听(SPA hash 路由)
let urlTimer = null;
const onUrlChange = () => {
clearTimeout(urlTimer);
urlTimer = setTimeout(() => {
$('#ujs-auto-btn').remove();
addButtons();
}, 600);
};
const origPush = history.pushState;
history.pushState = function () { origPush.apply(this, arguments); onUrlChange(); };
const origReplace = history.replaceState;
history.replaceState = function () { origReplace.apply(this, arguments); onUrlChange(); };
window.addEventListener('popstate', onUrlChange);
window.addEventListener('hashchange', onUrlChange);
// 立即尝试添加按钮(页面可能已加载完毕)
addButtons();
// 轮询兜底:SPA 框架可能延迟渲染 DOM
let pollCount = 0;
const poll = setInterval(() => {
addButtons();
if (++pollCount > 10 || document.getElementById('ujs-auto-btn')) clearInterval(poll);
}, 800);
// MutationObserver 补充:监听动态 DOM 变更
if (document.body) {
const obs = new MutationObserver(() => { addButtons(); });
obs.observe(document.body, { childList: true, subtree: true });
// 30 秒后停止观察,避免性能影响
setTimeout(() => obs.disconnect(), 30000);
}
};
// 入口:直接执行(userscript @run-at document-start + jQuery @require 保证 jQuery 已加载)
init();
})(jQuery);