// ==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 的作答记录,
* 再剔除 "
👤 - | 🪙 余额 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();
}
})();