// ==UserScript== // @name 一平台形考|统计+自动评阅 // @namespace http://tampermonkey.net/ // @version 2.5 // @description 一键扫描全部课程的形考/作业任务(含大作业、实验实习、学习表现、社会实践、终结性考试),自动统计未提交、待评阅、已评阅;考试类(含主观题)按题评分,作业类整份批改,全部自动提交。评分算法与积分扣费均在服务器端完成,客户端仅做搬运 // @author private // @match https://lms.ouchn.cn/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect 219.153.74.48 // @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js // @require https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js // @run-at document-end // ==/UserScript== /* * ============================================================================ * 设计说明(相对旧版 统计.js v11 / 评阅.js v23 的改进) * ---------------------------------------------------------------------------- * 1.【合二为一】扫描 → 判定 → 评阅 → 回写统计,全流程一个按钮完成。 * 旧版要先在 lms 页统计出"哪门课有几份待评",再手工打开测验、点"批改"、 * 逐题填分、逐份保存;现在扫描到 waiting>0 且含主观题时直接自动评阅。 * * 2.【评阅改为接口直连】不再操作 DOM(点标签、点学生行、找 input[name=score]、 * 点保存…),而是复刻平台自己的三条接口: * GET /api/exam/{examId}/scores?conditions=... 取"已交未批改"名单 * GET /api/exams/{examId}/score-list?conditions=... 取该生全部提交及 marked 标记 * GET /api/exams/{examId}/submissions/{submissionId} 取答卷(题目、学生原文、参考答案) * POST /api/exams/{examId}/give-score 提交评分(可一次提交整卷所有主观题) * —— 实测:一份 4 道主观题的答卷,旧版 DOM 流程约 12~20 秒且依赖页面结构; * 接口方式 2 个请求、约 0.3 秒,且不受页面改版影响。 * * 3.【评分与计费全部在服务器端(v14 核心改动)】 * · 客户端不再包含任何评分权重/阈值/覆盖率算法,只做三件事: * ① 从 LMS 取答卷(题目、满分、学生原文、参考答案) * ② 把题目打包发给服务器 POST /api/grade,取回每题得分 * ③ 把得分提交回 LMS give-score * 即使有人反编译 user.js,也拿不到打分规则,更无法在本地伪造分数绕过计费。 * · 服务器在一次事务里完成「评分 + 扣费 + 回执」: * 扣费 = ceil(题目数 / 每档题目数),默认每 2 个题目扣 1 积分; * 同一份答卷(examId_submissionId)只扣一次,网络重试/脚本重跑不会重复扣分。 * · 计费规则由服务器 /api/rules 下发,改规则只需改服务器,无需更新脚本。 * * 4.【准确性:判分依据换成真实数据】 * · 每题满分:直接取 subjects_data 里的 point(旧版用正则从页面文本抠"简答题(10分)", * 遇到"分析题""材料题""(5分)"等写法会取到 0 分)。 * · 是否作答:比对 submission_data.subjects 中是否确有该 subject_id 的作答记录, * 再剔除 "


" 这类空 HTML(旧版靠向上找 10 层父节点搜"此题未答"文本, * 容易误判,且学生答了空段落时判错)。 * · 分析题/材料题是"父题 + 子题"结构,评分子题 id 而非父题 id * (直接给父题 id 会 500,这是旧版最容易踩的坑)。 * * 5.【完整性:一份答卷必须全部主观题都有分才会变"已批改"】 * 实测平台规则:submission 下所有可评分子题都提交过分,marked 才为 true, * 该生才会从"已交未批改"名单消失。旧版只给"当前可见的输入框"填分,容易漏题。 * 另:同一学生多次交卷时,未批改的历史提交也要批改,该生才会出列 * (脚本按交卷时间顺序逐份批改,一次请求提交整卷所有主观题)。 * * 6.【数据沉淀与导出(v15)】 * · 扫描时按任务采集全员名单:学号(created_by.user_no)、姓名、班级、 * 成绩、是否提交、提交时间,随课程扫描完成自动上传服务器(分课程分块); * · 同一批次可重复上传(batch_key 固定),重跑只覆盖不重复; * · 面板“☁️ 云端Excel”一键导出一个 .xlsx 工作簿(4 个工作表: * 按学科统计 / 已交成绩 / 未交名单 / 学生汇总),一次下载、无乱码; * · 用户只能导出自己的数据:服务器按 user_id + 签名校验,并校验批次归属; * · 评阅日志固定为「姓名 已评阅 N 题 → X 分,剩余积分:Y」,不再逐卷打印扣费明细。 * * 7.【v2.0 改版】 * · 移除“单科上限”与全部开发调试代码(调试模式、占位分、自动化测试钩子); * · 界面重排:折叠按钮、统计卡片网格、功能分区(运行/导出两行按钮); * · 日志分级着色(成功绿/警告黄/错误红/汇总蓝),文案精简、要素完整。 * * 8.【v2.1 修复:学习活动型任务漏统计】 * · “学习表现综合评价”“写作任务”“口语形考”及“实验报告/实验实习”“社会实践” * 都是藏在 learning_activities(学习活动)里的 type=homework 作业, * 旧版只查 exams/homeworks 两类接口全部漏掉——现一并纳入扫描统计与云端名单; * · 纳入规则:is_formative=true 或标题含 学习表现/实验报告/实验实习/社会实践/写作任务 等; * · 作业型任务同步抓取提交名单(学号/姓名/班级/成绩/提交时间)上传云端, * “已交学生成绩/未交名单/学生汇总”对作业类任务同样完整; * · (v2.2 更正)此前判断"平台未开放作业教师直评接口"有误——平台确有接口, * 只是不在前端业务分块里,现已接入并用于自动批改。 * * 9.【v2.2 新增:作业型任务自动批改】 * · 找到平台教师端"批改"页保存时真正调用的接口(实测 201 通过): * PUT /api/course/activities/{活动id}/submission/score?fields=... * body: { score, reviewer_comment, uploads:[], id:提交id, student_id:学生id } * —— 作业没有题目、无法按题评分,改为"整份一个总分"提交; * · 适用:学习表现综合评价、实验报告/实验实习、社会实践、终结性考试等 * learning_activities 中的作业型任务(含纯文件上传的终考); * · 分数仍由服务器决定(POST /api/grade/homework,按满分百分比区间给分, * 区间与单价在后台 settings 可调),客户端只负责取待批名单、搬运分数; * · 计费:每份作业 1 积分(后台可调),回执幂等 hw_{活动id}_{提交id}; * · 未开放的课程(学生未提交)自动跳过,不产生费用。 * * 10.【v2.4 修复:旧版云端导致作业批改全失败】 * · 现象:扫描能识别大作业/实验实习/终结性考试,但每份都报 * “服务器未升级(缺少 /api/grade/homework)”,本次评阅 0 份、失败 9 份; * · 原因:云端服务器仍是 v15(/api/rules 无 homework_* 字段,接口 404), * 而客户端 v2.2 起把作业评分放到了新接口上; * · 修法:新增兼容通道——检测到 404 时自动改用已有的 /api/grade, * 提交 1 道"占位题"(满分=作业满分、视为已提交附件),由服务器按 * 80%~87% 给分并扣 1 积分,回执键同为 hw_{活动id}_{提交id},重跑不重复扣费; * · 启动时若 /api/rules 未下发作业区间,直接提示"将走兼容通道", * 不再让用户在每份作业上看到 9 条相同报错; * · 云端升级到 v2.2 后自动优先走 /api/grade/homework(85%~95%,后台可调)。 * * 11.【高效】 * · 课程扫描并发 4 路,单个任务 1 次题型 + 3 次人数(并发)请求; * · 登录失效、5xx、网络抖动自动重试(指数退避); * · 断点续跑:中途停止/关闭页面后,重新开始会跳过已完成的课程。 * * 12.【v2.5 修复:登录串号】 * · 现象:同一浏览器上任何人打开页面,都会自动"登录"成上一个人 * (如 13683768790)的脚本账号,评阅扣费全部算在别人头上; * · 原因:ouchn_auto_login 是浏览器级共享缓存,换人后不会自动清除; * · 方案:登录只验证脚本自身账号,不做一平台身份校验; * 面板新增「退出」按钮,清除脚本登录缓存并返回登录页, * 共用电脑换人时点「退出」再重新登录即可(或清除浏览器缓存)。 * ============================================================================ */ (function () { 'use strict'; /* ==================== 配置 ==================== */ const CFG = { SERVER_URL: 'http://219.153.74.48:3000', SECRET_KEY: 'MyTmScriptSecret2026@888', MAX_RETRIES: 3, RETRY_BASE: 700, SCAN_CONCURRENCY: 4, PAGE_SIZE: 100, LOG_LIMIT: 600 }; // 需要人工评阅的题型(lms.ouchn.cn 实测枚举) const SUBJECTIVE_TYPES = new Set([ 'short_answer', 'analysis', 'essay', 'subjective', 'file_upload', 'writing', 'speaking', 'speak' ]); // 形考任务判定(沿用旧版规则并补充 is_formative 标记) // v2.1:补齐学习活动型任务(学习表现/实验报告/实验实习/社会实践等) const INCLUSIVE_PATTERNS = [ /学习行为/, /大作业/, /终考/, /测验/, /终结性考试/, /形考/, /专题测验/, /日常学习/, /学习表现/, /实验报告/, /实验实习/, /社会实践/, /写作任务/ ]; const EXCLUDE_PATTERNS = [ /^第\s*[一二三四五六七八九十\d]+\s*章/, /^第\s*[一二三四五六七八九十\d]+\s*节/, /章节测验/, /单元测验/, /随堂练习/, /说明/, /流程/, /不计入形考/, /常见问题/, /步骤/, /模拟试题/, /不计分/, /^模块\s*[一二三四五六七八九十\d]*\s*测[验试]/ ]; /* ==================== 全局状态 ==================== */ let isRunning = false, isPaused = false, stopFlag = false; let autoReview = true; // 扫描到待评任务时自动评阅 let panel = null, logEl = null; const stat = { course: 0, task: 0, manual: 0, auto: 0, unsubmit: 0, submitted: 0, waiting: 0, graded: 0, gradedByMe: 0, failed: 0, skipped: 0 }; let rows = []; // CSV 明细:每个任务一行 let userInfo = { token: '', username: '', isGuest: false, points: 0, userId: 0 }; /* ---------- 计费状态(扣费在服务器完成,这里只做展示) ---------- */ let pointRule = 2; // 每 N 个题目扣 1 积分(由服务器 /api/rules 下发) let pointCharged = 0; // 本次运行已扣积分 let gradedQuestions = 0; // 本次运行已评题目数 // v2.2:作业型任务(学习表现/实验报告/社会实践/终结性考试)的评分区间与单价,均由服务器下发 let hwScoreMin = 85, hwScoreMax = 95, hwPointCost = 1; let serverLegacy = false; // v2.4:云端服务器是否旧版(无 /api/grade/homework) let hwGraded = 0; // 本次已批改作业份数 const SAVE_KEY = 'ouchn_integrated_progress_v12'; let batchKey = ''; // 本次扫描的云端批次键(用于断点续跑时覆盖同一批) let scanSavedStudents = 0; // 本次已上传到服务器的学生明细行数 let progress = { filterKeyword: '', courses: [], cursor: 0, stat: {}, rows: [], logs: [] }; const logs = []; /* ==================== 基础工具 ==================== */ const sleep = (ms) => new Promise(r => setTimeout(r, ms)); function stamp() { const d = new Date(); const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`; } function errMsg(e) { if (e instanceof Error) return e.message; if (typeof e === 'string') return e; try { return JSON.stringify(e); } catch (_) { return '未知错误'; } } function stripHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.innerHTML = s; return (d.textContent || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); } async function waitIfPaused() { while (isPaused && !stopFlag) await sleep(400); } function log(text, level) { const t = String(text == null ? '' : text); const icon = level === 'error' ? '❌ ' : level === 'warn' ? '⚠️ ' : level === 'ok' ? '✅ ' : ''; // 文本本身已带图标(✔ ⏹ 📄 等)时不再叠加,避免出现「✅ ✔ xxx」这种重复 const head = t.trimStart().charAt(0); const hasIcon = !!head && !/[0-9A-Za-z\u4e00-\u9fa5((\[]/.test(head); logs.push(`[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${hasIcon ? '' : icon}${t}`); if (logs.length > CFG.LOG_LIMIT) logs.shift(); renderLogs(); } // 日志彩色渲染:按级别着色,要素一目了然 function renderLogs() { if (!logEl) return; const esc = s => String(s).replace(/&/g, '&').replace(//g, '>'); logEl.innerHTML = logs.map(l => { let cls = 'lg'; if (/❌/.test(l)) cls += ' lg-err'; else if (/⚠️/.test(l)) cls += ' lg-warn'; else if (/✅|🎉|✔/.test(l)) cls += ' lg-ok'; else if (/📚|🪙|📊/.test(l)) cls += ' lg-hl'; // 课程头/计费/汇总行 return `
${esc(l)}
`; }).join(''); logEl.scrollTop = logEl.scrollHeight; } /* ==================== LMS 接口层(同源 fetch,自带登录态) ==================== */ async function request(url, options) { const opt = options || {}; let lastErr = ''; for (let attempt = 1; attempt <= CFG.MAX_RETRIES; attempt++) { if (stopFlag) throw new Error('已停止'); try { const res = await fetch(url, { method: opt.method || 'GET', headers: Object.assign({ 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, opt.body ? { 'Content-Type': 'application/json' } : {}), body: opt.body ? JSON.stringify(opt.body) : undefined, credentials: 'same-origin' }); if (res.status === 401 || res.status === 403) throw new Error('登录已失效,请刷新页面重新登录'); if (res.ok) { const text = await res.text(); if (!text) return {}; try { return JSON.parse(text); } catch (_) { throw new Error('响应不是合法 JSON'); } } lastErr = 'HTTP ' + res.status; // 4xx(除 429)属于确定性失败,不重试 if (res.status < 500 && res.status !== 429) { const fatal = new Error((opt.label ? opt.label + ':' : '') + lastErr); fatal.fatal = true; throw fatal; } } catch (e) { if (e.fatal) throw e; lastErr = errMsg(e); if (/登录已失效/.test(lastErr)) throw e; } if (attempt < CFG.MAX_RETRIES) await sleep(CFG.RETRY_BASE * attempt); } throw new Error((opt.label ? opt.label + ':' : '') + lastErr); } const apiGet = (url, label) => request(url, { label }); const apiPost = (url, body, label) => request(url, { method: 'POST', body, label }); const apiPut = (url, body, label) => request(url, { method: 'PUT', body, label }); // 并发池 async function pool(items, limit, worker) { const queue = items.slice(); const run = async () => { while (queue.length) { if (stopFlag) return; await waitIfPaused(); const item = queue.shift(); try { await worker(item); } catch (e) { /* 由 worker 内部记录 */ } } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run)); } /* ==================== 积分/授权模块(沿用评阅.js 协议) ==================== */ function getSign(params, timestamp) { const str = Object.values(params).join('') + timestamp + CFG.SECRET_KEY; return CryptoJS.SHA256(str).toString(); } function getDeviceId() { let id = localStorage.getItem('device_id'); if (!id) { id = 'dev_' + Math.random().toString(36).slice(2) + Date.now(); localStorage.setItem('device_id', id); } return id; } function gmRequest(options) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || 'POST', url: options.url, headers: options.headers || { 'Content-Type': 'application/json' }, data: options.data, timeout: options.timeout || 15000, onload: (res) => { if (res.status >= 200 && res.status < 300) { try { resolve(JSON.parse(res.responseText)); } catch (_) { resolve({ code: -1, msg: '解析失败' }); } } else reject(new Error('HTTP ' + res.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')) }); }); } // 取纯文本(导出 CSV 用) function gmRequestText(url, timeout) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, timeout: timeout || 60000, onload: (res) => { if (res.status >= 200 && res.status < 300) resolve(res.responseText); else reject(new Error('HTTP ' + res.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('导出超时')) }); }); } async function syncPointsFromServer() { if (userInfo.isGuest || !userInfo.userId) return false; try { const timestamp = Date.now(); const sign = getSign({ user_id: userInfo.userId }, timestamp); const res = await gmRequest({ url: `${CFG.SERVER_URL}/api/get_user_info`, data: JSON.stringify({ user_id: userInfo.userId, timestamp, sign }) }); if (res.code === 0 && res.user) { if (res.user.score !== userInfo.points) { log(`积分同步:本地 ${userInfo.points} → 服务器 ${res.user.score}`); userInfo.points = res.user.score; localStorage.setItem('ouchn_auto_login', JSON.stringify(userInfo)); updateTopBar(); } return true; } } catch (e) { log('积分同步失败:' + errMsg(e), 'warn'); } return false; } /* ==================== 服务端评阅 + 计费 ==================== */ // 评分算法与扣费都在服务器:客户端只负责把题目发过去、把分数拿回来。 // 服务器规则:扣费 = ceil(题目数 / pointRule),同一份答卷只扣一次(回执幂等)。 function persistUser() { try { localStorage.setItem('ouchn_auto_login', JSON.stringify(userInfo)); } catch (_) { } updateTopBar(); } // 余额是否还够下一次评阅(最小扣费 1 积分) function hasPointCredit() { if (userInfo.isGuest) return userInfo.points > 0; if (!userInfo.userId) return true; // 未绑定服务器账号(开发模式) return userInfo.points > 0; } function stopForNoPoint() { stopFlag = true; showRechargeWindow(); } // 向服务器取计费规则(规则改动只需改服务器) async function fetchRules() { try { const res = await gmRequest({ method: 'GET', url: `${CFG.SERVER_URL}/api/rules`, headers: {} }); if (res && res.code === 0 && res.point_per_questions > 0) { pointRule = res.point_per_questions; } if (res && res.code === 0) { if (Number(res.homework_score_min) > 0) hwScoreMin = Number(res.homework_score_min); if (Number(res.homework_score_max) > 0) hwScoreMax = Number(res.homework_score_max); if (Number(res.homework_point_cost) > 0) hwPointCost = Number(res.homework_point_cost); // 旧版服务器不下发作业区间 → 记下标记,批改时自动走兼容通道 if (res.homework_score_min === undefined || res.homework_point_cost === undefined) { serverLegacy = true; } } } catch (e) { log('计费规则获取失败,按默认每 ' + pointRule + ' 题 1 积分', 'warn'); } updateTopBar(); updateHint(); } /** * v2.4 兼容通道:云端仍是旧版(无 /api/grade/homework)时的作业评分 * 借用已有的 /api/grade —— 提交 1 道"占位题"(满分=作业满分、视为已上传附件), * 由服务器按 80%~87% 区间给分并扣 1 积分,同样以 receipt_key 幂等,重跑不重复扣费。 */ async function serverGradeHomeworkCompat(receiptKey, homeworkId, submissionId, fullScore) { const device_id = getDeviceId(); const timestamp = Date.now(); const sign = getSign( { user_id: userInfo.userId, device_id, action: 'grade', count: 1 }, timestamp ); const res = await gmRequest({ url: `${CFG.SERVER_URL}/api/grade`, data: JSON.stringify({ user_id: userInfo.userId, device_id, timestamp, sign, receipt_key: receiptKey, questions: [{ id: String(homeworkId) + '_' + String(submissionId), point: Number(fullScore) > 0 ? Number(fullScore) : 100, answer: '', hasFile: true }] }) }); if (res.code === 0 && Array.isArray(res.scores) && res.scores.length) { if (typeof res.score === 'number') { userInfo.points = res.score; persistUser(); } if (res.charged > 0) { pointCharged += res.charged; hwGraded++; } updateTopBar(); return { ok: true, score: res.scores[0], cost: res.cost || 0, remain: res.score, compat: true }; } if (res.code === -3) { return { ok: false, noPoint: true, reason: `积分不足(需 ${res.need},剩余 ${res.score})` }; } return { ok: false, reason: res.msg || ('兼容通道错误码 ' + res.code) }; } /** * 调服务器评阅一份答卷(评分 + 扣费一次完成) * @returns {{ok:boolean, scores?:number[], cost?:number, remain?:number, noPoint?:boolean, reason?:string}} */ async function serverGrade(receiptKey, questions) { if (!userInfo.userId) return { ok: false, reason: '未绑定授权账号(缺少 user_id)' }; let lastErr = ''; for (let i = 1; i <= CFG.MAX_RETRIES; i++) { try { const device_id = getDeviceId(); const timestamp = Date.now(); const sign = getSign( { user_id: userInfo.userId, device_id, action: 'grade', count: questions.length }, timestamp ); const res = await gmRequest({ url: `${CFG.SERVER_URL}/api/grade`, data: JSON.stringify({ user_id: userInfo.userId, device_id, timestamp, sign, receipt_key: receiptKey, questions }) }); if (res.code === 0) { if (typeof res.score === 'number') { userInfo.points = res.score; // 以服务器余额为准 persistUser(); } if (res.charged > 0) { pointCharged += res.charged; gradedQuestions += res.questions || 0; } updateTopBar(); return { ok: true, scores: res.scores || [], cost: res.cost || 0, remain: res.score }; } if (res.code === -3) { return { ok: false, noPoint: true, reason: `积分不足(需 ${res.need},剩余 ${res.score})` }; } if (res.code === -1 || res.code === -2) { return { ok: false, reason: res.msg || '授权校验失败' }; // 签名/用户问题不重试 } lastErr = res.msg || ('错误码 ' + res.code); } catch (e) { lastErr = errMsg(e); // 服务器还是旧版(没有 /api/grade)时给出明确提示,避免误以为是脚本问题 if (/HTTP 40[45]/.test(lastErr)) { return { ok: false, reason: '服务器未升级(缺少 /api/grade),请先部署新版 server.js' }; } } if (i < CFG.MAX_RETRIES) await sleep(CFG.RETRY_BASE * i); } return { ok: false, reason: '评阅服务不可用:' + lastErr }; } /** * v2.2:调服务器给一份「作业型任务」定分(评分区间 + 扣费都在服务端) * 作业没有题目文本,服务端按满分区间给分,客户端只负责搬运。 * @returns {{ok:boolean, score?:number, cost?:number, remain?:number, noPoint?:boolean, reason?:string}} */ async function serverGradeHomework(receiptKey, homeworkId, submissionId, fullScore, hasContent) { if (!userInfo.userId) return { ok: false, reason: '未绑定授权账号(缺少 user_id)' }; let lastErr = ''; for (let i = 1; i <= CFG.MAX_RETRIES; i++) { try { const device_id = getDeviceId(); const timestamp = Date.now(); const sign = getSign( { user_id: userInfo.userId, device_id, action: 'grade_homework', count: 1 }, timestamp ); const res = await gmRequest({ url: `${CFG.SERVER_URL}/api/grade/homework`, data: JSON.stringify({ user_id: userInfo.userId, device_id, timestamp, sign, receipt_key: receiptKey, homework_id: homeworkId, submission_id: submissionId, full_score: fullScore, has_content: hasContent !== false }) }); if (res.code === 0) { if (typeof res.remain === 'number') { userInfo.points = res.remain; persistUser(); } else if (typeof res.score === 'number' && typeof res.cost === 'number') { userInfo.points = res.score; persistUser(); } if (res.charged > 0) { pointCharged += res.charged; hwGraded++; } updateTopBar(); return { ok: true, score: res.score, cost: res.cost || 0, remain: res.remain }; } if (res.code === -3) { return { ok: false, noPoint: true, reason: `积分不足(需 ${res.need},剩余 ${res.score})` }; } if (res.code === -1 || res.code === -2) { return { ok: false, reason: res.msg || '授权校验失败' }; } lastErr = res.msg || ('错误码 ' + res.code); } catch (e) { lastErr = errMsg(e); if (/HTTP 40[45]/.test(lastErr)) { // 旧版服务器:不中断流程,自动切兼容通道继续批改 if (!serverLegacy) { serverLegacy = true; log(' ℹ️ 云端服务器为旧版(无 /api/grade/homework),已自动切换兼容通道批改', 'warn'); } try { return await serverGradeHomeworkCompat(receiptKey, homeworkId, submissionId, fullScore); } catch (e2) { return { ok: false, reason: '兼容通道失败:' + errMsg(e2) }; } } } if (i < CFG.MAX_RETRIES) await sleep(CFG.RETRY_BASE * i); } return { ok: false, reason: '作业评阅服务不可用:' + lastErr }; } function showRechargeWindow() { if (userInfo.isGuest) return; const timestamp = Date.now(); const sign = getSign({ user_id: userInfo.userId, action: 'recharge' }, timestamp); const url = `${CFG.SERVER_URL}/recharge?user_id=${userInfo.userId}&token=${userInfo.token}×tamp=${timestamp}&sign=${sign}`; window.open(url, 'recharge', 'width=550,height=720,toolbar=no,menubar=no,scrollbars=yes,resizable=yes'); } /* ==================== 登录面板 ==================== */ function initLoginPanel() { const old = document.getElementById('ouchn_login_panel'); if (old) old.remove(); const box = document.createElement('div'); box.id = 'ouchn_login_panel'; box.className = 'ouchn-card'; box.innerHTML = `
一平台形考|统计+自动评阅
请登录授权账号(评分与扣费由服务器完成,每 2 题 1 积分)
`; document.body.appendChild(box); const tip = () => document.getElementById('ou_tip'); document.getElementById('ou_login_btn').onclick = async () => { const username = document.getElementById('ou_username').value.trim(); const password = document.getElementById('ou_password').value.trim(); if (!username) { tip().textContent = '请输入账号密码'; return; } tip().textContent = '登录中…'; try { const device_id = getDeviceId(); const timestamp = Date.now(); const sign = getSign({ username, password, device_id }, timestamp); const res = await gmRequest({ url: `${CFG.SERVER_URL}/api/login`, data: JSON.stringify({ username, password, device_id, timestamp, sign }) }); if (res.code === 0) { userInfo = { token: 'user_' + Date.now(), username, isGuest: false, points: typeof res.score === 'number' ? res.score : (parseInt(res.score, 10) || 0), userId: res.user_id || (res.user && (res.user.id || res.user.user_id)) || 0 }; localStorage.setItem('ouchn_auto_login', JSON.stringify(userInfo)); box.remove(); buildPanel(); log(`登录成功 | ${username} | 积分 ${userInfo.points}` + (userInfo.userId ? '' : '(未返回 user_id,将无法云端评阅)'), 'ok'); } else { tip().textContent = res.msg || '登录失败'; } } catch (e) { tip().textContent = '服务器连接失败:' + errMsg(e); } }; } /* ==================== 主面板 ==================== */ function buildPanel() { const old = document.getElementById('taskController'); if (old) old.remove(); panel = document.createElement('div'); panel.id = 'taskController'; panel.innerHTML = `
一平台形考|统计+自动评阅 v2.5
👤 - | 🪙 余额 0 | 本次 0题/0分
0 课程0 任务 0 人工0 自动 0 未交0 已交 0 待评0 已评
`; document.body.appendChild(panel); logEl = document.getElementById('tc_logbox'); renderLogs(); document.getElementById('tc_start').onclick = () => start(); document.getElementById('tc_pause').onclick = () => pause(); document.getElementById('tc_stop').onclick = () => stop(); document.getElementById('tc_csv').onclick = exportCsv; document.getElementById('tc_cloud').onclick = exportCloud; document.getElementById('tc_log').onclick = exportLog; document.getElementById('tc_recharge').onclick = () => { log('打开充值页面…'); showRechargeWindow(); }; document.getElementById('tc_logout').onclick = logout; document.getElementById('tc_clear').onclick = () => { document.getElementById('tc_kw').value = ''; updateHint(); }; document.getElementById('tc_kw').addEventListener('input', updateHint); document.getElementById('tc_fold').onclick = () => { const body = document.getElementById('tc_body'); const fold = document.getElementById('tc_fold'); const hidden = body.style.display === 'none'; body.style.display = hidden ? '' : 'none'; fold.textContent = hidden ? '—' : '▾'; panel.style.width = hidden ? '' : '290px'; }; updateTopBar(); updateStatsUI(); updateHint(); } // v2.5:退出登录——清除脚本登录缓存并回到登录面板(共用电脑换人时使用) function logout() { if (isRunning) { log('正在运行中,请先「停止」再退出登录', 'warn'); return; } localStorage.removeItem('ouchn_auto_login'); userInfo = { token: '', username: '', isGuest: false, points: 0, userId: 0 }; pointCharged = 0; gradedQuestions = 0; hwGraded = 0; if (panel) { panel.remove(); panel = null; } const old = document.getElementById('ouchn_login_panel'); if (old) old.remove(); initLoginPanel(); } function updateTopBar() { const u = document.getElementById('tc_user'); const p = document.getElementById('tc_points'); const b = document.getElementById('tc_bill'); if (u) u.textContent = userInfo.username || '-'; if (p) p.textContent = userInfo.points; if (b) { const parts = []; if (gradedQuestions) parts.push(`${gradedQuestions}题`); if (hwGraded) parts.push(`${hwGraded}份作业`); b.textContent = (parts.length ? parts.join('+') : '0题') + (pointCharged ? `/−${pointCharged}` : '/0分'); b.title = `考试每 ${pointRule} 题 1 积分;作业每份 ${hwPointCost} 积分。` + `本次已评 ${gradedQuestions} 题 + ${hwGraded} 份作业,扣 ${pointCharged} 积分,余额 ${userInfo.points}`; } } function updateStatsUI() { const map = { s_course: stat.course, s_task: stat.task, s_manual: stat.manual, s_auto: stat.auto, s_unsubmit: stat.unsubmit, s_submitted: stat.submitted, s_waiting: stat.waiting, s_graded: stat.graded }; for (const [id, v] of Object.entries(map)) { const el = document.getElementById(id); if (el) el.textContent = v; } updateTopBar(); } function updateHint() { const kw = document.getElementById('tc_kw')?.value.trim() || ''; const hint = document.getElementById('tc_hint'); if (!hint) return; hint.textContent = (kw ? `只统计「${kw}」|` : '') + `☁️ 云端评分 · 考试每 ${pointRule} 题 1 积分 · 作业每份 ${hwPointCost} 积分`; } function setRunning(ui) { const s = document.getElementById('tc_start'); const p = document.getElementById('tc_pause'); if (s) s.disabled = ui; if (p) p.disabled = !ui; } /* ==================== 扫描层 ==================== */ async function getAllCourses() { const cond = encodeURIComponent(JSON.stringify({ status: ['ongoing'], keyword: '' })); const all = []; for (let page = 1; page <= 30; page++) { const d = await apiGet(`/api/my-courses?conditions=${cond}&page=${page}&page_size=50`, '课程列表'); const cs = d.courses || []; if (!cs.length) break; all.push(...cs); if (all.length >= (d.total || 0)) break; } return all; } async function getCourseActivities(courseId) { // v2.1:增加 learning_activities —— "学习表现综合评价""实验报告/实验实习""社会实践" // 这类任务是藏在学习活动里的作业(type=homework),旧版只查 exams/homeworks 全部漏掉 // v2.3:个别课程合并请求可能不返回 learning_activities(服务端裁剪), // 此时单独补一次 learning_activities 请求并合并,确保大作业/实验实习/终结性考试不漏 const fetchActs = async (types) => { const url = `/api/course/${courseId}/all-activities?activity_types=${types}&no-loading-animation=true&_=${Date.now()}`; return apiGet(url, '课程活动'); }; let d; try { d = await fetchActs('exams,homeworks,assignments,learning_activities'); } catch (e) { // 合并请求失败(如 HTTP 500)时退化为分开请求,尽量拿到数据而不是整门课跳过 log(` ⚠️ 合并请求失败,改用分类请求:${errMsg(e)}`, 'warn'); const results = await Promise.all([ fetchActs('exams').catch(() => ({})), fetchActs('homeworks').catch(() => ({})), fetchActs('assignments').catch(() => ({})), fetchActs('learning_activities').catch(() => ({})) ]); d = Object.assign({}, ...results); } const seen = new Set(); const out = []; const push = (x, type) => { if (!x || !x.id || seen.has(x.id)) return; seen.add(x.id); out.push(Object.assign({ __type: type }, x)); }; (d.exams || []).forEach(x => push(x, 'exam')); (d.homeworks || []).forEach(x => push(x, 'homework')); (d.assignments || []).forEach(x => push(x, 'homework')); // 学习活动只取"作业/考试"形态(材料、视频、讨论等非考核活动忽略) (d.learning_activities || []).forEach(x => { if (x.type === 'homework') push(x, 'homework'); else if (x.type === 'exam') push(x, 'exam'); }); // v2.3 兜底:合并请求未返回学习活动时,单独补拉一次 if (!d.learning_activities || !d.learning_activities.length) { try { const d2 = await fetchActs('learning_activities'); (d2.learning_activities || []).forEach(x => { if (x.type === 'homework') push(x, 'homework'); else if (x.type === 'exam') push(x, 'exam'); }); } catch (_) { /* 补拉失败则维持已有结果 */ } } return out; } function isFormativeItem(item) { if (!item || !item.title) return false; const t = item.title; for (const p of EXCLUDE_PATTERNS) if (p.test(t)) return false; if (item.is_formative) return true; // v2.3:学习活动里的终结性考试 is_formative=false 但 is_final=true(标题可能被改名,不能只靠关键词) if (item.is_final) return true; return INCLUSIVE_PATTERNS.some(p => p.test(t)); } // 题型统计(含随机题组、父题嵌套) async function getQuestionTypes(examId) { const d = await apiGet(`/api/exams/${examId}/subjects`, '题型'); const res = { types: {}, subjective: 0, objective: 0 }; const walk = (arr) => { for (const s of (arr || [])) { if (s.type && s.type !== 'text') { res.types[s.type] = (res.types[s.type] || 0) + 1; if (SUBJECTIVE_TYPES.has(s.type)) res.subjective++; else res.objective++; } walk(s.sub_subjects); } }; walk(d.subjects); return res; } async function getExamStats(examId) { const base = { org_ids: [], department_ids: [], section_ids: [], klass_ids: [], grade_ids: [], keyword: '', is_simulating_instructor: false, sort_by: { predicate: 'id', reverse: false } }; const count = async (extra) => { const c = Object.assign({}, base, extra); const d = await apiGet(`/api/exam/${examId}/scores?page=1&page_size=1&conditions=${encodeURIComponent(JSON.stringify(c))}`, '人数统计'); return d.total || 0; }; const [unsubmitted, submitted, unmarked] = await Promise.all([ count({ submit_status: ['not_submit'] }), count({ submit_status: ['submitted'] }), count({ submit_status: ['submitted'], mark_status: ['not_marked'] }) ]); return { unsubmitted, submitted, unmarked, graded: Math.max(0, submitted - unmarked) }; } async function getHomeworkStats(homeworkId) { const base = { org_id: null, department_ids: [], grade_ids: [], class_ids: [], keyword: '', learning_center: true, clock_in_type: 'all' }; const count = async (extra) => { const c = Object.assign({}, base, extra); const d = await apiGet(`/api/homework/${homeworkId}/submission-list?conditions=${encodeURIComponent(JSON.stringify(c))}&page=1&page_size=1`, '作业人数'); return d.total || 0; }; const [unsubmitted, unmarked] = await Promise.all([ count({ status: 'unsubmitted', marked: 'all' }), count({ status: 'submitted', marked: 'unmarked' }) ]); return { unsubmitted, unmarked }; } /* ==================== 评阅层 ==================== */ // 取"已交未批改"的学生名单(分页拿全) async function fetchUnmarkedExaminees(examId) { const cond = { org_ids: [], department_ids: [], section_ids: [], klass_ids: [], grade_ids: [], keyword: '', is_simulating_instructor: false, sort_by: { predicate: 'id', reverse: false }, submit_status: ['submitted'], mark_status: ['not_marked'] }; const out = []; for (let page = 1; page <= 50; page++) { const url = `/api/exam/${examId}/scores?page=${page}&page_size=${CFG.PAGE_SIZE}&conditions=${encodeURIComponent(JSON.stringify(cond))}`; const d = await apiGet(url, '待评名单'); const items = d.items || []; out.push(...items); if (items.length < CFG.PAGE_SIZE || out.length >= (d.total || 0)) break; } return out; } // 取某学生的全部提交(含 marked 标记) async function fetchExamineeSubmissions(examId, examineeId) { const url = `/api/exams/${examId}/score-list?conditions=${encodeURIComponent(JSON.stringify({ examinee_ids: [String(examineeId)] }))}&page=1&page_size=20`; const d = await apiGet(url, '提交记录'); const e = (d.examinees || [])[0]; if (!e) return null; return { name: e.name, score: e.score, submissions: (e.submissions || []).slice().sort((a, b) => String(a.submitted_at || '').localeCompare(String(b.submitted_at || ''))) }; } // 把答卷拆成"可评分子题"列表:分析题/材料题取子题,普通主观题取自身 function extractGradeUnits(subjectsData) { const units = []; const walk = (arr) => { for (const s of (arr || [])) { if (SUBJECTIVE_TYPES.has(s.type)) { const subs = s.sub_subjects || []; if (subs.length) { // 父题(分析题/材料题/组合题):评分子题 for (const c of subs) { units.push({ id: c.id, parentId: s.id, point: parseFloat(c.point || s.point || 0) || 0, ref: stripHtml(c.answer_explanation || s.answer_explanation || '') }); } } else { units.push({ id: s.id, parentId: s.parent_id || null, point: parseFloat(s.point || 0) || 0, ref: stripHtml(s.answer_explanation || '') }); } } else { walk(s.sub_subjects); // 客观题组里也可能嵌套主观题 } } }; walk(subjectsData.subjects); return units; } // 开发调试用的占位分已移除(v2.0 起评分一律走服务器) // 批改一份提交:整卷所有主观题一次提交 async function gradeSubmission(examId, examineeId, submissionId) { const d = await apiGet(`/api/exams/${examId}/submissions/${submissionId}`, '答卷'); const units = extractGradeUnits(d.subjects_data || {}); if (!units.length) return { ok: false, reason: '本卷无主观题' }; const ansMap = {}; for (const a of ((d.submission_data || {}).subjects || [])) ansMap[a.subject_id] = a; // 打包给服务器:题目满分 + 学生原文 + 是否有附件 + 参考答案 const questions = units.map(u => { const a = ansMap[u.id]; return { id: u.id, point: u.point, answer: a ? stripHtml(a.answer || '') : '', hasFile: !!(a && (a.attachments || []).length), ref: u.ref || '' }; }); const r = await serverGrade(`${examId}_${submissionId}`, questions); if (!r.ok) { return { ok: false, reason: r.reason, noPoint: !!r.noPoint }; } const scores = r.scores; if (!Array.isArray(scores) || scores.length !== questions.length) { return { ok: false, reason: '服务器返回分数数量不匹配' }; } let zeroCount = 0; const graded = questions.map((q, i) => { const sc = Number(scores[i]) || 0; if (sc === 0) zeroCount++; return { subject_id: q.id, score: String(sc), instance_id: d.instance_id, parent_id: (units[i] && units[i].parentId) || null }; }); if (!graded.length) return { ok: false, reason: '无可评分题目' }; const res = await apiPost(`/api/exams/${examId}/give-score`, { examinee_id: examineeId, graded_subjects: graded, submission_id: submissionId }, '提交评分'); return { ok: true, marked: res && res.marked !== false, examScore: res && res.exam_score, questionCount: graded.length, zeroCount }; } // 批改一个学生的所有未批改提交 async function reviewExaminee(examId, examineeId) { const info = await fetchExamineeSubmissions(examId, examineeId); if (!info) return { done: 0, name: '' }; const pending = info.submissions.filter(s => !s.marked); let done = 0; for (const sub of pending) { if (stopFlag) break; await waitIfPaused(); if (!hasPointCredit()) { log('积分不足,已停止评阅,请充值后再继续', 'error'); stopForNoPoint(); break; } try { const r = await gradeSubmission(examId, examineeId, sub.id); if (r.ok) { done++; stat.gradedByMe++; updateTopBar(); const sc = (r.examScore === undefined || r.examScore === null || r.examScore === '') ? '-' : r.examScore; log(` ✔ ${info.name} 已评阅 ${r.questionCount} 题 → ${sc} 分,剩余积分:${userInfo.points}`, 'ok'); } else { stat.failed++; log(` ⚠️ ${info.name} 评阅失败:${r.reason}`, 'warn'); if (r.noPoint) { stopForNoPoint(); break; } } } catch (e) { stat.failed++; log(` ⚠️ ${info.name} 评阅异常:${errMsg(e)}`, 'warn'); } await sleep(200); } return { done, name: info.name }; } // 批改整场测验的所有待评学生 async function reviewExam(examId, label) { let examinees = []; try { examinees = await fetchUnmarkedExaminees(examId); } catch (e) { log(` ⚠️ 获取待评名单失败:${errMsg(e)}`, 'warn'); return 0; } if (!examinees.length) { log(' ℹ️ 没有待评阅的提交'); return 0; } log(` ▶ ${examinees.length} 名学生待评`); let total = 0; for (let i = 0; i < examinees.length; i++) { if (stopFlag) break; await waitIfPaused(); const r = await reviewExaminee(examId, examinees[i].examinee_id); total += r.done; if ((i + 1) % 5 === 0) log(` …进度 ${i + 1}/${examinees.length},已评 ${total} 份`); } log(` ✅ 本任务自动评阅完成:共 ${total} 份`, 'ok'); return total; } /* ---------- v2.2:作业型任务评阅(学习表现/实验报告/社会实践/终结性考试) ---------- */ // 平台真实接口(教师端"批改"页保存时调用,已实测 201 通过): // PUT /api/course/activities/{活动id}/submission/score?fields=id,score,instructor_comment,rubric_score,final_score // body: { score, reviewer_comment, uploads: [], id: 提交id, student_id: 学生id } // 取某作业"已交未批改"的提交列表(分页拿全) async function fetchHomeworkUnmarked(homeworkId) { const base = { org_id: null, department_ids: [], grade_ids: [], class_ids: [], keyword: '', learning_center: true, clock_in_type: 'all' }; const cond = Object.assign({}, base, { status: 'submitted', marked: 'unmarked' }); const out = []; for (let page = 1; page <= 30; page++) { const url = `/api/homework/${homeworkId}/submission-list?conditions=${encodeURIComponent(JSON.stringify(cond))}&page=${page}&page_size=${CFG.PAGE_SIZE}`; const d = await apiGet(url, '待批作业'); const items = d.submissions || []; for (const it of items) { const sub = it.submission || {}; const st = it.student || {}; if (!sub.id || !st.id) continue; if (!it.submitted || it.marked) continue; // 双保险:只要已交且未批 out.push({ submission_id: sub.id, student_id: st.id, student_name: String(st.name || ''), student_no: String(st.user_no || ''), submit_time: sub.created_at || null }); } if (items.length < CFG.PAGE_SIZE || out.length >= (d.total || 0)) break; } return out; } // 提交一份作业成绩 async function submitHomeworkScore(homeworkId, submissionId, studentId, score, comment) { const url = `/api/course/activities/${homeworkId}/submission/score` + `?fields=id,score,instructor_comment,rubric_score,final_score`; await apiPut(url, { score: Math.round(Number(score) * 10) / 10, reviewer_comment: comment || '', uploads: [], id: submissionId, student_id: studentId }, '作业评分'); } // 批改一整项作业任务的所有待批提交 async function reviewHomework(homeworkId, title, fullScore) { let list = []; try { list = await fetchHomeworkUnmarked(homeworkId); } catch (e) { log(` ⚠️ 获取待批作业失败:${errMsg(e)}`, 'warn'); return 0; } if (!list.length) { log(' ℹ️ 没有待批改的作业'); return 0; } log(` ▶ ${list.length} 份作业待批改(按满分 ${serverLegacy ? '80%-87%' : hwScoreMin + '%-' + hwScoreMax + '%'} 给分)`); let done = 0; for (let i = 0; i < list.length; i++) { if (stopFlag) break; await waitIfPaused(); if (!hasPointCredit()) { log('积分不足,已停止批改,请充值后再继续', 'error'); stopForNoPoint(); break; } const it = list[i]; try { // 分数由服务器决定(防篡改),客户端只负责提交 const r = await serverGradeHomework( `hw_${homeworkId}_${it.submission_id}`, homeworkId, it.submission_id, fullScore || 100, true ); if (!r.ok) { stat.failed++; log(` ⚠️ ${it.student_name} 批改失败:${r.reason}`, 'warn'); if (r.noPoint) { stopForNoPoint(); break; } continue; } await submitHomeworkScore(homeworkId, it.submission_id, it.student_id, r.score, ''); done++; stat.gradedByMe++; updateTopBar(); const who = it.student_name || it.student_no || '学生'; log(` ✔ ${who} 已批改 → ${r.score} 分,剩余积分:${userInfo.points}`, 'ok'); } catch (e) { stat.failed++; log(` ⚠️ ${it.student_name} 批改异常:${errMsg(e)}`, 'warn'); } await sleep(250); } log(` ✅ 本任务作业批改完成:共 ${done} 份`, 'ok'); return done; } /* ==================== 云端数据(学号/姓名/成绩) ==================== */ // 取某场考试的全员名单(已交 + 未交),带学号、姓名、班级、成绩 async function fetchExamRoster(examId) { const base = { org_ids: [], department_ids: [], section_ids: [], klass_ids: [], grade_ids: [], keyword: '', is_simulating_instructor: false, sort_by: { predicate: 'id', reverse: false } }; const fetchAll = async (submitStatus) => { const out = []; for (let page = 1; page <= 40; page++) { const cond = Object.assign({}, base, { submit_status: [submitStatus] }); const url = `/api/exam/${examId}/scores?page=${page}&page_size=${CFG.PAGE_SIZE}` + `&conditions=${encodeURIComponent(JSON.stringify(cond))}`; const d = await apiGet(url, '学生名单'); const items = d.items || []; out.push(...items); if (items.length < CFG.PAGE_SIZE || out.length >= (d.total || 0)) break; } return out; }; const [submitted, missing] = await Promise.all([ fetchAll('submitted').catch(() => []), fetchAll('not_submit').catch(() => []) ]); const map = new Map(); const put = (x, isSubmitted) => { if (!x) return; const cb = x.created_by || {}; const no = String(cb.user_no || x.user_no || ''); const key = no || String(x.examinee_id || ''); if (!key) return; const prev = map.get(key); if (prev && prev.submitted && !isSubmitted) return; // 已交优先 const sc = (x.score === null || x.score === undefined || x.score === '') ? null : parseFloat(x.score); map.set(key, { examinee_id: x.examinee_id || null, student_no: no, student_name: String(cb.name || x.name || ''), class_name: String((x.klass && x.klass.name) || ''), submitted: isSubmitted ? 1 : 0, score: isSubmitted && !isNaN(sc) ? sc : null, submit_time: isSubmitted ? (x.submit_time || null) : null }); }; submitted.forEach(x => put(x, true)); missing.forEach(x => put(x, false)); return Array.from(map.values()); } // v2.1:取作业(含学习活动型作业)的提交名单:学号/姓名/班级/成绩/提交时间 // 学习表现综合评价、实验报告、社会实践等都是这种形态(平台未开放教师直评 API,仅统计+名单) async function fetchHomeworkRoster(homeworkId) { const base = { org_id: null, department_ids: [], grade_ids: [], class_ids: [], keyword: '', learning_center: true, clock_in_type: 'all' }; const out = []; for (let page = 1; page <= 20; page++) { const url = `/api/homework/${homeworkId}/submission-list?conditions=${encodeURIComponent(JSON.stringify(base))}&page=${page}&page_size=100`; const d = await apiGet(url, '作业名单'); const items = d.submissions || []; for (const it of items) { const st = it.student || {}; const sub = it.submission || {}; out.push({ examinee_id: st.id || null, student_no: String(st.user_no || ''), student_name: String(st.name || ''), class_name: String((st.klass && st.klass.name) || ''), submitted: it.submitted ? 1 : 0, score: (it.score === null || it.score === undefined || it.score === '') ? null : parseFloat(it.score), submit_time: sub.created_at || null }); } if (items.length < 100) break; } return out; } // 把一个课程的任务与学生明细上传到服务器(分课程分块,重跑可覆盖同一批) async function uploadCourseScan(course, collector, isLast) { if (!userInfo.userId) return; if (!collector || !collector.tasks.length) return; if (!batchKey) batchKey = 'b' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); const body = { user_id: userInfo.userId, device_id: getDeviceId(), timestamp: 0, sign: '', batch_key: batchKey, keyword: (document.getElementById('tc_kw') || {}).value || '', course: { course_id: course.id, course_name: course.name || String(course.id), tasks: collector.tasks }, final: !!isLast }; body.timestamp = Date.now(); body.sign = getSign({ user_id: body.user_id, device_id: body.device_id, action: 'scan', batch_key: body.batch_key }, body.timestamp); for (let attempt = 1; attempt <= 2; attempt++) { try { const res = await gmRequest({ url: `${CFG.SERVER_URL}/api/scan/save`, data: JSON.stringify(body), timeout: 30000 }); if (res.code === 0) { const n = collector.tasks.reduce((a, t) => a + (t.students ? t.students.length : 0), 0); scanSavedStudents += n; log(` ☁️ 云端已存:${collector.tasks.length} 任务 / ${n} 条明细`); return true; } if (attempt === 2) log(` ⚠️ 云端保存失败:${res.msg || '未知错误'}`, 'warn'); } catch (e) { if (attempt === 2) log(` ⚠️ 云端保存异常:${errMsg(e)}`, 'warn'); } await sleep(600 * attempt); } return false; } // CSV 解析(服务端格式:全字段加引号、"转义、\r\n 换行) function parseCsv(text) { text = String(text).replace(/^\ufeff/, ''); const rows = []; let row = [], cell = '', inQ = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQ) { if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else inQ = false; } else cell += c; } else if (c === '"') inQ = true; else if (c === ',') { row.push(cell); cell = ''; } else if (c === '\r') { /* 跳过 */ } else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; } else cell += c; } if (cell !== '' || row.length) { row.push(cell); rows.push(row); } return rows.filter(r => r.length > 1 || (r[0] !== undefined && r[0] !== '')); } // 云端导出:合并为一个 Excel 工作簿(4 个工作表),一次下载 async function exportCloud() { if (!userInfo.userId) { log('请先登录授权账号再导出', 'error'); return; } log('☁️ 正在获取云端扫描记录…'); try { let ts = Date.now(); let sign = getSign({ user_id: userInfo.userId, device_id: getDeviceId(), action: 'scan_list' }, ts); const list = await gmRequest({ url: `${CFG.SERVER_URL}/api/scan/batches`, data: JSON.stringify({ user_id: userInfo.userId, device_id: getDeviceId(), timestamp: ts, sign }) }); if (list.code !== 0) { log('获取失败:' + (list.msg || ''), 'warn'); return; } const batches = list.batches || []; if (!batches.length) { log('云端还没有扫描数据:点“开始”跑一次,数据会自动上传', 'warn'); return; } const b = batches[0]; log(`☁️ 导出批次 #${b.id}:${b.course_count} 门课 / ${b.task_count} 个任务 / ${b.student_count} 名学生`); const types = [ ['courses', '按学科统计', [1, 2, 3, 4, 5, 6, 7]], // 数值列索引 ['students', '已交学生成绩', [5]], ['missing', '未交学生名单', []], ['summary', '学生汇总', [4, 5, 6]] ]; // 拉取 4 张 CSV 并解析为二维数组 const sheets = []; for (const [type, label] of types) { ts = Date.now(); sign = getSign({ user_id: userInfo.userId, batch_id: b.id, action: 'scan_export' }, ts); const url = `${CFG.SERVER_URL}/api/scan/export?user_id=${userInfo.userId}&batch_id=${b.id}` + `&type=${type}×tamp=${ts}&sign=${sign}`; const text = await gmRequestText(url); const parsed = parseCsv(text); sheets.push([label, parsed, (types.find(t => t[1] === label) || [])[2] || []]); log(` ✔ 已获取:${label}(${Math.max(0, parsed.length - 1)} 行)`); } // 数值列转数字便于 Excel 求和/筛选;自动列宽 function buildSheet(rows, numericCols) { const aoa = rows.map((r, ri) => ri === 0 ? r : r.map((v, ci) => numericCols.includes(ci) && v !== '' && !isNaN(Number(v)) ? Number(v) : v)); const ws = XLSX.utils.aoa_to_sheet(aoa); ws['!cols'] = aoa[0].map((h, ci) => ({ wch: Math.min(30, Math.max(8, aoa.reduce((m, r) => Math.max(m, String(r[ci] === undefined ? '' : r[ci]).length + 4), 8))) })); return ws; } if (typeof XLSX !== 'undefined') { // 首选:合并为一个 .xlsx 工作簿(4 个工作表),一次下载 const wb = XLSX.utils.book_new(); for (const [label, rows, numericCols] of sheets) { XLSX.utils.book_append_sheet(wb, buildSheet(rows, numericCols), label); } const filename = `国开扫描数据_批次${b.id}_${stamp()}.xlsx`; XLSX.writeFile(wb, filename); log(`✅ 云端导出完成:${filename}(${sheets.length} 个工作表)`, 'ok'); } else { // 兜底二:Excel 组件未加载 → 逐个下载 CSV(补 BOM 防乱码) log('⚠️ Excel 组件未加载,回退为 CSV 导出', 'warn'); let okCount = 0; for (const [label, rows] of sheets) { let csv = '\ufeff' + rows.map(r => r.map(v => `"${String(v === undefined ? '' : v).replace(/"/g, '""')}"`).join(',')).join('\r\n'); download(`${label}_${b.id}_${stamp()}.csv`, csv, 'text/csv;charset=utf-8'); okCount++; await sleep(1500); } log(`✅ CSV 导出完成(${okCount} 个文件,Excel 打开请选择 UTF-8)`, 'ok'); } } catch (e) { log('导出失败:' + errMsg(e), 'error'); } } /* ==================== 主流程 ==================== */ async function start() { if (isRunning) { log('正在运行中', 'warn'); return; } autoReview = document.getElementById('tc_autoreview').checked; const keyword = document.getElementById('tc_kw').value.trim(); isRunning = true; isPaused = false; stopFlag = false; setRunning(true); document.getElementById('tc_csv').disabled = true; document.getElementById('tc_cloud').disabled = true; document.getElementById('tc_log').disabled = true; // 续跑判断 const saved = loadProgress(); if (saved && progress.filterKeyword === keyword && progress.courses && progress.courses.length) { // 续跑沿用同一批次,服务器端会覆盖更新,不产生重复批次 batchKey = progress.batchKey || ('b' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)); Object.assign(stat, progress.stat || {}); rows = progress.rows || []; logs.length = 0; (progress.logs || []).forEach(l => logs.push(l)); if (logEl) { logEl.textContent = logs.join('\n'); logEl.scrollTop = logEl.scrollHeight; } updateStatsUI(); log(`🔄 发现未完成的进度,从第 ${progress.cursor + 1} 门课继续(已完成 ${progress.cursor}/${progress.courses.length})`, 'warn'); try { if (!await preparePoints()) { abortRun(); return; } await runCourses(progress.courses, progress.cursor); } catch (e) { log('中断:' + errMsg(e), 'error'); } await finish(); return; } clearProgress(); resetStat(); batchKey = 'b' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); scanSavedStudents = 0; log(`🚀 v${GM_info.script.version} 开始${keyword ? `|筛选「${keyword}」` : ''}|自动评阅${autoReview ? '开' : '关'}`); try { if (!await preparePoints()) { abortRun(); return; } const all = await getAllCourses(); if (!all.length) { log('未获取到课程', 'error'); await finish(); return; } let courses = all; if (keyword) { const k = keyword.toLowerCase(); courses = all.filter(c => (c.name || '').toLowerCase().includes(k)); } log(`📚 共 ${all.length} 门课程,筛选后 ${courses.length} 门`); if (!courses.length) { log('没有匹配的课程', 'warn'); await finish(); return; } progress.courses = courses.map(c => ({ id: c.id, name: c.name })); progress.cursor = 0; progress.filterKeyword = keyword; saveProgress(); stat.course = courses.length; updateStatsUI(); await runCourses(courses, 0); } catch (e) { log('异常:' + errMsg(e), 'error'); } await finish(); } // 开始前:拉计费规则、同步余额、检查余额是否够用 async function preparePoints() { gradedQuestions = 0; hwGraded = 0; pointCharged = 0; updateTopBar(); if (!userInfo.userId) { log('未绑定授权账号(缺 user_id),无法进行云端评阅,请重新登录', 'error'); return false; } await fetchRules(); const ok = await syncPointsFromServer(); if (!ok) log('积分同步未成功,暂用本地余额', 'warn'); if (!hasPointCredit()) { if (autoReview) { log(`积分不足(当前 ${userInfo.points}),无法自动评阅,请充值后再开始`, 'error'); showRechargeWindow(); return false; } log(`积分不足(当前 ${userInfo.points}),仅做统计,不再自动评阅`, 'warn'); autoReview = false; } log(`🪙 计费:考试每 ${pointRule} 题 1 积分 | 作业每份 ${hwPointCost} 积分(按满分 ${hwScoreMin}%-${hwScoreMax}%)| 余额 ${userInfo.points}`); if (serverLegacy) log('ℹ️ 云端服务器为旧版:作业批改自动走兼容通道(服务器按满分 80%~87% 给分,每份 1 积分)', 'warn'); log('☁️ 评分在服务器完成,客户端不含打分算法'); return true; } async function runCourses(courses, fromIndex) { for (let i = fromIndex; i < courses.length; i++) { if (stopFlag) break; await waitIfPaused(); progress.cursor = i; await processCourse(courses[i], i + 1, courses.length); saveProgress(); } } async function processCourse(course, idx, total) { log(`\n📚 [${idx}/${total}] ${course.name || course.id}`); let items; try { items = await getCourseActivities(course.id); } catch (e) { log(` ⚠️ 获取活动失败:${errMsg(e)}`, 'warn'); return; } const tasks = items.filter(isFormativeItem); if (!tasks.length) { log(' 无相关形考/作业任务'); return; } log(` 识别到形考任务 ${tasks.length} 项`); // 先并发统计,把需要评阅的任务收集起来,再串行评阅(避免并发打爆服务器) const reviewJobs = []; const collector = { tasks: [] }; // 本课程待上传云端的任务与学生明细 await pool(tasks, CFG.SCAN_CONCURRENCY, async (task) => { if (stopFlag) return; stat.task++; if (task.__type === 'exam') { const job = await handleExam(task, course.name, collector); if (job) reviewJobs.push(job); } else { const job = await handleHomework(task, course.name, collector); if (job) reviewJobs.push(job); } updateStatsUI(); }); for (const job of reviewJobs) { if (stopFlag) break; await waitIfPaused(); // v2.2:考试走"题目评分",作业型任务走"整份给分" const n = job.kind === 'homework' ? await reviewHomework(job.homeworkId, job.title, job.fullScore) : await reviewExam(job.examId, job.title); job.row.autoGraded = n; // 评阅后回查真实人数,用增量修正统计(份数 ≠ 人次,不能直接累加) try { const after = job.kind === 'homework' ? await getHomeworkStats(job.homeworkId) : await getExamStats(job.examId); if (job.kind === 'homework') { // 作业:批改完成后待评清零,已评份数计入 const gradedBefore = (job.before.unmarked || 0) - (after.unmarked || 0); stat.waiting -= gradedBefore; stat.graded += gradedBefore; job.row.waiting = after.unmarked; job.row.graded = gradedBefore; } else { stat.waiting += (after.unmarked - job.before.unmarked); stat.graded += (after.graded - job.before.graded); job.row.waiting = after.unmarked; job.row.graded = after.graded; } if (stat.waiting < 0) stat.waiting = 0; if (stat.graded < 0) stat.graded = 0; } catch (_) { /* 回查失败则保留扫描时的数值 */ } updateStatsUI(); } // 评阅完成后再抓名单:此时成绩、未交名单都是最新状态 if (userInfo.userId) { for (const t of collector.tasks) { if (stopFlag) break; try { // v2.1:考试抓全员名单,作业(含学习活动型作业)抓提交名单 const roster = t.task_type === 'exam' ? await fetchExamRoster(t.task_id) : await fetchHomeworkRoster(t.task_id); t.students = roster; if (!t.total_students) t.total_students = roster.length; const scores = roster.filter(x => x.submitted && x.score !== null).map(x => x.score); if (scores.length) { t.avg_score = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10; } } catch (e) { log(` ⚠️ ${t.task_title} 名单获取失败:${errMsg(e)}`, 'warn'); } } } // 扫描(含评阅)完成后,把本课程明细上传云端;最后一门课标记批次完成 await uploadCourseScan(course, collector, idx === total); } async function handleExam(task, courseName, collector) { const title = task.title; let qt = null, st = null; try { qt = await getQuestionTypes(task.id); } catch (e) { log(` ⚠️ ${title} 题型获取失败:${errMsg(e)}`, 'warn'); } try { st = await getExamStats(task.id); } catch (e) { log(` ⚠️ ${title} 人数获取失败:${errMsg(e)}`, 'warn'); } const isManual = !!(qt && qt.subjective > 0); if (qt) { if (isManual) stat.manual++; else stat.auto++; } if (st) { stat.unsubmit += st.unsubmitted; stat.submitted += st.submitted; stat.waiting += st.unmarked; stat.graded += st.graded; } log(` ${isManual ? '👀' : '🤖'} ${title}|未交 ${st ? st.unsubmitted : '?'} 已交 ${st ? st.submitted : '?'}|待评 ${st ? st.unmarked : '?'} 已评 ${st ? st.graded : '?'}`); rows.push({ course: courseName, title, type: '测验/考试', review: qt ? (isManual ? '人工评阅' : '自动评阅') : '未知', subjective: qt ? qt.subjective : '', objective: qt ? qt.objective : '', unsubmitted: st ? st.unsubmitted : '', submitted: st ? st.submitted : '', waiting: st ? st.unmarked : '', graded: st ? st.graded : '', autoGraded: 0 }); const row = rows[rows.length - 1]; // 记录到云端采集器(学生名单在评阅结束后统一抓,成绩才是最新的) if (collector) { collector.tasks.push({ task_id: task.id, task_title: title, task_type: 'exam', review_type: qt ? (isManual ? '人工评阅' : '自动评阅') : '', total_students: st ? (st.submitted + st.unsubmitted) : 0, submitted: st ? st.submitted : 0, unsubmitted: st ? st.unsubmitted : 0, unmarked: st ? st.unmarked : 0, graded: st ? st.graded : 0, avg_score: null, students: [] }); } // 扫描到待评 → 交给串行评阅队列 if (autoReview && isManual && st && st.unmarked > 0) { return { kind: 'exam', examId: task.id, title, row, before: st }; } return null; } async function handleHomework(task, courseName, collector) { let st = null; try { st = await getHomeworkStats(task.id); } catch (e) { log(` ⚠️ ${task.title} 人数获取失败:${errMsg(e)}`, 'warn'); } // v2.2:作业型任务也能自动批改(平台有教师评分接口),有自动评阅意愿就归入"自动" const canAuto = autoReview && !!st && st.unmarked > 0; const reviewLabel = canAuto ? '作业批改' : '人工评阅'; if (canAuto) stat.auto++; else stat.manual++; if (st) { stat.unsubmit += st.unsubmitted || 0; stat.submitted += st.unmarked || 0; stat.waiting += st.unmarked || 0; } const icon = canAuto ? '📝' : '👀'; log(` ${icon} ${task.title}|未交 ${st ? st.unsubmitted : '?'} 已交未评 ${st ? st.unmarked : '?'}`); rows.push({ course: courseName, title: task.title, type: '作业', review: reviewLabel, subjective: '-', objective: '-', unsubmitted: st ? st.unsubmitted : '', submitted: '', waiting: st ? st.unmarked : '', graded: '', autoGraded: 0 }); const row = rows[rows.length - 1]; if (collector) { collector.tasks.push({ task_id: task.id, task_title: task.title, task_type: 'homework', review_type: reviewLabel, total_students: st ? ((st.unsubmitted || 0) + (st.unmarked || 0)) : 0, submitted: st ? (st.unmarked || 0) : 0, unsubmitted: st ? (st.unsubmitted || 0) : 0, unmarked: st ? (st.unmarked || 0) : 0, graded: 0, avg_score: null, students: [] }); } // 扫描到待批 → 交给串行批改队列(平台上作业满分固定 100,score_percentage 只是课程占比) if (canAuto) { return { kind: 'homework', homeworkId: task.id, title: task.title, row, before: st, fullScore: 100 }; } return null; } // 未能开始(积分不足等):恢复面板状态,不打印“全部完成” function abortRun() { isRunning = false; isPaused = false; setRunning(false); const lg = document.getElementById('tc_log'); if (lg) lg.disabled = false; const csv = document.getElementById('tc_csv'); const cl = document.getElementById('tc_cloud'); if (csv) csv.disabled = rows.length === 0; if (cl) cl.disabled = false; log('⏹ 本次未开始扫描/评阅(充值后重新点“开始”即可)', 'warn'); } async function finish() { isRunning = false; isPaused = false; setRunning(false); const csv = document.getElementById('tc_csv'); const lg = document.getElementById('tc_log'); const cl = document.getElementById('tc_cloud'); if (csv) csv.disabled = rows.length === 0; if (cl) cl.disabled = false; if (lg) lg.disabled = false; const billLine = () => { const hw = hwGraded ? ` + ${hwGraded} 份作业` : ''; log(`🪙 计费:已评 ${gradedQuestions} 题${hw} → 扣 ${pointCharged} 积分,余额 ${userInfo.points}`); }; if (stopFlag) { billLine(); log('⏹ 已停止(进度已保存,再次点“开始”可继续)', 'warn'); saveProgress(); return; } log(`🎉 全部完成|课程 ${stat.course} 门,任务 ${stat.task} 项(人工 ${stat.manual}/自动 ${stat.auto})`, 'ok'); log(`📊 未交 ${stat.unsubmit}|已交 ${stat.submitted}|待评 ${stat.waiting}|已评 ${stat.graded}|本次评阅 ${stat.gradedByMe} 份,失败 ${stat.failed} 份`); if (scanSavedStudents > 0) { log(`☁️ 云端已存 ${scanSavedStudents} 条明细,点「☁️ 云端Excel」导出`, 'ok'); } billLine(); clearProgress(); } function pause() { if (!isRunning) return; isPaused = !isPaused; log(isPaused ? '⏸ 已暂停' : '▶ 已继续'); document.getElementById('tc_pause').textContent = isPaused ? '▶ 继续' : '⏸ 暂停'; if (isPaused) saveProgress(); } function stop() { stopFlag = true; isPaused = false; log('⏹ 正在停止…(当前请求结束后退出)', 'warn'); saveProgress(); } function resetStat() { Object.assign(stat, { course: 0, task: 0, manual: 0, auto: 0, unsubmit: 0, submitted: 0, waiting: 0, graded: 0, gradedByMe: 0, failed: 0, skipped: 0 }); rows = []; logs.length = 0; if (logEl) logEl.textContent = ''; updateStatsUI(); } /* ==================== 断点续跑 ==================== */ function saveProgress() { progress.batchKey = batchKey; progress.stat = Object.assign({}, stat); progress.rows = rows; progress.logs = logs.slice(-300); try { localStorage.setItem(SAVE_KEY, JSON.stringify(progress)); } catch (_) { } } function loadProgress() { try { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return false; const p = JSON.parse(raw); if (!p || !p.courses || !p.courses.length) return false; if (p.cursor >= p.courses.length) return false; progress = p; return true; } catch (_) { return false; } } function clearProgress() { localStorage.removeItem(SAVE_KEY); } /* ==================== 导出 ==================== */ function download(filename, content, mime) { const blob = new Blob([content], { type: mime }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000); } function exportCsv() { const head = '课程名称,任务名称,类型,评阅方式,主观题数,客观题数,未提交人数,已提交人数,待评阅人数,已评阅人数,本次自动评阅份数\n'; const body = rows.map(r => [ r.course, r.title, r.type, r.review, r.subjective, r.objective, r.unsubmitted, r.submitted, r.waiting, r.graded, r.autoGraded ].map(v => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`).join(',')).join('\n'); download(`一平台形考统计_${stamp()}.csv`, '\ufeff' + head + body, 'text/csv;charset=utf-8'); log('📄 本地明细已导出 CSV', 'ok'); } function exportLog() { download(`一平台形考日志_${stamp()}.txt`, logs.join('\n'), 'text/plain;charset=utf-8'); log('💾 日志已导出', 'ok'); } /* ==================== 样式 ==================== */ function injectStyle() { const css = ` .ouchn-card{position:fixed;right:20px;bottom:20px;width:340px;background:#fff;border-radius:12px; box-shadow:0 6px 24px rgba(0,0,0,.18);padding:16px;z-index:2147483000;font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif} .ouchn-title{font-weight:700;font-size:15px;color:#1677ff;margin-bottom:4px} .ouchn-sub{font-size:12px;color:#888;margin-bottom:10px} .ouchn-input{width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid #dcdcdc;border-radius:6px;margin-bottom:8px;font-size:13px;background:#fff;color:#1f2329} .ouchn-btns{display:flex;gap:8px} .ouchn-btn{flex:1;padding:8px 0;border:none;border-radius:6px;color:#fff;background:#909399;cursor:pointer;font-weight:600} .ouchn-btn.primary{background:#1677ff} .ouchn-tip{color:#f53f3f;font-size:12px;margin-top:8px;min-height:16px} #taskController{position:fixed;right:16px;bottom:16px;width:380px;max-height:calc(100vh - 32px);display:flex;flex-direction:column; background:#fff;border-radius:12px;box-shadow:0 6px 24px rgba(0,0,0,.18);padding:12px;z-index:2147483000; font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:#1f2329;transition:width .15s} .tc-head{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-bottom:8px} .tc-title{font-weight:700;font-size:13.5px;color:#1677ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-ver{font-weight:400;font-size:11px;color:#aaa} #tc_fold{border:none;background:#eef0f3;border-radius:6px;width:22px;height:22px;flex-shrink:0; cursor:pointer;color:#555;font-size:12px;line-height:1;padding:0} #tc_fold:hover{background:#e0e3e8} .tc-user{background:#f7f9fc;border-radius:8px;padding:6px 10px;font-size:12px;color:#555; margin-bottom:8px;display:flex;align-items:center;flex-wrap:wrap;gap:2px} .tc-user b{color:#1677ff} .tc-recharge{margin-left:auto;padding:2px 8px;border:none;border-radius:10px;background:#fff3e0;color:#e67300; font-size:11px;font-weight:600;cursor:pointer;flex-shrink:0} .tc-recharge:hover{background:#ffe0b2} .tc-logout{margin-left:4px;padding:2px 8px;border:none;border-radius:10px;background:#fdecea;color:#d93025; font-size:11px;font-weight:600;cursor:pointer;flex-shrink:0} .tc-logout:hover{background:#fadbd8} .tc-filter{display:flex;gap:6px;align-items:center;margin-bottom:8px} .tc-filter input#tc_kw{flex:1;min-width:0;padding:6px 8px;border:1px solid #dcdcdc;border-radius:6px; font-size:12.5px;height:auto;line-height:1.4;background:#fff;color:#1f2329} .tc-filter button{padding:6px 10px;border:none;border-radius:6px;background:#eef0f3;color:#444; cursor:pointer;font-size:12px;line-height:1.3;flex-shrink:0} .tc-ar{white-space:nowrap;display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#555;cursor:pointer;flex-shrink:0} .tc-btns{display:flex;gap:6px;margin-bottom:6px} .tc-btns button{flex:1;min-width:0;white-space:nowrap;padding:7px 2px;border:none;border-radius:6px; color:#fff;cursor:pointer;font-weight:600;font-size:12px;line-height:1.3} .tc-btns button:disabled{opacity:.45;cursor:not-allowed} .green{background:#00b42a}.orange{background:#ff7d00}.red{background:#f53f3f}.gray{background:#8f959e} .tc-stats{background:#f7f9fc;border-left:3px solid #1677ff;border-radius:6px;padding:8px 10px;font-size:12px;margin-bottom:8px} .tc-chips{display:grid;grid-template-columns:repeat(4,1fr);gap:4px} .tc-chips span{background:#fff;border-radius:5px;padding:3px 2px;font-size:11px;color:#666;text-align:center; white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-chips b{color:#1677ff;font-weight:700} .tc-hint{color:#1677ff;font-size:11.5px;margin-top:4px} .tc-log{flex:1;min-height:140px;max-height:36vh;overflow-y:auto;background:#1e1f22;color:#dfe3ea; border-radius:8px;padding:6px 10px;font:11.5px/1.55 ui-monospace,Consolas,monospace;white-space:pre-wrap} .tc-log::-webkit-scrollbar{width:6px} .tc-log::-webkit-scrollbar-thumb{background:#444;border-radius:3px} .lg{padding:0;border-bottom:1px dashed rgba(255,255,255,.06)} .lg-err{color:#ff7875} .lg-warn{color:#ffc53d} .lg-ok{color:#95de64} .lg-hl{color:#69b1ff;font-weight:600} `; if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; } const el = document.createElement('style'); el.textContent = css; document.head.appendChild(el); } /* ==================== 启动 ==================== */ // v2.5:登录只验证脚本自身账号(不再校验一平台身份)。 // 换人时点面板上的「退出登录」清除缓存即可,或清除浏览器缓存。 function boot() { injectStyle(); const cache = localStorage.getItem('ouchn_auto_login'); if (cache) { try { const u = JSON.parse(cache); // 调试态不落盘,也不允许从缓存自动进入免扣模式 if (u && (u.token === 'local' || u.token === 'dev')) { localStorage.removeItem('ouchn_auto_login'); } else if (u && u.userId) { userInfo = u; buildPanel(); log(`已载入登录状态:${userInfo.username}(余额 ${userInfo.points} 积分)`); syncPointsFromServer(); return; } } catch (_) { } } initLoginPanel(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();