Greasy Fork

Greasy Fork is available in English.

正方教务系统导出成绩详情

绕过默认权限限制,一键下载包含平时成绩、期末成绩及最终成绩的完整成绩单。

当前为 2025-07-15 提交的版本,查看 最新版本

// ==UserScript==
// @name         正方教务系统导出成绩详情
// @namespace    https://www.klaio.top/
// @version      1.0.0
// @description  绕过默认权限限制,一键下载包含平时成绩、期末成绩及最终成绩的完整成绩单。
// @author       NianBroken
// @match        *://*.edu.cn/cjcx/*
// @run-at       document-idle
// @grant        GM_registerMenuCommand
// @icon         https://www.zfsoft.com/img/zf.ico
// @homepageURL  https://github.com/NianBroken/ZFAllGradeDetails
// @supportURL   https://github.com/NianBroken/ZFAllGradeDetails/issues
// @copyright    Copyright © 2025 NianBroken. All rights reserved.
// @license      Apache-2.0 license
// ==/UserScript==

(function () {
    'use strict';

    /**
     * 从页面中获取“学年”(xnm)和“学期”(xqm)下拉框的选中值。
     * 如果任一元素不存在,则抛出错误,后续逻辑会被外层 catch 捕获并提示。
     *
     * @throws {Error} 找不到对应下拉框时抛出
     * @returns {{ xnm: string, xqm: string }} 返回包含学年和学期的对象
     */
    function getTermParams() {
        const xnmEl = document.getElementById('xnm'); // 页面上学年下拉框元素
        const xqmEl = document.getElementById('xqm'); // 页面上学期下拉框元素
        if (!xnmEl || !xqmEl) {
            throw new Error('页面中未找到“学年”或“学期”下拉框');
        }
        return {
            xnm: xnmEl.value,
            xqm: xqmEl.value
        };
    }

    /**
     * 根据学年和学期参数,构造符合后端要求的
     * application/x-www-form-urlencoded 编码请求体字符串。
     * 包含功能码、模板编号以及所有需要导出的字段列信息。
     *
     * @param {{ xnm: string, xqm: string }} param0 包含学年和学期的对象
     * @returns {string} 编码后的请求体字符串,可直接作为 fetch 的 body
     */
    function buildFormBody({ xnm, xqm }) {
        const params = new URLSearchParams();           // 用于累积各项表单字段
        params.append('gnmkdmKey', 'N305005');          // 后端接口所需功能码
        params.append('xnm', xnm);                      // 当前选中的学年
        params.append('xqm', xqm);                      // 当前选中的学期
        params.append('dcclbh', 'JW_N305005_GLY');       // 导出模板标识

        // 定义所有要导出的列:课程名称、学年、学期等
        const cols = [
            'xnmmc@学年',
            'xqmmc@学期',
            'jxb_id@教学班ID',
            'xf@学分',
            'kcmc@课程名称',
            'xmcj@成绩',
            'xmblmc@成绩分项',
        ];
        cols.forEach(col => {
            params.append('exportModel.selectCol', col);
        });

        params.append('exportModel.exportWjgs', 'xls');  // 导出格式设为 xls
        params.append('fileName', '成绩单');             // 默认下载文件名

        return params.toString();                       // 返回最终编码结果
    }

    /**
     * 主流程:依次尝试两种不同的请求路径进行成绩导出,
     * 若任一路径返回成功,则直接下载;两次均失败后弹窗提示错误信息。
     */
    async function exportGrades() {
        try {
            // 获取页面上学年和学期下拉框的值
            const { xnm, xqm } = getTermParams();
            // 根据学年学期构造请求体
            const body = buildFormBody({ xnm, xqm });

            // 定义不带前缀和带 /jwglxt 前缀的两条接口路径
            const paths = [
                '/cjcx/cjcx_dcXsKccjList.html',
                '/jwglxt/cjcx/cjcx_dcXsKccjList.html'
            ];

            let lastError = null;                         // 用于记录最后一次请求的错误
            for (const path of paths) {
                try {
                    // 以 POST 方式发送表单编码请求
                    const response = await fetch(path, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                        body
                    });
                    if (!response.ok) {
                        // 非 2xx 响应也视为失败,触发 catch 以便重试
                        throw new Error(`HTTP 错误,状态码:${response.status}`);
                    }
                    const blob = await response.blob();     // 解析返回的二进制文件流
                    downloadBlob(blob);                     // 调用下载方法
                    return;                                 // 成功后退出,不再继续重试
                } catch (err) {
                    lastError = err;                       // 保存本次错误,继续尝试下一条路径
                }
            }

            // 两次路径均尝试失败,抛出最后一次捕获的错误
            throw lastError;
        } catch (err) {
            // 捕获所有异常并通过浏览器弹窗向用户通报
            alert(`导出失败:${err.message}`);
            console.error('导出成绩详情时发生错误:', err);
        }
    }

    /**
     * 将后端返回的 Blob 对象转换为临时下载链接,
     * 自动创建隐藏 <a> 元素并触发点击完成文件保存,
     * 最后释放 URL 对象避免内存泄漏。
     *
     * @param {Blob} blob 后端返回的二进制文件数据
     */
    function downloadBlob(blob) {
        const url = URL.createObjectURL(blob);            // 创建指向 blob 的临时 URL
        const a = document.createElement('a');           // 动态生成一个 <a> 元素
        a.href = url;                                     // 指定下载地址
        a.download = `成绩单_${Date.now()}.xlsx`;           // 设置下载文件名,保证唯一性
        document.body.appendChild(a);                     // 插入 DOM,触发 click 需要元素在文档中
        a.click();                                        // 模拟用户点击,实现下载
        document.body.removeChild(a);                     // 下载后清理 DOM
        URL.revokeObjectURL(url);                         // 释放临时 URL,防止内存泄漏
    }

    // 在 Tampermonkey 脚本菜单中注册“导出成绩详情”命令,
    // 用户可通过菜单项或 Alt+e 快捷键触发导出功能
    GM_registerMenuCommand('导出成绩详情', exportGrades, 'e');

})();