fix: rename assistant and gate exports behind collapsible switch
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
## 安装和更新
|
||||
|
||||
1. 在 Chrome / Edge 中安装并启用油猴(Tampermonkey)。
|
||||
2. 新建脚本,将 `谊安学题库导出.user.js` 的全部内容粘贴进去,保存。升级时替换原脚本全文,不要同时启用新旧两份脚本。
|
||||
2. 新建脚本,将 `谊安学助手 2.0.user.js` 的全部内容粘贴进去,保存。脚本显示名称为“谊安学助手 2.0”。升级时替换原脚本全文,不要同时启用新旧两份脚本。
|
||||
3. 刷新网页,点击右下角“题库助手”入口展开面板。
|
||||
4. 选择“主操 / 班长 / 副操”,点击“导入题库”,选择 ZIP、CSV 或 JSON 文件。
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
|
||||
## 网页题目导出
|
||||
|
||||
进入兼容练习页后,默认等待题目和参考答案加载完整,再自动下载 CSV;可在面板关闭自动导出,或点击 CSV / JSON / TXT 按钮手动导出。导出直接读取网页中的参考答案,包括隐藏答案,不用导入题库补全缺失答案。
|
||||
“导出题库”是独立的折叠开关,每次刷新默认关闭,升级前的自动导出设置也不会使它自动开启。关闭时导出区收起,自动下载和手动导出均禁用。
|
||||
|
||||
开启“导出题库”后展开 CSV / JSON / TXT 按钮。在兼容练习页中,题目和参考答案加载完整后会自动下载一次 CSV,也可点击格式按钮手动导出。再次关闭后停止后续导出;主动重新开启可再次下载当前题库。导出直接读取网页中的参考答案,包括隐藏答案,不用导入题库补全缺失答案。
|
||||
|
||||
- CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。
|
||||
- 缺题或缺答案时不自动下载;手动导出会标记不完整。没有答题卡总数时,只能核对当前页面内容。
|
||||
|
||||
+29
-15
@@ -21,7 +21,7 @@
|
||||
const state = {
|
||||
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
|
||||
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面',
|
||||
canAnswer: false, canExport: false, metrics: {}, autoExport: true, expanded: false, loading: false
|
||||
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false
|
||||
};
|
||||
let destroyed = false;
|
||||
let selectionText = '';
|
||||
@@ -43,7 +43,7 @@
|
||||
visibilityStyle.textContent = '[hidden]{display:none!important}';
|
||||
root.appendChild(visibilityStyle);
|
||||
const panel = el(doc, 'section', 'panel collapsed');
|
||||
panel.setAttribute('aria-label', '谊安学题库助手');
|
||||
panel.setAttribute('aria-label', '谊安学助手 2.0');
|
||||
const header = el(doc, 'div', 'header');
|
||||
const brand = el(doc, 'span', 'brand', '题库助手');
|
||||
const expand = el(doc, 'button', 'toggle', '展开');
|
||||
@@ -76,7 +76,7 @@
|
||||
brand.addEventListener('click', togglePanel);
|
||||
|
||||
const switches = el(doc, 'div', 'switches');
|
||||
function makeSwitch(labelText, callback) {
|
||||
function makeSwitch(labelText, callback, container) {
|
||||
const label = el(doc, 'label', 'switch-row');
|
||||
const text = el(doc, 'span', '', labelText);
|
||||
const input = el(doc, 'input', 'switch');
|
||||
@@ -84,7 +84,7 @@
|
||||
input.setAttribute('role', 'switch');
|
||||
input.setAttribute('aria-label', labelText);
|
||||
label.append(text, input);
|
||||
switches.appendChild(label);
|
||||
(container || switches).appendChild(label);
|
||||
input.addEventListener('change', callback);
|
||||
return { input: input, label: label };
|
||||
}
|
||||
@@ -227,22 +227,35 @@
|
||||
const unprocessedList = el(doc, 'div', 'unprocessed-list');
|
||||
unprocessed.append(unprocessedSummary, unprocessedList);
|
||||
unprocessed.hidden = true;
|
||||
pageSection.append(pageStatus, metrics, unprocessed);
|
||||
body.appendChild(pageSection);
|
||||
|
||||
const exportSection = el(doc, 'section', 'page-section export-section');
|
||||
exportSection.setAttribute('aria-label', '题库导出设置');
|
||||
const exportSwitch = makeSwitch('导出题库', function () {
|
||||
state.exportEnabled = exportSwitch.input.checked;
|
||||
refresh();
|
||||
invoke('onExportEnabledChange', state.exportEnabled);
|
||||
}, exportSection);
|
||||
const exportContent = el(doc, 'div', 'export-content');
|
||||
exportContent.id = 'yian-export-content';
|
||||
exportContent.hidden = true;
|
||||
exportSwitch.input.setAttribute('aria-controls', exportContent.id);
|
||||
exportContent.appendChild(el(doc, 'p', 'hint', '开启后,题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
|
||||
const exportRow = el(doc, 'div', 'exports');
|
||||
const exportButtons = [];
|
||||
['csv', 'json', 'txt'].forEach(function (format) {
|
||||
const button = el(doc, 'button', '', '导出 ' + format.toUpperCase());
|
||||
button.type = 'button';
|
||||
button.addEventListener('click', function () { invoke('onExport', format); });
|
||||
button.addEventListener('click', function () {
|
||||
if (state.exportEnabled && state.canExport) invoke('onExport', format);
|
||||
});
|
||||
exportRow.appendChild(button);
|
||||
exportButtons.push(button);
|
||||
});
|
||||
const autoExportLabel = el(doc, 'label', 'auto-export');
|
||||
const autoExportInput = el(doc, 'input');
|
||||
autoExportInput.type = 'checkbox';
|
||||
autoExportLabel.append(autoExportInput, el(doc, 'span', '', '进入练习时自动导出 CSV'));
|
||||
autoExportInput.addEventListener('change', function () { state.autoExport = autoExportInput.checked; invoke('onAutoExportChange', state.autoExport); });
|
||||
pageSection.append(pageStatus, metrics, unprocessed, exportRow, autoExportLabel);
|
||||
body.appendChild(pageSection);
|
||||
exportContent.appendChild(exportRow);
|
||||
exportSection.appendChild(exportContent);
|
||||
body.appendChild(exportSection);
|
||||
|
||||
function autoUnavailableReason() {
|
||||
if (!state.role) return '请选择岗位后再开启自动选择。';
|
||||
@@ -260,7 +273,7 @@
|
||||
if (destroyed) return;
|
||||
panel.classList.toggle('collapsed', !state.expanded);
|
||||
body.hidden = !state.expanded;
|
||||
brand.textContent = state.expanded ? '谊安学助手' : '题库助手';
|
||||
brand.textContent = state.expanded ? '谊安学助手 2.0' : '题库助手';
|
||||
expand.textContent = state.expanded ? '收起' : '展开';
|
||||
expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手');
|
||||
expand.setAttribute('aria-expanded', String(Boolean(state.expanded)));
|
||||
@@ -310,8 +323,9 @@
|
||||
notProcessed.forEach(function (item) {
|
||||
unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。')));
|
||||
});
|
||||
exportButtons.forEach(function (button) { button.disabled = !state.canExport; });
|
||||
autoExportInput.checked = Boolean(state.autoExport);
|
||||
exportSwitch.input.checked = Boolean(state.exportEnabled);
|
||||
exportContent.hidden = !state.exportEnabled;
|
||||
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
|
||||
previous.disabled = currentOffset === 0 || Boolean(state.loading);
|
||||
next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const metadata = `// ==UserScript==
|
||||
// @name 谊安学练习题库自动导出
|
||||
// @name 谊安学助手 2.0
|
||||
// @namespace yianxue-question-bank
|
||||
// @version 2.0.0
|
||||
// @version 2.0.1
|
||||
// @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。
|
||||
// @match *://*/*
|
||||
// @match file:///*
|
||||
@@ -25,5 +25,5 @@ const zipWrapper = '(function () { const module = { exports: {} }; const exports
|
||||
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js',
|
||||
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'practice-exporter.js'];
|
||||
const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n');
|
||||
writeFileSync(new URL('./谊安学题库导出.user.js', import.meta.url), metadata + '\n' + notices + zipWrapper + source, 'utf8');
|
||||
console.log('已生成 谊安学题库导出.user.js');
|
||||
writeFileSync(new URL('./谊安学助手 2.0.user.js', import.meta.url), metadata + '\n' + notices + zipWrapper + source, 'utf8');
|
||||
console.log('已生成 谊安学助手 2.0.user.js');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yianxue-question-bank-exporter",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"description": "不绑定网址的谊安学题库助手:岗位搜索、允许复制和练习自动选择",
|
||||
"scripts": {
|
||||
|
||||
+16
-8
@@ -4,7 +4,7 @@
|
||||
const core = window.QuestionBankCore;
|
||||
const store = window.YianBankStore;
|
||||
const matcher = window.YianAnswerMatcher;
|
||||
const config = { autoAnswer: false, allowCopy: true, autoExport: true, role: '', course: '' };
|
||||
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' };
|
||||
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
|
||||
let loading = true, disposed = false, page = null, previous = null;
|
||||
let searchQuery = '', runKey = '', debounceTimer;
|
||||
@@ -61,7 +61,7 @@
|
||||
}
|
||||
function refreshPanel(extra) {
|
||||
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
||||
autoExport: config.autoExport, role: config.role, course: config.course, courses: courses(),
|
||||
exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(),
|
||||
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
|
||||
}
|
||||
function performSearch(query = searchQuery, options = {}) {
|
||||
@@ -106,6 +106,7 @@
|
||||
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
|
||||
}
|
||||
function exportCurrent(format = 'csv') {
|
||||
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
|
||||
const route = routeInfo();
|
||||
if (!route.practice) throw new Error('请先进入支持的练习页面');
|
||||
const bank = core.extract(document, route.sourceUrl);
|
||||
@@ -115,6 +116,13 @@
|
||||
+ ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning');
|
||||
return bank;
|
||||
}
|
||||
function setExportEnabled(enabled) {
|
||||
const opening = !!enabled && !config.exportEnabled;
|
||||
config.exportEnabled = !!enabled;
|
||||
// 用户主动重新开启可再次下载;同一开启期间只自动导出一次相同题库。
|
||||
if (opening) exported.clear();
|
||||
refreshPanel(); tick(); return config.exportEnabled;
|
||||
}
|
||||
const panel = window.YianAssistantPanel.create({
|
||||
onAutoChange: setAutoAnswer, onCopyChange: setCopy,
|
||||
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
|
||||
@@ -122,7 +130,7 @@
|
||||
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
|
||||
onSelectionSearch: text => performSearch(text),
|
||||
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
|
||||
onAutoExportChange: enabled => { config.autoExport = !!enabled; saveSetting('autoExport', config.autoExport); tick(); }
|
||||
onExportEnabledChange: setExportEnabled
|
||||
});
|
||||
|
||||
function tick() {
|
||||
@@ -160,11 +168,11 @@
|
||||
refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total,
|
||||
canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
|
||||
if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; }
|
||||
if (config.autoExport && bank.complete) {
|
||||
if (config.exportEnabled && bank.complete) {
|
||||
const key = route.key + '|' + signature;
|
||||
if (!exported.has(key)) {
|
||||
try { exportCurrent('csv'); exported.add(key); }
|
||||
catch (error) { config.autoExport = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
|
||||
catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
|
||||
}
|
||||
}
|
||||
const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision;
|
||||
@@ -188,13 +196,13 @@
|
||||
}
|
||||
const ready = (async () => {
|
||||
try {
|
||||
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('autoExport', true)]);
|
||||
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true)]);
|
||||
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
|
||||
config.allowCopy = values[2] !== false; config.autoExport = values[3] !== false;
|
||||
config.allowCopy = values[2] !== false;
|
||||
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
|
||||
loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot;
|
||||
})();
|
||||
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, search: performSearch, getContext,
|
||||
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
|
||||
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
|
||||
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
|
||||
window.YianAssistant = api; window.YianQuestionBankExporter = api;
|
||||
|
||||
+6
-2
@@ -6,9 +6,10 @@ const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { chromium } = require('playwright');
|
||||
const workspace = path.resolve(__dirname, '..');
|
||||
const script = fs.readFileSync(path.join(workspace, '谊安学题库导出.user.js'), 'utf8');
|
||||
const script = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
|
||||
assert(script.includes('// @match *://*/*'));
|
||||
assert(!/\/\/ @match\s+https?:\/\/10\./.test(script));
|
||||
assert(script.includes('// @name 谊安学助手 2.0'));
|
||||
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-assistant-qa');
|
||||
fs.mkdirSync(qaDir, { recursive: true });
|
||||
|
||||
@@ -48,7 +49,7 @@ function documentHTML(practice) {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', false]]);
|
||||
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', true], ['yianxue.assistant.setting.v2.exportEnabled', true]]);
|
||||
let clipboard = '';
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
|
||||
@@ -76,6 +77,8 @@ function documentHTML(practice) {
|
||||
assert.equal(page.url(), startURL); assert.equal(await page.title(), '普通网页验证');
|
||||
assert(await page.getByRole('heading', { name: '任意网址的题库搜索' }).isVisible());
|
||||
await page.getByRole('button', { name: '展开题库助手' }).click();
|
||||
assert(await page.locator('#yian-question-assistant .brand').textContent() === '谊安学助手 2.0');
|
||||
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
|
||||
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
|
||||
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
|
||||
console.log('PASS 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容');
|
||||
@@ -134,6 +137,7 @@ function documentHTML(practice) {
|
||||
const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`;
|
||||
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
|
||||
await second.waitForFunction(() => YianAssistant.getState().pageReady);
|
||||
assert.equal(await second.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false, '旧版导出偏好不能开启新版开关');
|
||||
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
|
||||
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操');
|
||||
assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
|
||||
|
||||
+38
-3
@@ -8,7 +8,7 @@ const { chromium } = require('playwright');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8');
|
||||
const scriptSource = fs.readFileSync(path.join(root, '谊安学题库导出.user.js'), 'utf8');
|
||||
const scriptSource = fs.readFileSync(path.join(root, '谊安学助手 2.0.user.js'), 'utf8');
|
||||
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa');
|
||||
fs.mkdirSync(qaDir, { recursive: true });
|
||||
const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html');
|
||||
@@ -68,9 +68,21 @@ function parseCSV(text) {
|
||||
const practiceURL = title => origin + '/clientcn/exam/' + encodeURIComponent(title) + '/questions?title=' + encodeURIComponent(title) + '&flag=exercise';
|
||||
await page.goto(practiceURL('模拟练习'));
|
||||
await page.evaluate(() => { window.answerClicks = 0; });
|
||||
const firstDownload = page.waitForEvent('download');
|
||||
await page.addScriptTag({ content: scriptSource });
|
||||
await page.evaluate(() => YianAssistant.ready);
|
||||
await page.waitForFunction(() => YianAssistant.getState().pageReady);
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(downloads.length, 0, '导出开关默认关闭,不应自动下载');
|
||||
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
|
||||
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
|
||||
const blockedMessage = await page.evaluate(() => {
|
||||
try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; }
|
||||
});
|
||||
assert(blockedMessage.includes('开关'), '开关关闭时导出入口必须拒绝手动下载');
|
||||
const firstDownload = page.waitForEvent('download');
|
||||
await page.getByRole('switch', { name: '导出题库', exact: true }).check();
|
||||
const downloaded = await firstDownload;
|
||||
assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isVisible());
|
||||
await downloaded.saveAs(path.join(qaDir, 'synthetic.csv'));
|
||||
const bank = await page.evaluate(() => QuestionBankCore.extract(document));
|
||||
assert.equal(bank.total, 3); assert.equal(bank.answered, 3); assert.equal(bank.complete, true);
|
||||
@@ -140,6 +152,26 @@ function parseCSV(text) {
|
||||
assert.equal(await page.evaluate(() => window.answerClicks), 0);
|
||||
console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
|
||||
|
||||
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
|
||||
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
|
||||
const beforeClosedRoute = downloads.length;
|
||||
await page.evaluate(({ url, html }) => {
|
||||
history.pushState({}, '', url);
|
||||
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
|
||||
}, { url: practiceURL('开关关闭练习'), html: fixture('开关关闭练习') });
|
||||
await page.waitForFunction(() => YianAssistant.getState().pageReady);
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(downloads.length, beforeClosedRoute, '开关关闭时新练习也不自动导出');
|
||||
const reopening = page.waitForEvent('download');
|
||||
await page.getByRole('switch', { name: '导出题库', exact: true }).check();
|
||||
assert.equal((await reopening).suggestedFilename(), '开关关闭练习_题库_3题.csv');
|
||||
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
|
||||
const closedManual = await page.evaluate(() => {
|
||||
try { YianAssistant.exportCurrent('json'); return ''; } catch (error) { return error.message; }
|
||||
});
|
||||
assert(closedManual.includes('开关'));
|
||||
console.log('PASS 导出默认关闭与折叠、关闭后换页不下载、手动入口拒绝、开启才导出');
|
||||
|
||||
const sampleHTML = fs.existsSync(samplePath) ? fs.readFileSync(samplePath, 'utf8') : fixture();
|
||||
const tool = await context.newPage();
|
||||
tool.on('pageerror', error => errors.push(error.message));
|
||||
@@ -183,7 +215,10 @@ function parseCSV(text) {
|
||||
const localHTML = '<!doctype html><!-- saved from url=(0100)http://10.119.67.7/clientcn/exam/local/questions?flag=exercise -->' + fixture('本地练习');
|
||||
const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML);
|
||||
await local.goto(require('node:url').pathToFileURL(localPath).href);
|
||||
const localDownload = local.waitForEvent('download'); await local.addScriptTag({ content: scriptSource });
|
||||
await local.addScriptTag({ content: scriptSource });
|
||||
await local.evaluate(() => YianAssistant.ready);
|
||||
const localDownload = local.waitForEvent('download');
|
||||
await local.getByRole('switch', { name: '导出题库', exact: true }).check();
|
||||
assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv');
|
||||
assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n'));
|
||||
console.log('PASS 本地保存网页自动识别与浏览器错误检查');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name 谊安学练习题库自动导出
|
||||
// @name 谊安学助手 2.0
|
||||
// @namespace yianxue-question-bank
|
||||
// @version 2.0.0
|
||||
// @version 2.0.1
|
||||
// @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。
|
||||
// @match *://*/*
|
||||
// @match file:///*
|
||||
@@ -1070,7 +1070,7 @@ window.JSZip = module.exports;
|
||||
const state = {
|
||||
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
|
||||
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面',
|
||||
canAnswer: false, canExport: false, metrics: {}, autoExport: true, expanded: false, loading: false
|
||||
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false
|
||||
};
|
||||
let destroyed = false;
|
||||
let selectionText = '';
|
||||
@@ -1092,7 +1092,7 @@ window.JSZip = module.exports;
|
||||
visibilityStyle.textContent = '[hidden]{display:none!important}';
|
||||
root.appendChild(visibilityStyle);
|
||||
const panel = el(doc, 'section', 'panel collapsed');
|
||||
panel.setAttribute('aria-label', '谊安学题库助手');
|
||||
panel.setAttribute('aria-label', '谊安学助手 2.0');
|
||||
const header = el(doc, 'div', 'header');
|
||||
const brand = el(doc, 'span', 'brand', '题库助手');
|
||||
const expand = el(doc, 'button', 'toggle', '展开');
|
||||
@@ -1125,7 +1125,7 @@ window.JSZip = module.exports;
|
||||
brand.addEventListener('click', togglePanel);
|
||||
|
||||
const switches = el(doc, 'div', 'switches');
|
||||
function makeSwitch(labelText, callback) {
|
||||
function makeSwitch(labelText, callback, container) {
|
||||
const label = el(doc, 'label', 'switch-row');
|
||||
const text = el(doc, 'span', '', labelText);
|
||||
const input = el(doc, 'input', 'switch');
|
||||
@@ -1133,7 +1133,7 @@ window.JSZip = module.exports;
|
||||
input.setAttribute('role', 'switch');
|
||||
input.setAttribute('aria-label', labelText);
|
||||
label.append(text, input);
|
||||
switches.appendChild(label);
|
||||
(container || switches).appendChild(label);
|
||||
input.addEventListener('change', callback);
|
||||
return { input: input, label: label };
|
||||
}
|
||||
@@ -1276,22 +1276,35 @@ window.JSZip = module.exports;
|
||||
const unprocessedList = el(doc, 'div', 'unprocessed-list');
|
||||
unprocessed.append(unprocessedSummary, unprocessedList);
|
||||
unprocessed.hidden = true;
|
||||
pageSection.append(pageStatus, metrics, unprocessed);
|
||||
body.appendChild(pageSection);
|
||||
|
||||
const exportSection = el(doc, 'section', 'page-section export-section');
|
||||
exportSection.setAttribute('aria-label', '题库导出设置');
|
||||
const exportSwitch = makeSwitch('导出题库', function () {
|
||||
state.exportEnabled = exportSwitch.input.checked;
|
||||
refresh();
|
||||
invoke('onExportEnabledChange', state.exportEnabled);
|
||||
}, exportSection);
|
||||
const exportContent = el(doc, 'div', 'export-content');
|
||||
exportContent.id = 'yian-export-content';
|
||||
exportContent.hidden = true;
|
||||
exportSwitch.input.setAttribute('aria-controls', exportContent.id);
|
||||
exportContent.appendChild(el(doc, 'p', 'hint', '开启后,题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
|
||||
const exportRow = el(doc, 'div', 'exports');
|
||||
const exportButtons = [];
|
||||
['csv', 'json', 'txt'].forEach(function (format) {
|
||||
const button = el(doc, 'button', '', '导出 ' + format.toUpperCase());
|
||||
button.type = 'button';
|
||||
button.addEventListener('click', function () { invoke('onExport', format); });
|
||||
button.addEventListener('click', function () {
|
||||
if (state.exportEnabled && state.canExport) invoke('onExport', format);
|
||||
});
|
||||
exportRow.appendChild(button);
|
||||
exportButtons.push(button);
|
||||
});
|
||||
const autoExportLabel = el(doc, 'label', 'auto-export');
|
||||
const autoExportInput = el(doc, 'input');
|
||||
autoExportInput.type = 'checkbox';
|
||||
autoExportLabel.append(autoExportInput, el(doc, 'span', '', '进入练习时自动导出 CSV'));
|
||||
autoExportInput.addEventListener('change', function () { state.autoExport = autoExportInput.checked; invoke('onAutoExportChange', state.autoExport); });
|
||||
pageSection.append(pageStatus, metrics, unprocessed, exportRow, autoExportLabel);
|
||||
body.appendChild(pageSection);
|
||||
exportContent.appendChild(exportRow);
|
||||
exportSection.appendChild(exportContent);
|
||||
body.appendChild(exportSection);
|
||||
|
||||
function autoUnavailableReason() {
|
||||
if (!state.role) return '请选择岗位后再开启自动选择。';
|
||||
@@ -1309,7 +1322,7 @@ window.JSZip = module.exports;
|
||||
if (destroyed) return;
|
||||
panel.classList.toggle('collapsed', !state.expanded);
|
||||
body.hidden = !state.expanded;
|
||||
brand.textContent = state.expanded ? '谊安学助手' : '题库助手';
|
||||
brand.textContent = state.expanded ? '谊安学助手 2.0' : '题库助手';
|
||||
expand.textContent = state.expanded ? '收起' : '展开';
|
||||
expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手');
|
||||
expand.setAttribute('aria-expanded', String(Boolean(state.expanded)));
|
||||
@@ -1359,8 +1372,9 @@ window.JSZip = module.exports;
|
||||
notProcessed.forEach(function (item) {
|
||||
unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。')));
|
||||
});
|
||||
exportButtons.forEach(function (button) { button.disabled = !state.canExport; });
|
||||
autoExportInput.checked = Boolean(state.autoExport);
|
||||
exportSwitch.input.checked = Boolean(state.exportEnabled);
|
||||
exportContent.hidden = !state.exportEnabled;
|
||||
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
|
||||
previous.disabled = currentOffset === 0 || Boolean(state.loading);
|
||||
next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading);
|
||||
}
|
||||
@@ -1852,7 +1866,7 @@ window.JSZip = module.exports;
|
||||
const core = window.QuestionBankCore;
|
||||
const store = window.YianBankStore;
|
||||
const matcher = window.YianAnswerMatcher;
|
||||
const config = { autoAnswer: false, allowCopy: true, autoExport: true, role: '', course: '' };
|
||||
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' };
|
||||
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
|
||||
let loading = true, disposed = false, page = null, previous = null;
|
||||
let searchQuery = '', runKey = '', debounceTimer;
|
||||
@@ -1909,7 +1923,7 @@ window.JSZip = module.exports;
|
||||
}
|
||||
function refreshPanel(extra) {
|
||||
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
||||
autoExport: config.autoExport, role: config.role, course: config.course, courses: courses(),
|
||||
exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(),
|
||||
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
|
||||
}
|
||||
function performSearch(query = searchQuery, options = {}) {
|
||||
@@ -1954,6 +1968,7 @@ window.JSZip = module.exports;
|
||||
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
|
||||
}
|
||||
function exportCurrent(format = 'csv') {
|
||||
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
|
||||
const route = routeInfo();
|
||||
if (!route.practice) throw new Error('请先进入支持的练习页面');
|
||||
const bank = core.extract(document, route.sourceUrl);
|
||||
@@ -1963,6 +1978,13 @@ window.JSZip = module.exports;
|
||||
+ ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning');
|
||||
return bank;
|
||||
}
|
||||
function setExportEnabled(enabled) {
|
||||
const opening = !!enabled && !config.exportEnabled;
|
||||
config.exportEnabled = !!enabled;
|
||||
// 用户主动重新开启可再次下载;同一开启期间只自动导出一次相同题库。
|
||||
if (opening) exported.clear();
|
||||
refreshPanel(); tick(); return config.exportEnabled;
|
||||
}
|
||||
const panel = window.YianAssistantPanel.create({
|
||||
onAutoChange: setAutoAnswer, onCopyChange: setCopy,
|
||||
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
|
||||
@@ -1970,7 +1992,7 @@ window.JSZip = module.exports;
|
||||
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
|
||||
onSelectionSearch: text => performSearch(text),
|
||||
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
|
||||
onAutoExportChange: enabled => { config.autoExport = !!enabled; saveSetting('autoExport', config.autoExport); tick(); }
|
||||
onExportEnabledChange: setExportEnabled
|
||||
});
|
||||
|
||||
function tick() {
|
||||
@@ -2008,11 +2030,11 @@ window.JSZip = module.exports;
|
||||
refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total,
|
||||
canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
|
||||
if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; }
|
||||
if (config.autoExport && bank.complete) {
|
||||
if (config.exportEnabled && bank.complete) {
|
||||
const key = route.key + '|' + signature;
|
||||
if (!exported.has(key)) {
|
||||
try { exportCurrent('csv'); exported.add(key); }
|
||||
catch (error) { config.autoExport = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
|
||||
catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
|
||||
}
|
||||
}
|
||||
const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision;
|
||||
@@ -2036,13 +2058,13 @@ window.JSZip = module.exports;
|
||||
}
|
||||
const ready = (async () => {
|
||||
try {
|
||||
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('autoExport', true)]);
|
||||
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true)]);
|
||||
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
|
||||
config.allowCopy = values[2] !== false; config.autoExport = values[3] !== false;
|
||||
config.allowCopy = values[2] !== false;
|
||||
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
|
||||
loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot;
|
||||
})();
|
||||
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, search: performSearch, getContext,
|
||||
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
|
||||
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
|
||||
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
|
||||
window.YianAssistant = api; window.YianQuestionBankExporter = api;
|
||||
Reference in New Issue
Block a user