// ==UserScript==
// @name 第一师范学院成绩自动填报助手
// @namespace http://tampermonkey.net/
// @version 3.7
// @description 自动识别成绩类型,支持灵活的Excel列顺序和行顺序,模板包含已填报数据(兼容篡改猴/ScriptCat)
// @author SoyaBean
// @match http://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/course/teacher!inputGA.action*
// @match http://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/delaymakeup/retake-manage-teacher!inputReady.action*
// @match https://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/course/teacher!inputGA.action*
// @match https://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/delaymakeup/retake-manage-teacher!inputReady.action*
// @require https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect cdn.jsdelivr.net
// @connect unpkg.com
// @connect cdn.bootcdn.net
// ==/UserScript==
(function () {
'use strict';
/* ==================================================================
* XLSX 库多策略加载器(篡改猴 / ScriptCat 双平台兼容)
* ================================================================== */
// 全局保存解析出的 XLSX 库对象
let XLSXLib = null;
// CDN 备选列表(GM_xmlhttpRequest 下载用)
const XLSX_CDN_LIST = [
'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js',
'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js',
'https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'
];
// 策略①②:尝试从各种作用域直接获取 XLSX
function tryGetXLSXFromScope() {
// 策略①:@require 注入到脚本作用域(Tampermonkey 典型情况)
try {
// eslint-disable-next-line no-undef
if (typeof XLSX !== 'undefined' && XLSX && XLSX.utils) {
return XLSX;
}
} catch (e) { /* ignore */ }
// 策略②:unsafeWindow(部分环境挂载在页面真实 window 上)
try {
if (typeof unsafeWindow !== 'undefined' && unsafeWindow.XLSX && unsafeWindow.XLSX.utils) {
return unsafeWindow.XLSX;
}
} catch (e) { /* ignore */ }
// 沙箱 window
try {
if (window.XLSX && window.XLSX.utils) {
return window.XLSX;
}
} catch (e) { /* ignore */ }
return null;
}
// 策略③:GM_xmlhttpRequest 下载库源码,在脚本作用域内执行
// (ScriptCat 终极方案:完全不依赖页面 window,不受沙箱隔离影响)
function loadXLSXByGMXHR(cdnIndex) {
return new Promise((resolve, reject) => {
if (cdnIndex >= XLSX_CDN_LIST.length) {
reject(new Error('所有 CDN 均加载失败'));
return;
}
const url = XLSX_CDN_LIST[cdnIndex];
console.log(`[成绩助手] 正在通过 GM_xmlhttpRequest 加载 XLSX:${url}`);
if (typeof GM_xmlhttpRequest === 'undefined') {
reject(new Error('GM_xmlhttpRequest 不可用'));
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 20000,
onload: function (response) {
if (response.status === 200 && response.responseText) {
try {
// 用 CommonJS 方式执行 UMD 库,导出结果被捕获到 module.exports
// 这样 XLSX 直接进入脚本作用域,绕过所有沙箱隔离
const module = { exports: {} };
const runner = new Function(
'module', 'exports',
response.responseText +
'\n;if (typeof XLSX !== "undefined") { module.exports = XLSX; }'
);
runner(module, module.exports);
const lib = module.exports;
if (lib && lib.utils && lib.utils.book_new) {
console.log('[成绩助手] XLSX 通过 GM_xmlhttpRequest 加载成功');
resolve(lib);
} else {
reject(new Error('XLSX 执行后未获取到有效对象'));
}
} catch (err) {
console.warn(`[成绩助手] CDN ${cdnIndex} 执行失败,尝试下一个`, err);
loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject);
}
} else {
console.warn(`[成绩助手] CDN ${cdnIndex} 响应异常,尝试下一个`);
loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject);
}
},
onerror: function () {
console.warn(`[成绩助手] CDN ${cdnIndex} 网络错误,尝试下一个`);
loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject);
},
ontimeout: function () {
console.warn(`[成绩助手] CDN ${cdnIndex} 超时,尝试下一个`);
loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject);
}
});
});
}
// 统一加载入口:依次尝试所有策略
async function ensureXLSX() {
if (XLSXLib) return XLSXLib;
// 先尝试作用域直取(Tampermonkey 走这里,零延迟)
XLSXLib = tryGetXLSXFromScope();
if (XLSXLib) {
console.log('[成绩助手] XLSX 从脚本作用域获取成功');
return XLSXLib;
}
// ScriptCat 走这里:GM_xmlhttpRequest 下载 + 脚本内执行
XLSXLib = await loadXLSXByGMXHR(0);
return XLSXLib;
}
// 同步获取(供已确保加载后的函数使用)
function getXLSX() {
if (!XLSXLib) {
XLSXLib = tryGetXLSXFromScope();
}
return XLSXLib;
}
/* ==================================================================
* 初始化入口
* ================================================================== */
function init() {
createControlPanel();
// 后台预加载 XLSX(不阻塞面板显示)
ensureXLSX()
.then(() => {
console.log('[成绩助手] XLSX 库准备就绪');
})
.catch((err) => {
console.error('[成绩助手] XLSX 库预加载失败:', err);
showStatus('⚠️ XLSX 库预加载失败,点击功能按钮时将自动重试。\n如持续失败请检查网络。', 'warning');
});
}
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}
/* ==================================================================
* 控制面板
* ================================================================== */
function createControlPanel() {
// 防止重复创建
if (document.getElementById('grade-auto-fill-container')) return;
const container = document.createElement('div');
container.id = 'grade-auto-fill-container';
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
`;
const toggleBtn = document.createElement('button');
toggleBtn.id = 'grade-toggle-btn';
toggleBtn.innerHTML = '📊';
toggleBtn.style.cssText = `
position: absolute;
top: 0;
right: 0;
width: 40px;
height: 40px;
background: #1976d2;
color: white;
border: none;
border-radius: 50%;
cursor: move;
font-size: 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
display: none;
z-index: 10001;
`;
const panel = document.createElement('div');
panel.id = 'grade-auto-fill-panel';
panel.style.cssText = `
width: 320px;
background: #fff;
border: 2px solid #333;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
transition: all 0.3s ease;
`;
const header = document.createElement('div');
header.style.cssText = `
padding: 10px 15px;
background: #1976d2;
color: white;
border-radius: 6px 6px 0 0;
cursor: move;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
`;
header.innerHTML = `
📊 第一师范学院成绩自动填报助手
`;
const content = document.createElement('div');
content.style.cssText = 'padding: 15px;';
content.innerHTML = `
第一步:导出成绩数据
包含所有学生信息和已填报的成绩
📖 使用说明
- 点击"导出当前成绩Excel"下载包含已有成绩的表格
- 在Excel中修改成绩(只填数字)
- 空白成绩也会被填入为空
- 非数字内容将被跳过
- Excel中列顺序和行顺序可自由调整
- 点击"自动填入"会先清空原有数据
⚠️ 注意:自动填入前会清空所有原有成绩!
作者:舒宜彬 | v3.7
`;
panel.appendChild(header);
panel.appendChild(content);
container.appendChild(toggleBtn);
container.appendChild(panel);
document.body.appendChild(container);
bindEvents();
makeDraggable(container, header);
makeDraggable(container, toggleBtn);
}
/* ==================================================================
* 事件绑定
* ================================================================== */
function bindEvents() {
document.getElementById('download-template-btn').addEventListener('click', downloadTemplate);
document.getElementById('preview-btn').addEventListener('click', handlePreview);
document.getElementById('fill-btn').addEventListener('click', handleFill);
document.getElementById('clear-all-btn').addEventListener('click', handleClearAll);
document.getElementById('minimize-btn').addEventListener('click', function () {
document.getElementById('grade-auto-fill-panel').style.display = 'none';
document.getElementById('grade-toggle-btn').style.display = 'block';
});
document.getElementById('close-btn').addEventListener('click', function () {
if (confirm('确定要关闭成绩填报助手吗?')) {
document.getElementById('grade-auto-fill-container').remove();
}
});
document.getElementById('grade-toggle-btn').addEventListener('click', function (e) {
if (e.target.dataset.dragging === 'true') {
e.target.dataset.dragging = 'false';
return;
}
document.getElementById('grade-auto-fill-panel').style.display = 'block';
document.getElementById('grade-toggle-btn').style.display = 'none';
});
document.querySelectorAll('#grade-auto-fill-panel button').forEach(btn => {
if (btn.id !== 'minimize-btn' && btn.id !== 'close-btn') {
btn.addEventListener('mouseenter', function () { this.style.opacity = '0.9'; });
btn.addEventListener('mouseleave', function () { this.style.opacity = '1'; });
}
});
}
/* ==================================================================
* 拖动功能
* ================================================================== */
function makeDraggable(container, handle) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
let isDragging = false;
handle.onmousedown = function (e) {
e = e || window.event;
e.preventDefault();
pos3 = e.clientX;
pos4 = e.clientY;
isDragging = false;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
};
function elementDrag(e) {
e = e || window.event;
e.preventDefault();
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
if (Math.abs(pos1) > 5 || Math.abs(pos2) > 5) {
isDragging = true;
if (handle.id === 'grade-toggle-btn') {
handle.dataset.dragging = 'true';
}
}
container.style.top = (container.offsetTop - pos2) + 'px';
container.style.left = (container.offsetLeft - pos1) + 'px';
container.style.right = 'auto';
}
function closeDragElement() {
document.onmouseup = null;
document.onmousemove = null;
if (handle.id === 'grade-toggle-btn' && !isDragging) {
handle.dataset.dragging = 'false';
}
}
}
/* ==================================================================
* 成绩清空
* ================================================================== */
function clearAllGrades() {
let clearedCount = 0;
const scoreInputs = document.querySelectorAll('input[name$=".score"]');
scoreInputs.forEach(input => {
if (input.value !== '') {
input.value = '';
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('blur', { bubbles: true }));
clearedCount++;
}
});
return { total: scoreInputs.length, cleared: clearedCount };
}
function handleClearAll() {
if (confirm('确定要清空所有成绩吗?\n此操作不可恢复!')) {
const result = clearAllGrades();
showStatus(`清空完成!\n共清空 ${result.cleared} 个成绩\n总计 ${result.total} 个成绩输入框`, 'warning');
}
}
/* ==================================================================
* 成绩类型识别
* ================================================================== */
function identifyGradeTypes() {
const gradeTypes = [];
const firstHeaderRow = document.querySelector('table.gridtable thead tr:first-child');
if (firstHeaderRow) {
firstHeaderRow.querySelectorAll('th').forEach(header => {
const text = header.textContent.trim();
if (text.includes('成绩') && !text.includes('总评成绩')) {
gradeTypes.push(text);
}
});
}
return gradeTypes;
}
/* ==================================================================
* 导出成绩模板(异步确保 XLSX 已加载)
* ================================================================== */
async function downloadTemplate() {
let XLSX;
try {
showStatus('正在准备 XLSX 库...', 'info');
XLSX = await ensureXLSX();
} catch (err) {
showStatus('❌ XLSX 库加载失败:' + err.message + '\n请检查网络后重试。', 'error');
return;
}
const students = [];
const gradeTypes = identifyGradeTypes();
if (gradeTypes.length === 0) {
showStatus('未能识别到成绩类型!', 'error');
return;
}
showStatus(`正在导出数据...\n成绩类型:${gradeTypes.join('、')}`, 'info');
const studentRows = document.querySelectorAll('#mylist tr');
let exportedGradeCount = 0;
studentRows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length > 2) {
const studentObj = {
'学号': cells[0].textContent.trim(),
'姓名': cells[1].textContent.trim(),
'行政班': cells[2].textContent.trim()
};
const scoreInputs = row.querySelectorAll('input[name$=".score"]');
gradeTypes.forEach((gradeType, index) => {
if (index < scoreInputs.length) {
const score = scoreInputs[index].value;
studentObj[gradeType] = score;
if (score !== '') exportedGradeCount++;
} else {
studentObj[gradeType] = '';
}
});
students.push(studentObj);
}
});
if (students.length === 0) {
showStatus('未能从页面提取到学生信息!', 'error');
return;
}
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(students);
const cols = [{ wch: 15 }, { wch: 12 }, { wch: 15 }];
gradeTypes.forEach(() => cols.push({ wch: 12 }));
ws['!cols'] = cols;
const range = XLSX.utils.decode_range(ws['!ref']);
for (let R = 1; R <= range.e.r; ++R) {
for (let C = 3; C < 3 + gradeTypes.length; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C });
if (ws[cellAddress] && ws[cellAddress].v) {
ws[cellAddress].s = { fill: { fgColor: { rgb: 'E8F5E9' } } };
}
}
}
XLSX.utils.book_append_sheet(wb, ws, '成绩数据');
const courseInfo = document.querySelector('.grade-input-lesson-info');
let courseName = '成绩';
if (courseInfo) {
const match = courseInfo.textContent.match(/课程名称:([^课程类别]+)/);
if (match) courseName = match[1].trim();
}
XLSX.writeFile(wb, `${courseName}_成绩数据_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`);
showStatus(`导出成功!\n共${students.length}名学生\n已填报成绩:${exportedGradeCount}个\n成绩类型:${gradeTypes.join('、')}\n\n已填报的成绩在Excel中用浅绿色标记`, 'success');
}
/* ==================================================================
* Excel 读取与预览
* ================================================================== */
let excelData = null;
let skippedRecords = [];
async function handlePreview() {
const file = document.getElementById('excel-file-input').files[0];
if (!file) { showStatus('请先选择Excel文件!', 'error'); return; }
try {
showStatus('正在读取Excel文件...', 'info');
excelData = await parseExcelFile(file);
const excelGradeTypes = excelData.length > 0
? Object.keys(excelData[0]).filter(k => k.includes('成绩') && !k.includes('总评'))
: [];
let validCount = 0, emptyCount = 0, invalidCount = 0, totalGrades = 0;
excelData.forEach(row => {
let hasValid = false, hasEmpty = false;
excelGradeTypes.forEach(gt => {
const score = String(row[gt] === undefined || row[gt] === null ? '' : row[gt]).trim();
if (score === '') { hasEmpty = true; emptyCount++; }
else if (!isNaN(score)) { hasValid = true; totalGrades++; }
else { invalidCount++; }
});
if (hasValid || hasEmpty) validCount++;
});
let preview = `成功读取 ${excelData.length} 条数据\n`;
preview += `识别到的成绩类型:${excelGradeTypes.join('、')}\n`;
preview += `有效数据行:${validCount} 条\n`;
preview += `数字成绩:${totalGrades} 个\n`;
preview += `空白成绩:${emptyCount} 个\n`;
if (invalidCount > 0) preview += `非数字成绩:${invalidCount} 处(将被跳过)\n`;
preview += '\n数据预览(前3条):\n';
excelData.slice(0, 3).forEach((row, i) => {
preview += `${i + 1}. ${row['学号']} ${row['姓名']}\n`;
excelGradeTypes.forEach(gt => {
const s = row[gt];
preview += ` ${gt}: ${s === '' || s === undefined || s === null ? '(空)' : s}\n`;
});
});
showStatus(preview, 'info');
} catch (error) {
showStatus('读取文件失败:' + error.message, 'error');
}
}
/* ==================================================================
* 自动填入
* ================================================================== */
async function handleFill() {
const file = document.getElementById('excel-file-input').files[0];
if (!file) { showStatus('请先选择Excel文件!', 'error'); return; }
excelData = null;
try {
showStatus('正在读取Excel文件...', 'info');
excelData = await parseExcelFile(file);
} catch (error) {
showStatus('读取文件失败:' + error.message, 'error');
return;
}
if (confirm('确定要自动填入成绩吗?\n注意:填入前会先清空所有原有成绩!')) {
showStatus('正在清空原有成绩...', 'info');
const clearResult = clearAllGrades();
setTimeout(() => {
showStatus('正在填入新成绩...', 'info');
const result = fillGrades(excelData);
showDetailedReport(result, clearResult);
}, 500);
}
}
/* ==================================================================
* Excel 解析(异步确保 XLSX 已加载)
* ================================================================== */
function parseExcelFile(file) {
return new Promise((resolve, reject) => {
// 先确保 XLSX 库可用
ensureXLSX().then((XLSX) => {
const reader = new FileReader();
reader.onload = function (e) {
try {
const workbook = XLSX.read(e.target.result, { type: 'binary' });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
resolve(XLSX.utils.sheet_to_json(worksheet));
} catch (err) {
reject(err);
}
};
reader.onerror = function () {
reject(new Error('文件读取失败'));
};
reader.readAsBinaryString(file);
}).catch((err) => {
reject(new Error('XLSX 库加载失败:' + err.message));
});
});
}
/* ==================================================================
* 成绩填入核心逻辑
* ================================================================== */
function fillGrades(data) {
let filledCount = 0, emptyFills = 0;
let notFoundStudents = [], successfulFills = [];
skippedRecords = [];
const pageGradeTypes = identifyGradeTypes();
const pageStudentMap = new Map();
document.querySelectorAll('#mylist tr').forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length > 2) {
pageStudentMap.set(
`${cells[0].textContent.trim()}_${cells[1].textContent.trim()}`,
row
);
}
});
data.forEach(student => {
const studentId = String(student['学号'] || '').trim();
const studentName = String(student['姓名'] || '').trim();
if (!studentId || !studentName) return;
const studentRow = pageStudentMap.get(`${studentId}_${studentName}`);
if (studentRow) {
let hasValidOperation = false;
const filledScores = [];
const scoreInputs = studentRow.querySelectorAll('input[name$=".score"]');
pageGradeTypes.forEach((gradeType, index) => {
let score = null;
Object.keys(student).forEach(key => {
if (key === gradeType ||
key.includes(gradeType.replace('成绩', '')) ||
gradeType.includes(key.replace('成绩', ''))) {
score = student[key];
}
});
if (score !== null && score !== undefined && index < scoreInputs.length) {
const scoreStr = String(score).trim();
if (scoreStr === '') {
scoreInputs[index].value = '';
scoreInputs[index].dispatchEvent(new Event('change', { bubbles: true }));
scoreInputs[index].dispatchEvent(new Event('blur', { bubbles: true }));
hasValidOperation = true;
emptyFills++;
filledScores.push(`${gradeType}:(空)`);
} else if (!isNaN(scoreStr)) {
scoreInputs[index].value = scoreStr;
scoreInputs[index].dispatchEvent(new Event('change', { bubbles: true }));
scoreInputs[index].dispatchEvent(new Event('blur', { bubbles: true }));
hasValidOperation = true;
filledScores.push(`${gradeType}:${scoreStr}`);
} else {
skippedRecords.push({
学号: studentId,
姓名: studentName,
原因: `${gradeType}"${scoreStr}"不是有效数字`
});
}
}
});
if (hasValidOperation) {
filledCount++;
successfulFills.push({
学号: studentId,
姓名: studentName,
成绩: filledScores.join(', ')
});
}
} else {
const hasAnyGrade = Object.keys(student).some(
key => key.includes('成绩') && student[key] !== undefined && student[key] !== null
);
if (hasAnyGrade) notFoundStudents.push({ 学号: studentId, 姓名: studentName });
}
});
return { total: data.length, filledCount, emptyFills, skippedRecords, notFoundStudents, successfulFills };
}
/* ==================================================================
* 状态显示
* ================================================================== */
function showStatus(message, type = 'info') {
const statusArea = document.getElementById('status-area');
const statusText = document.getElementById('status-text');
if (!statusArea || !statusText) {
console.log('[成绩助手] ' + message);
return;
}
statusArea.style.display = 'block';
statusText.textContent = message;
statusText.style.whiteSpace = 'pre-wrap';
const styles = {
success: { bg: '#d4edda', color: '#155724' },
error: { bg: '#f8d7da', color: '#721c24' },
warning: { bg: '#fff3cd', color: '#856404' },
info: { bg: '#f5f5f5', color: '#333' }
};
const s = styles[type] || styles.info;
statusArea.style.background = s.bg;
statusArea.style.color = s.color;
}
/* ==================================================================
* 详细报告
* ================================================================== */
function showDetailedReport(result, clearResult) {
let message = `=== 填报完成 ===\n\n`;
if (clearResult) message += `🗑️ 已清空 ${clearResult.cleared} 个原有成绩\n\n`;
message += `✅ 成功填报:${result.filledCount} 条`;
if (result.emptyFills > 0) message += `(含${result.emptyFills}处空值)`;
message += '\n';
if (result.successfulFills.length > 0 && result.successfulFills.length <= 5) {
message += '\n成功填报的学生:\n';
result.successfulFills.forEach(s => {
message += ` ${s.学号} ${s.姓名}\n ${s.成绩}\n`;
});
} else if (result.successfulFills.length > 5) {
message += '\n成功填报的学生(显示前5条):\n';
result.successfulFills.slice(0, 5).forEach(s => {
message += ` ${s.学号} ${s.姓名}\n ${s.成绩}\n`;
});
message += ` ...还有${result.successfulFills.length - 5}条\n`;
}
if (result.notFoundStudents.length > 0) {
message += `\n❌ 未找到的学生(${result.notFoundStudents.length}人):\n`;
result.notFoundStudents.slice(0, 5).forEach(s => {
message += ` ${s.学号} ${s.姓名}\n`;
});
if (result.notFoundStudents.length > 5) {
message += ` ...还有${result.notFoundStudents.length - 5}人\n`;
}
}
if (result.skippedRecords.length > 0) {
message += `\n⚠️ 跳过的非数字记录(${result.skippedRecords.length}条):\n`;
result.skippedRecords.slice(0, 5).forEach(s => {
message += ` ${s.学号} ${s.姓名}:${s.原因}\n`;
});
if (result.skippedRecords.length > 5) {
message += ` ...还有${result.skippedRecords.length - 5}条\n`;
}
}
showStatus(message, result.filledCount > 0 ? 'success' : 'warning');
if (skippedRecords.length > 0) addDownloadButton();
}
/* ==================================================================
* 跳过记录下载
* ================================================================== */
function addDownloadButton() {
if (document.getElementById('download-skipped-btn')) return;
const btnContainer = document.createElement('div');
btnContainer.style.marginTop = '10px';
const downloadBtn = document.createElement('button');
downloadBtn.id = 'download-skipped-btn';
downloadBtn.textContent = '📊 下载跳过记录';
downloadBtn.style.cssText = 'width: 100%; padding: 8px; background: #ff9800; color: white; border: none; border-radius: 4px; cursor: pointer;';
downloadBtn.addEventListener('click', downloadSkippedRecords);
btnContainer.appendChild(downloadBtn);
document.getElementById('status-area').appendChild(btnContainer);
}
async function downloadSkippedRecords() {
if (skippedRecords.length === 0) {
showStatus('没有跳过的记录!', 'info');
return;
}
let XLSX;
try {
XLSX = await ensureXLSX();
} catch (err) {
showStatus('❌ XLSX 库加载失败:' + err.message, 'error');
return;
}
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(skippedRecords);
ws['!cols'] = [{ wch: 15 }, { wch: 12 }, { wch: 40 }];
XLSX.utils.book_append_sheet(wb, ws, '跳过记录');
XLSX.writeFile(wb, `跳过记录_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`);
showStatus(`已下载 ${skippedRecords.length} 条跳过记录`, 'success');
}
})();