diff --git a/README.md b/README.md index 556ded3..11ff605 100644 --- a/README.md +++ b/README.md @@ -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 便于阅读。 - 缺题或缺答案时不自动下载;手动导出会标记不完整。没有答题卡总数时,只能核对当前页面内容。 diff --git a/assistant-panel.js b/assistant-panel.js index eea1ec8..23a0a28 100644 --- a/assistant-panel.js +++ b/assistant-panel.js @@ -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); } diff --git a/build-userscript.mjs b/build-userscript.mjs index f9ece6d..7b09f51 100644 --- a/build-userscript.mjs +++ b/build-userscript.mjs @@ -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'); diff --git a/package.json b/package.json index 0750d09..63314e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yianxue-question-bank-exporter", - "version": "2.0.0", + "version": "2.0.1", "private": true, "description": "不绑定网址的谊安学题库助手:岗位搜索、允许复制和练习自动选择", "scripts": { diff --git a/practice-exporter.js b/practice-exporter.js index 7a79eda..b69bbb6 100644 --- a/practice-exporter.js +++ b/practice-exporter.js @@ -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; diff --git a/tests/assistant.cjs b/tests/assistant.cjs index 0755b8e..d4bc885 100644 --- a/tests/assistant.cjs +++ b/tests/assistant.cjs @@ -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); diff --git a/tests/verify.cjs b/tests/verify.cjs index 8f94f34..135518d 100644 --- a/tests/verify.cjs +++ b/tests/verify.cjs @@ -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 = '' + 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 本地保存网页自动识别与浏览器错误检查'); diff --git a/谊安学题库导出.user.js b/谊安学助手 2.0.user.js similarity index 98% rename from 谊安学题库导出.user.js rename to 谊安学助手 2.0.user.js index e8ff83d..7efefc3 100644 --- a/谊安学题库导出.user.js +++ b/谊安学助手 2.0.user.js @@ -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;