// ==UserScript== // @name 重庆大学快速评教 // @namespace https://github.com/cqu-moe/jxzljc // @version 0.3.3 // @license MIT // @updateURL https://raw.githubusercontent.com/cqu-moe/jxzljc/main/jxzljc.user.js // @downloadURL https://raw.githubusercontent.com/cqu-moe/jxzljc/main/jxzljc.user.js // @description 预览确认后批量评价当前学期待评条目,保留已有草稿。 // @match *://jxzljc.cqu.edu.cn/xspj/xspj/index/xspjindex* // @run-at document-idle // @grant none // @noframes // ==/UserScript== (() => { 'use strict'; if (window.top !== window.self || document.getElementById('cqu-quick-evaluation')) return; const apiBase = '/xspj/xspj/index'; const grades = { A: ['非常满意', 1], B: ['满意', 0.8], C: ['一般满意', 0.6], D: ['不满意', 0.4], E: ['非常不满意', 0.2] }; const host = document.createElement('div'); host.id = 'cqu-quick-evaluation'; const root = host.attachShadow({ mode: 'open' }); root.innerHTML = `

重庆大学 · 学生评教

快速评教

01 填写设置 · 02 核对预览 · 03 提交评价

统一评价设置

第 1 步

按网站允许的范围填写

已有评分和评语会保留。下一步可选择课程、逐门调整;修改统一设置会重置逐课修改。

`; document.body.append(host); const $ = id => root.getElementById(id); const dialog = root.querySelector('dialog'); const fieldset = root.querySelector('fieldset'); for (const [value, [label]] of Object.entries(grades)) $('grade').add(new Option(label, value)); let plan = null; let busy = false; let stopping = false; let step = 'settings'; let stepAnimation; const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); host.dataset.motion = 'off'; root.addEventListener('pointerdown', () => { host.dataset.motion = reducedMotion.matches ? 'off' : 'on'; }); root.addEventListener('keydown', () => { host.dataset.motion = 'off'; stepAnimation?.cancel(); }); reducedMotion.addEventListener('change', () => { if (reducedMotion.matches) stepAnimation?.cancel(); }); function showStep(next) { const backwards = next === 'settings'; step = next; $('settings-step').hidden = step !== 'settings'; $('preview-step').hidden = step !== 'preview'; $('progress-step').hidden = step !== 'progress'; $('records-step').hidden = step !== 'progress'; $('selection').hidden = step !== 'preview' || !plan?.items.length; lock(busy); const content = root.querySelector('.content'); content.scrollTop = 0; stepAnimation?.cancel(); if (host.dataset.motion === 'on' && !reducedMotion.matches) { stepAnimation = content.animate([ { opacity: 0, transform: `translateX(${backwards ? -12 : 12}px)` }, { opacity: 1, transform: 'translateX(0)' }, ], { duration: 180, easing: 'cubic-bezier(0.19, 1, 0.22, 1)' }); } $(step === 'settings' ? 'settings-title' : step === 'preview' ? 'list-title' : 'progress-title').focus({ preventScroll: true }); } function updateProgress(completed, total) { $('percent').textContent = `${Math.round(completed / total * 100)}%`; $('progress-count').textContent = `${completed} / ${total} 门已核实`; $('progress-bar').setAttribute('aria-valuemax', String(total)); $('progress-bar').setAttribute('aria-valuenow', String(completed)); $('progress-bar').setAttribute('aria-valuetext', `${completed} / ${total} 门已核实提交成功`); $('progress-fill').style.transform = `scaleX(${completed / total})`; } function startRecord(course) { const row = document.createElement('li'); const name = paragraph(row, course.name, 'record-name'); name.title = course.name; const detail = document.createElement('div'); detail.className = 'record-detail'; paragraph(detail, course.teacher, 'teacher').title = course.teacher; const result = paragraph(detail, '正在核对…', 'result'); row.append(detail); $('records').prepend(row); $('records').scrollTo({ top: 0, behavior: 'instant' }); return result; } function lock(value) { busy = value; fieldset.disabled = value; $('next').disabled = value; $('next').hidden = step !== 'settings'; $('next').textContent = value ? '正在读取…' : '下一步'; $('back').disabled = value; $('back').hidden = step !== 'preview'; $('close').disabled = value; $('refresh').disabled = value; $('refresh').hidden = step !== 'progress' || value; $('stop').hidden = step !== 'progress' || !value; const selected = plan?.items.some(item => item.selected); $('confirm').disabled = value || !selected; $('confirm').hidden = step !== 'preview'; for (const control of $('evaluation').querySelectorAll('fieldset, input[type="checkbox"]')) control.disabled = value || !plan; $('settings').setAttribute('aria-busy', String(value)); } function fail(message) { throw new Error(message); } async function api(endpoint, payload) { const response = await fetch(apiBase + endpoint, { credentials: 'same-origin', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(20000), ...(payload ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } : {}), }); if (!response.ok) fail(`网站请求失败(HTTP ${response.status}),请检查登录状态。`); const result = await response.json(); if (result.success !== true) fail(typeof result.msg === 'string' ? result.msg : '网站未确认请求成功。'); return result.data; } async function currentSemester() { const data = await api('/getSemesters'); if (!Array.isArray(data)) fail('无法识别学期数据。'); const current = data.filter(s => s.iscurrent === true || s.isCurrent === true); if (current.length !== 1 || typeof current[0].xq !== 'string' || !current[0].xq) fail('无法确定当前学期。'); return current[0].xq; } async function courses(semester) { const data = await api('/getCourses?xq=' + encodeURIComponent(semester)); if (!Array.isArray(data) || data.some(c => !c || typeof c.id !== 'string' || !c.id || c.xq !== semester || typeof c.completed !== 'boolean' || typeof c.status !== 'string')) fail('课程数据格式发生变化,请停止使用并更新脚本。'); if (new Set(data.map(c => c.id)).size !== data.length) fail('课程数据包含重复条目。'); return data; } const pending = c => c.completed === false && c.status === '未评教'; function limits() { const input = document.getElementById('overallRating'); if (!input || input.type !== 'range') fail('未找到网站综合评分控件,请等待页面加载完成。'); const result = { min: Number(input.min), max: Number(input.max), initial: Number(input.dataset.initial) }; if (!Number.isInteger(result.min) || !Number.isInteger(result.max) || result.min < 0 || result.max > 100 || result.min >= result.max || !Number.isFinite(result.initial) || input.step !== '1') fail('综合评分规则发生变化。'); return result; } function draftData(raw) { const draft = raw === null ? {} : JSON.parse(raw); if (!draft || typeof draft !== 'object' || Array.isArray(draft)) fail('评价草稿无法读取,已停止以保护原内容。'); for (const key of ['ratings', 'overallRatings', 'comments']) { if (draft[key] !== undefined && (!draft[key] || typeof draft[key] !== 'object' || Array.isArray(draft[key]))) fail('评价草稿格式发生变化,已停止以保护原内容。'); } return draft; } async function definitions(type) { if (typeof type !== 'string' || !type) fail('课程类型缺失。'); const data = await api('/getIndicators?courseType=' + encodeURIComponent(type)); // shortcut: 仅支持实测的 A–E 选择题,学校更换题型时重新适配。 if (!Array.isArray(data) || !data.length || data.some(i => !i || typeof i.id !== 'string' || !i.id || typeof i.title !== 'string' || i.type !== '选项问答' || i.options !== 'A;B;C;D;E' || !Number.isFinite(Number(i.maxScore)) || Number(i.maxScore) <= 0 || Number(i.maxScore) > 100) || new Set(data.map(i => i.id)).size !== data.length) fail('发现尚未支持的评价指标或选项,请手动评价。'); return data; } function prepare(course, indicators, semester, draft, preset, range) { if (typeof course.name !== 'string' || typeof course.teacher !== 'string') fail('课程名称或教师信息缺失。'); const key = `semester-${semester}-course-${course.id}`; const fields = indicators.map(indicator => { const ratingKey = `${semester}-${course.id}-${indicator.id}`; const saved = draft.ratings?.[ratingKey]; const preserved = saved !== undefined; const grade = preserved ? saved : preset.grade; if (!Object.hasOwn(grades, grade)) fail(`${course.name}:已有指标评分无法识别,未覆盖草稿。`); const score = Number(indicator.maxScore) * grades[grade][1]; const savedScore = draft.ratings?.[ratingKey + '_score']; if (preserved && savedScore !== undefined && Number(savedScore) !== score) fail(`${course.name}:草稿分数与当前指标权重不一致。`); return { indicator, grade, score, preserved }; }); const savedOverall = draft.overallRatings?.[key]; const hasOverall = savedOverall !== undefined && savedOverall !== '' && Number(savedOverall) !== 0 && Number(savedOverall) !== range.initial; const overall = hasOverall ? Number(savedOverall) : preset.overall; if (!Number.isInteger(overall) || overall < range.min || overall > range.max || overall === range.initial) fail(`${course.name}:综合评分超出网站范围,未覆盖已有评分。`); const savedComment = draft.comments?.[key]; if (savedComment !== undefined && typeof savedComment !== 'string') fail(`${course.name}:已有评语无法识别。`); const hasComment = Boolean(savedComment?.trim()); return { course, fields, hasOverall, hasComment, selected: true, payload: { xsxkxxid: course.id, overallRating: String(overall), comment: hasComment ? savedComment : preset.comment, ratings: fields.map(f => ({ zbid: f.indicator.id, score: f.score })) }, }; } function paragraph(parent, text, className) { const p = document.createElement('p'); p.textContent = text; if (className) p.className = className; parent.append(p); return p; } function updateSelection() { const count = plan.items.filter(item => item.selected).length; $('count').textContent = `已选 ${count} / ${plan.items.length}`; $('select-all').checked = count > 0 && count === plan.items.length; $('select-all').indeterminate = count > 0 && count < plan.items.length; $('status').textContent = count ? `${plan.semester}:点击「开始刷课」将提交已选 ${count} 门课程的评价。` : (plan.items.length ? '尚未选择课程,请勾选要评价的条目。' : `${plan.semester}:0 个待评条目,无需提交。`); lock(busy); } function showPlan(items, range) { $('list').replaceChildren(); $('selection').hidden = !items.length; $('empty').hidden = items.length > 0; if (!items.length) $('empty').innerHTML = '当前学期没有待评条目

已提交和非待评状态的条目不会列入清单。

'; for (const [index, item] of items.entries()) { const li = document.createElement('li'); const name = `${item.course.name} · ${item.course.teacher}`; const selection = document.createElement('label'); selection.className = 'checkbox course-select'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.setAttribute('aria-label', `评价 ${name}`); selection.append(checkbox); const details = document.createElement('details'); const summary = document.createElement('summary'); const heading = document.createElement('span'); heading.className = 'course-name'; const courseTitle = document.createElement('span'); courseTitle.className = 'course-title'; courseTitle.textContent = item.course.name; const teacher = document.createElement('span'); teacher.className = 'teacher'; teacher.textContent = item.course.teacher; heading.append(courseTitle, teacher); summary.append(heading); const score = document.createElement('span'); score.className = 'result'; summary.append(score); const editor = document.createElement('fieldset'); editor.className = 'editor'; editor.setAttribute('aria-label', `${name}的评价内容`); details.append(summary, editor); li.append(selection, details); const updateSummary = () => { score.textContent = item.selected ? (item.payload.overallRating ? `${item.payload.overallRating} 分` : '待填写') : '本次不评'; }; checkbox.addEventListener('change', () => { item.selected = checkbox.checked; updateSummary(); updateSelection(); }); for (const f of item.fields) { const label = document.createElement('label'); const title = document.createElement('span'); title.textContent = f.indicator.title; const select = document.createElement('select'); for (const [value, [text, weight]] of Object.entries(grades)) select.add(new Option(`${text} · ${Number(f.indicator.maxScore) * weight} / ${f.indicator.maxScore}`, value)); select.value = f.grade; label.append(title, select); const source = paragraph(label, f.preserved ? '保留草稿' : '来自统一设置', 'muted'); if (f.indicator.description) paragraph(label, f.indicator.description, 'muted'); select.addEventListener('change', () => { f.grade = select.value; f.score = Number(f.indicator.maxScore) * grades[f.grade][1]; item.payload.ratings = item.fields.map(field => ({ zbid: field.indicator.id, score: field.score })); source.textContent = '本次修改'; }); editor.append(label); } const overallLabel = document.createElement('label'); const overallTitle = document.createElement('span'); overallTitle.textContent = '综合评分'; const overall = document.createElement('input'); overall.type = 'number'; overall.min = $('overall').min; overall.max = String(range.max); overall.step = '1'; overall.required = true; overall.value = item.payload.overallRating; overallLabel.append(overallTitle, overall); const overallSource = paragraph(overallLabel, item.hasOverall ? '保留草稿' : '来自统一设置', 'muted'); const error = paragraph(overallLabel, `请输入 ${overall.min}–${range.max} 范围内的整数,不能使用网站初始值 ${range.initial}。`, 'field-error'); error.id = `course-error-${index}`; error.hidden = true; overall.setAttribute('aria-describedby', error.id); item.validate = () => { const value = Number(overall.value); const valid = overall.value !== '' && overall.checkValidity() && value !== range.initial; overall.setAttribute('aria-invalid', String(!valid)); error.hidden = valid; return valid; }; item.focusInvalid = () => { details.open = true; overall.focus(); }; overall.addEventListener('blur', item.validate); overall.addEventListener('input', () => { item.payload.overallRating = overall.value === '' ? '' : String(Number(overall.value)); overallSource.textContent = '本次修改'; updateSummary(); if (overall.getAttribute('aria-invalid') === 'true') item.validate(); }); const commentLabel = document.createElement('label'); commentLabel.className = 'wide'; const commentTitle = document.createElement('span'); commentTitle.textContent = '评语'; const comment = document.createElement('textarea'); comment.rows = 3; comment.value = item.payload.comment; commentLabel.append(commentTitle, comment); const commentSource = paragraph(commentLabel, item.hasComment ? '保留草稿' : '来自统一设置,可留空', 'muted'); comment.addEventListener('input', () => { item.payload.comment = comment.value; commentSource.textContent = '本次修改'; }); editor.append(overallLabel, commentLabel); updateSummary(); $('list').append(li); } } $('select-all').addEventListener('change', () => { const checked = $('select-all').checked; for (const checkbox of $('list').querySelectorAll('input[type="checkbox"]')) { checkbox.checked = checked; checkbox.dispatchEvent(new Event('change')); } }); $('launch').addEventListener('click', () => { dialog.showModal(); try { const range = limits(); $('overall').min = String(range.min === range.initial ? range.min + 1 : range.min); $('overall').max = String(range.max); $('overall').placeholder = `${$('overall').min}–${range.max}`; $('score-help').textContent = `网站允许 ${$('overall').min}–${range.max} 分`; } catch (error) { $('error').textContent = error.message; } }); $('close').addEventListener('click', () => dialog.close()); dialog.addEventListener('cancel', event => { if (busy) event.preventDefault(); }); $('back').addEventListener('click', () => { if (busy || step !== 'preview') return; showStep('settings'); $('error').textContent = ''; $('status').textContent = '课程选择与逐课编辑已保留;修改统一设置后会重新生成。'; }); $('settings').addEventListener('input', () => { plan = null; $('list').replaceChildren(); $('selection').hidden = true; $('error').textContent = ''; $('status').textContent = '填写后进入下一步,核对课程与评价内容。'; lock(busy); }); $('settings').addEventListener('submit', async event => { event.preventDefault(); if (busy || step !== 'settings') return; if (plan) { showStep('preview'); updateSelection(); return; } lock(true); $('error').textContent = ''; $('status').textContent = '正在读取当前学期、待评条目和草稿…'; try { const modal = document.getElementById('evaluationModal'); if (modal && getComputedStyle(modal).display !== 'none') fail('请先关闭网站的单门评教窗口,保存当前草稿后再预览。'); const range = limits(); const preset = { grade: $('grade').value, overall: Number($('overall').value), comment: $('comment').value }; if (!Object.hasOwn(grades, preset.grade)) fail('请选择满意度档位。'); const semester = await currentSemester(); const rows = (await courses(semester)).filter(pending); const draftRaw = localStorage.getItem('evaluationState'); const draft = draftData(draftRaw); const byType = new Map(); const items = []; for (const course of rows) { if (!byType.has(course.kcxz)) byType.set(course.kcxz, await definitions(course.kcxz)); items.push(prepare(course, byType.get(course.kcxz), semester, draft, preset, range)); } plan = { semester, items, draftRaw, reviewedAt: Date.now() }; showPlan(items, range); updateSelection(); showStep('preview'); } catch (error) { $('status').textContent = '未生成提交计划。'; $('error').textContent = `${error.message}\n处理后点击「下一步」重试,已填写的设置会保留。`; } finally { lock(false); } }); $('refresh').addEventListener('click', () => location.reload()); $('stop').addEventListener('click', () => { stopping = true; $('stop').disabled = true; $('progress-title').textContent = '正在停止'; $('progress-step').dataset.state = 'stopped'; $('status').textContent = '正在停止;如已发出提交请求,将先核实当前条目的结果。'; }); $('evaluation').addEventListener('submit', async event => { event.preventDefault(); if (busy || step !== 'preview' || !plan) return; const selected = plan.items.filter(item => item.selected); if (!selected.length) return; const invalid = selected.find(item => !item.validate()); if (invalid) { invalid.focusInvalid(); return; } const batch = { ...plan, items: selected }; plan = null; lock(true); stopping = false; $('stop').disabled = false; $('error').textContent = ''; $('status').textContent = '正在核对当前学期与课程状态…'; $('progress-step').dataset.state = 'running'; updateProgress(0, batch.items.length); showStep('progress'); $('stop').focus({ preventScroll: true }); let completed = 0; let active = null; let result = null; let sent = false; try { if (await currentSemester() !== batch.semester) fail('当前学期已变化,请重新预览。'); for (const item of batch.items) { if (stopping) break; active = item; result = startRecord(item.course); $('current-course').textContent = `${item.course.name} · ${item.course.teacher}`; $('progress-phase').textContent = '正在核对课程状态…'; sent = false; if (localStorage.getItem('evaluationState') !== batch.draftRaw) fail('草稿已变化,请重新预览,避免覆盖新填写的内容。'); const latest = (await courses(batch.semester)).find(c => c.id === item.course.id); if (stopping) { result.textContent = '未提交'; break; } if (!latest || !pending(latest) || latest.kcxz !== item.course.kcxz || latest.teacher !== item.course.teacher || latest.name !== item.course.name) fail('条目状态已变化,请重新预览。'); if (localStorage.getItem('evaluationState') !== batch.draftRaw) fail('草稿已变化,请重新预览。'); result.textContent = '正在提交…'; $('progress-phase').textContent = '正在提交评价,核实成功后计入进度。'; $('status').textContent = `正在提交:${item.course.name} · ${item.course.teacher}(已完成 ${completed} / ${batch.items.length})`; sent = true; await api('/saveEvaluation', { ...item.payload, pjsj: Math.max(0, Math.round((Date.now() - batch.reviewedAt) / 1000)) }); result.textContent = '正在核实…'; $('progress-phase').textContent = '正在向服务器核实提交结果…'; const saved = (await courses(batch.semester)).find(c => c.id === item.course.id); if (!saved?.completed || saved.status === '未评教' || !saved.status) fail('服务器尚未显示已提交,结果未确认。'); result.textContent = '已核实提交成功'; result.dataset.state = 'success'; completed++; updateProgress(completed, batch.items.length); } $('progress-step').dataset.state = stopping ? 'stopped' : 'complete'; $('progress-title').textContent = stopping ? '已停止' : '全部评价已完成'; $('progress-phase').textContent = stopping ? '已停止后续提交。刷新后重新核对剩余课程。' : '所有已选课程均已核实提交成功。'; $('status').textContent = `${stopping ? '已停止' : '完成'}:${completed} / ${batch.items.length} 个条目已核实提交成功。`; } catch (error) { $('progress-step').dataset.state = 'error'; $('progress-title').textContent = '提交已停止'; $('progress-phase').textContent = '进度仅包含已核实成功的课程,请先刷新核实。'; if (result) { result.textContent = sent ? '结果未确认' : '未提交'; result.dataset.state = 'error'; } $('status').textContent = `已停止:${completed} / ${batch.items.length} 个条目已核实提交成功。`; $('error').textContent = `${active ? `${active.course.name} · ${active.course.teacher}:` : ''}${error.message}${sent ? '\n请求可能已到达服务器;请先刷新核实,再决定是否重新预览。' : ''}`; } finally { lock(false); $('refresh').focus({ preventScroll: true }); } }); })();