// ==UserScript== // @name Exam Answer Sidebar Preview // @namespace local.exam.answer.sidebar // @version 0.1.0 // @description Preview a sidebar for exam answer suggestions with mock data. // @match https://www.nmpaied.com/* // @run-at document-start // @grant unsafeWindow // @grant GM_xmlhttpRequest // @connect api.deepseek.com // @license MIT // ==/UserScript== (function () { 'use strict'; console.log('[exam-helper] script loaded'); const CONFIG = { useMockData: false, useRealAiForMock: false, hookApi: true, apiUrlKeywords: ['96', 'course_start/'], looseUrlMatch: true, objectiveQuestionTypes: [1, 2, 5], defaultSubjectiveOptionN: 'A', maxRawApiCharsForAi: 30000, panelWidth: 390, collapsed: false, deepSeekApiKey: 'sk-476cf94e1a074ed4b100dcfbd2f15c04', deepSeekModel: 'deepseek-chat' }; const state = { questions: new Map(), answers: new Map(), requestedBatches: new Set(), loading: false, status: '等待题目数据' }; const MOCK_QUESTIONS = [ { score: 2, right_answer: '', answer: '', question_type: 1, options: [ { q: '鼓励', n: 'D' }, { q: '补偿', n: 'A' }, { q: '保险', n: 'C' }, { q: '受益', n: 'B' } ], id: 10118, title: '申办者、临床试验机构和研究者不得夸大参与临床试验的( )措施,误导受试者参与临床试验。' }, { score: 2, right_answer: '', answer: '', question_type: 1, options: [ { q: '试验监查', n: 'A' }, { q: '对试验质量的最终责任', n: 'D' }, { q: '试验方案设计', n: 'C' }, { q: '数据管理', n: 'B' } ], id: 10201, title: '申办者选择合同研究组织(CRO)承担部分或全部试验职责时,以下哪项责任不能被转移?' }, { score: 2, right_answer: '', answer: '', question_type: 5, options: [ { q: '正确', n: 'A' }, { q: '错误', n: 'B' } ], id: 10488, title: '对于不良事件的记录,只要判断为“轻度”或“常见”,就可以不记录在病例报告表中。' } ]; const MOCK_AI_RESULTS = [ { id: 10118, title: '申办者、临床试验机构和研究者不得夸大参与临床试验的( )措施,误导受试者参与临床试验。', question_type: 1, answer: '补偿', option_n: 'A', confidence: 0.86, reason: '题干强调不得通过夸大相关措施误导受试者,语境上对应补偿措施。' }, { id: 10201, title: '申办者选择合同研究组织(CRO)承担部分或全部试验职责时,以下哪项责任不能被转移?', question_type: 1, answer: '对试验质量的最终责任', option_n: 'D', confidence: 0.9, reason: 'CRO 可以承担具体职责,但申办者对试验质量的最终责任不能转移。' }, { id: 10488, title: '对于不良事件的记录,只要判断为“轻度”或“常见”,就可以不记录在病例报告表中。', question_type: 5, answer: '错误', option_n: 'B', confidence: 0.84, reason: '不良事件通常需要按要求记录,不能仅因轻度或常见而不记录。' } ]; if (CONFIG.hookApi) { hookFetch(); hookXHR(); } waitForBody(init); function waitForBody(callback) { if (document.body) { callback(); return; } const timer = window.setInterval(() => { if (!document.body) return; window.clearInterval(timer); callback(); }, 100); } function init() { console.log('[exam-helper] init'); injectStyle(); createPanel(); if (CONFIG.useMockData) { loadMockData(); } renderPanel(); } function loadMockData() { state.status = CONFIG.useRealAiForMock ? '假数据已加载,等待 AI 分析' : '假数据预览'; for (const question of MOCK_QUESTIONS) { state.questions.set(question.id, question); } if (!CONFIG.useRealAiForMock) { for (const answer of MOCK_AI_RESULTS) { state.answers.set(answer.id, answer); } return; } askDeepSeekBatch(MOCK_QUESTIONS) .then(results => { for (const answer of results) { if (!answer || typeof answer.id === 'undefined') continue; state.answers.set(answer.id, normalizeAiAnswer(answer)); } state.status = `AI 已返回 ${results.length} 条答案`; renderPanel(); }) .catch(err => { state.status = 'AI 请求失败'; for (const question of MOCK_QUESTIONS) { state.answers.set(question.id, { error: err.message }); } renderPanel(); }); } async function askDeepSeekBatch(questions) { if (!CONFIG.deepSeekApiKey || CONFIG.deepSeekApiKey === 'PASTE_DEEPSEEK_API_KEY_HERE') { throw new Error('请先在 CONFIG.deepSeekApiKey 中填入 DeepSeek API Key'); } state.loading = true; renderPanel(); try { const data = await requestDeepSeek({ model: CONFIG.deepSeekModel, messages: [ { role: 'system', content: '你是练习考试系统的答题助手。只返回 JSON,不要 Markdown,不要解释性前后文。' }, { role: 'user', content: buildAnswerPrompt(questions) } ], response_format: { type: 'json_object' }, temperature: 0.1 }); const content = data && data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; const parsed = parseJsonObject(content); const results = Array.isArray(parsed) ? parsed : parsed.answers; if (!Array.isArray(results)) { throw new Error('AI 返回格式不是数组'); } return results; } finally { state.loading = false; } } async function askDeepSeekExtractAndAnswer(apiResponse) { if (!CONFIG.deepSeekApiKey || CONFIG.deepSeekApiKey === 'PASTE_DEEPSEEK_API_KEY_HERE') { throw new Error('请先在 CONFIG.deepSeekApiKey 中填入 DeepSeek API Key'); } state.loading = true; renderPanel(); try { const data = await requestDeepSeek({ model: CONFIG.deepSeekModel, messages: [ { role: 'system', content: '你是练习系统接口解析和答题助手。只返回 JSON,不要 Markdown,不要解释性前后文。' }, { role: 'user', content: buildExtractAndAnswerPrompt(apiResponse) } ], response_format: { type: 'json_object' }, temperature: 0.1 }); const content = data && data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; const parsed = parseJsonObject(content); if (!Array.isArray(parsed.questions) || !Array.isArray(parsed.answers)) { throw new Error('AI 返回格式缺少 questions 或 answers 数组'); } return { questions: parsed.questions.map(normalizeQuestion).filter(Boolean), answers: parsed.answers.map(normalizeAiAnswer) }; } finally { state.loading = false; } } function requestDeepSeek(payload) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://api.deepseek.com/chat/completions', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${CONFIG.deepSeekApiKey}` }, data: JSON.stringify(payload), timeout: 60000, onload(response) { if (response.status < 200 || response.status >= 300) { reject(new Error(`DeepSeek 请求失败:${response.status} ${response.responseText || ''}`)); return; } try { resolve(JSON.parse(response.responseText)); } catch (err) { reject(new Error(`DeepSeek 响应不是 JSON:${err.message}`)); } }, onerror() { reject(new Error('DeepSeek 网络请求失败')); }, ontimeout() { reject(new Error('DeepSeek 请求超时')); } }); }); } function buildAnswerPrompt(questions) { return ` 请根据题目和选项选择最可能正确的答案。 题型说明: question_type = 1 表示单选题 question_type = 2 表示多选题 question_type = 5 表示判断题 要求: 1. 只返回 JSON 对象,格式为 {"answers":[...]}。 2. 每道题必须返回 id、title、question_type、answer、option_n、confidence、reason。 3. 单选题和判断题:answer 是字符串,option_n 是字符串。 4. 多选题:answer 是字符串数组,option_n 是字符串数组。 5. answer 必须完全来自原题 options[].q。 6. option_n 必须完全来自原题 options[].n。 7. 不确定时降低 confidence,不要编造不存在的选项。 8. 返回顺序与输入题目顺序一致。 输入题目: ${JSON.stringify(questions, null, 2)} `; } function buildExtractAndAnswerPrompt(apiResponse) { return ` 请从下面接口响应中提取题目数组,并给出建议答案。 你需要兼容不同字段名,例如: - 题目标题可能是 title、question、content、name - 题目选项可能是 options、question_options、items、answers - 选项编号可能是 n、key、optionKey、label、code、A/B/C/D - 选项文本可能是 q、text、optionValue、value、name - question_options 可能是 JSON 字符串 题型说明: question_type = 1 表示单选题 question_type = 2 表示多选题 question_type = 5 表示判断题 其他题型当作主观题,默认选择 A。 要求: 1. 只返回 JSON 对象,不要 Markdown。 2. 返回格式必须是 {"questions":[...],"answers":[...]}。 3. questions 每项必须包含 id、title、question_type、options。 4. options 每项必须统一成 {"n":"A","q":"选项文本"}。 5. answers 每项必须包含 id、title、question_type、answer、option_n、confidence、reason。 6. 单选题和判断题:answer 是字符串,option_n 是字符串。 7. 多选题:answer 是字符串数组,option_n 是字符串数组。 8. 主观题默认 option_n 为 "A",answer 为 A 对应选项文本;没有选项时 answer 为 "A"。 9. answer 必须来自统一后的 options[].q,option_n 必须来自统一后的 options[].n。 10. 不确定时降低 confidence。 接口响应: ${stringifyForAi(apiResponse)} `; } function stringifyForAi(value) { let text; try { text = JSON.stringify(parseMaybeJson(value), null, 2); } catch (err) { text = String(value); } if (text.length <= CONFIG.maxRawApiCharsForAi) return text; return `${text.slice(0, CONFIG.maxRawApiCharsForAi)}\n...内容过长已截断,请只根据已提供内容解析...`; } function parseJsonObject(text) { const raw = String(text || '').trim() .replace(/^```json\s*/i, '') .replace(/^```\s*/i, '') .replace(/```$/i, '') .trim(); return JSON.parse(raw); } function normalizeAiAnswer(answer) { return { id: answer.id, title: answer.title || '', question_type: answer.question_type, answer: answer.answer, option_n: answer.option_n, confidence: answer.confidence, reason: answer.reason || '' }; } function hookFetch() { const rawFetch = unsafeWindow.fetch; unsafeWindow.fetch = async function (...args) { const response = await rawFetch.apply(this, args); try { const url = typeof args[0] === 'string' ? args[0] : args[0] && args[0].url; response.clone().json().then(data => { handleApiResponse(url, data); }).catch(() => {}); } catch (err) { console.warn('[exam-helper] fetch hook failed:', err); } return response; }; } function hookXHR() { const rawOpen = unsafeWindow.XMLHttpRequest.prototype.open; const rawSend = unsafeWindow.XMLHttpRequest.prototype.send; unsafeWindow.XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__examHelperUrl = url; return rawOpen.call(this, method, url, ...rest); }; unsafeWindow.XMLHttpRequest.prototype.send = function (...args) { this.addEventListener('load', () => { try { if (!this.responseText) return; handleApiResponse(this.__examHelperUrl || this.responseURL, JSON.parse(this.responseText)); } catch (err) {} }); return rawSend.apply(this, args); }; } function handleApiResponse(url, data) { const cleanUrl = String(url || '').replace(/\?.*$/, ''); if (!isTargetQuestionApi(url, cleanUrl)) return; const list = extractQuestions(data); console.log('[exam-helper] target api response:', url, { parsedQuestionCount: list.length, dataPreview: previewValue(data) }); if (!list.length) { state.status = '命中接口,本地未解析到题目,交给 AI 解析'; renderPanel(); requestExtractAndAnswersFromApi(data); return; } const batchKey = list.map(question => question.id).sort().join(','); if (state.requestedBatches.has(batchKey)) { console.log('[exam-helper] duplicate question batch ignored:', url); return; } state.requestedBatches.add(batchKey); state.status = `已抓到 ${list.length} 道题,等待 AI 分析`; console.log('[exam-helper] questions captured:', url, list); for (const question of list) { state.questions.set(question.id, question); } renderPanel(); requestAnswersForQuestions(list); } function requestExtractAndAnswersFromApi(apiResponse) { const rawKey = previewValue(apiResponse); if (state.requestedBatches.has(rawKey)) { console.log('[exam-helper] duplicate raw api response ignored'); return; } state.requestedBatches.add(rawKey); askDeepSeekExtractAndAnswer(apiResponse) .then(result => { for (const question of result.questions) { state.questions.set(question.id, question); } for (const answer of result.answers) { if (!answer || typeof answer.id === 'undefined') continue; state.answers.set(answer.id, answer); } state.status = `AI 解析到 ${result.questions.length} 道题,返回 ${result.answers.length} 条答案`; renderPanel(); }) .catch(err => { state.status = `AI 解析接口失败:${err.message}`; renderPanel(); }); } function isTargetQuestionApi(rawUrl, cleanUrl) { const keywords = CONFIG.apiUrlKeywords || []; if (!keywords.length) return true; const url = String(rawUrl || ''); return keywords.some(keyword => { const target = String(keyword); if (cleanUrl.endsWith(target)) return true; return CONFIG.looseUrlMatch && url.includes(target); }); } function requestAnswersForQuestions(questions) { const objectiveQuestions = []; const subjectiveAnswers = []; for (const question of questions) { if (isSubjectiveQuestion(question)) { subjectiveAnswers.push(buildDefaultSubjectiveAnswer(question)); } else { objectiveQuestions.push(question); } } for (const answer of subjectiveAnswers) { state.answers.set(answer.id, answer); } if (!objectiveQuestions.length) { state.status = `已处理 ${subjectiveAnswers.length} 道主观题`; renderPanel(); return; } renderPanel(); askDeepSeekBatch(objectiveQuestions) .then(results => { for (const answer of results) { if (!answer || typeof answer.id === 'undefined') continue; state.answers.set(answer.id, normalizeAiAnswer(answer)); } state.status = `AI 已返回 ${results.length} 条答案,主观题默认 ${subjectiveAnswers.length} 条`; renderPanel(); }) .catch(err => { state.status = 'AI 请求失败'; for (const question of objectiveQuestions) { state.answers.set(question.id, { error: err.message }); } renderPanel(); }); } function isSubjectiveQuestion(question) { return !CONFIG.objectiveQuestionTypes.includes(Number(question.question_type)); } function buildDefaultSubjectiveAnswer(question) { const option = (question.options || []).find(item => String(item.n).toUpperCase() === CONFIG.defaultSubjectiveOptionN ) || (question.options || [])[0] || {}; return { id: question.id, title: question.title || '', question_type: question.question_type, answer: option.q || CONFIG.defaultSubjectiveOptionN, option_n: option.n || CONFIG.defaultSubjectiveOptionN, confidence: 1, reason: '主观题按配置默认选择 A。' }; } function extractQuestions(data) { data = parseMaybeJson(data); if (Array.isArray(data)) return uniqueById(data.map(normalizeQuestion).filter(Boolean)); const found = []; walk(data); return uniqueById(found); function walk(value) { if (!value || typeof value !== 'object') return; if (Array.isArray(value)) { const normalized = value.map(normalizeQuestion).filter(Boolean); if (normalized.length) { found.push(...normalized); return; } for (const item of value) walk(item); return; } for (const key of Object.keys(value)) { walk(value[key]); } } } function uniqueById(list) { return [...new Map(list.map(item => [item.id, item])).values()]; } function normalizeQuestion(item) { if (!item || typeof item !== 'object') return null; const title = item.title || item.question || item.name || item.subject || item.content; const rawOptions = item.options || item.option || item.question_options || item.answers || item.answer_list || item.items; const options = normalizeOptions(rawOptions); if (typeof item.id === 'undefined' || typeof title !== 'string' || !options.length) { return null; } return { ...item, id: item.id, title, question_type: item.question_type || item.type || item.q_type, options }; } function normalizeOptions(rawOptions) { let options = parseMaybeJson(rawOptions); if (!Array.isArray(options)) return normalizeObjectOptions(options); return options.map((option, index) => { if (!option || typeof option !== 'object') return null; const n = option.n || option.optionKey || option.key || option.label || option.code || String.fromCharCode(65 + index); const q = option.q || option.optionValue || option.text || option.title || option.name || option.value || option.content; if (typeof q === 'undefined') { const keys = Object.keys(option); if (keys.length === 1) { const key = keys[0]; return { q: String(option[key]), n: String(key) }; } return null; } return { ...option, q: String(q), n: String(n) }; }).filter(Boolean); } function normalizeObjectOptions(options) { if (!options || typeof options !== 'object') return []; return Object.keys(options).map(key => ({ q: String(options[key]), n: String(key) })); } function parseMaybeJson(value) { if (typeof value !== 'string') return value; const text = value.trim(); if (!text) return value; try { return JSON.parse(text); } catch (err) { return value; } } function previewValue(value) { try { const text = JSON.stringify(value); return text.length > 1000 ? `${text.slice(0, 1000)}...` : text; } catch (err) { return String(value); } } function collectDomQuestions() { return [...document.querySelectorAll('.question')].map(root => { const title = root.querySelector('dt')?.innerText || ''; const options = [...root.querySelectorAll('.el-radio, .el-checkbox, label')].map(option => ({ text: option.innerText || option.textContent || '', value: option.querySelector('input')?.value || '', node: option })); return { title, options, root }; }); } function fillAnswersFromDom() { const answers = [...state.answers.values()].filter(answer => answer && !answer.error); const domQuestions = collectDomQuestions(); let success = 0; let failed = 0; for (const answer of answers) { const domQuestion = findDomQuestion(domQuestions, answer); if (!domQuestion) { console.warn('[exam-helper] no dom question matched:', answer); failed++; continue; } const ok = clickDomAnswer(domQuestion.root, answer); if (ok) { success++; } else { failed++; } } return { success, failed }; } function findDomQuestion(domQuestions, answer) { const targetTitle = normalizeMatchText(answer.title); if (!targetTitle) return null; return domQuestions.find(item => { const pageTitle = normalizeQuestionTitle(item.title); return pageTitle.includes(targetTitle) || targetTitle.includes(pageTitle); }); } function clickDomAnswer(questionRoot, answer) { const answerTexts = Array.isArray(answer.answer) ? answer.answer : [answer.answer]; const optionNs = Array.isArray(answer.option_n) ? answer.option_n : [answer.option_n]; let allMatched = true; answerTexts.forEach((answerText, index) => { const optionN = optionNs[index]; const matched = findOptionNode(questionRoot, answerText, optionN); if (!matched) { console.warn('[exam-helper] no option matched:', { answerText, optionN, questionRoot }); allMatched = false; return; } clickOptionNode(matched); }); return allMatched; } function findOptionNode(questionRoot, answerText, optionN) { const optionNodes = [...questionRoot.querySelectorAll('.el-radio, .el-checkbox, label')]; const targetText = normalizeOptionText(answerText); if (targetText) { const byText = optionNodes.find(option => { const label = option.querySelector('.el-radio__label, .el-checkbox__label') || option; const pageText = normalizeOptionText(label.innerText || label.textContent || ''); return pageText === targetText || pageText.includes(targetText) || targetText.includes(pageText); }); if (byText) return byText; } const targetN = String(optionN || '').trim().toUpperCase(); if (!targetN) return null; return optionNodes.find(option => { const input = option.querySelector('input'); const label = option.querySelector('.el-radio__label, .el-checkbox__label') || option; const value = String(input?.value || '').trim().toUpperCase(); const labelPrefix = String(label.innerText || label.textContent || '').trim().slice(0, 1).toUpperCase(); return value === targetN || labelPrefix === targetN; }); } function clickOptionNode(optionNode) { const input = optionNode.querySelector('input[type="radio"], input[type="checkbox"]'); optionNode.click(); if (input) { input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } } function normalizeQuestionTitle(text) { return normalizeMatchText(String(text || '') .replace(/^【[^】]+】/, '') .replace(/^\d+[、..]/, '')); } function normalizeOptionText(text) { return normalizeMatchText(String(text || '') .replace(/^[A-ZA-Z]\s*[、..::]\s*/i, '')); } function normalizeMatchText(text) { return decodeHtml(text) .replace(/[A-Za-z0-9]/g, char => String.fromCharCode(char.charCodeAt(0) - 0xFEE0)) .replace(/\s+/g, '') .replace(/[“”‘’"',。,.、::;;()()【】\[\]]/g, '') .trim() .toLowerCase(); } function createPanel() { if (document.getElementById('exam-answer-sidebar')) return; const panel = document.createElement('aside'); panel.id = 'exam-answer-sidebar'; panel.innerHTML = `
答案建议
初始化中
`; document.body.appendChild(panel); panel.querySelector('.exam-helper-toggle').addEventListener('click', () => { CONFIG.collapsed = !CONFIG.collapsed; renderPanel(); }); panel.querySelector('.exam-helper-fill').addEventListener('click', () => { const result = fillAnswersFromDom(); state.status = `填充完成:成功 ${result.success},失败 ${result.failed}`; renderPanel(); }); panel.querySelector('.exam-helper-debug').addEventListener('click', () => { console.log('[exam-helper] page questions:', collectDomQuestions()); }); } function injectStyle() { if (document.getElementById('exam-answer-sidebar-style')) return; const style = document.createElement('style'); style.id = 'exam-answer-sidebar-style'; style.textContent = ` #exam-answer-sidebar { position: fixed; top: 72px; right: 16px; width: ${CONFIG.panelWidth}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); z-index: 2147483647; overflow: hidden; background: #ffffff; color: #1f2933; border: 1px solid #d7dde5; border-radius: 8px; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18); font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } #exam-answer-sidebar * { box-sizing: border-box; } .exam-helper-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid #e5e9ef; background: #f8fafc; } .exam-helper-title { font-size: 15px; font-weight: 700; color: #111827; } .exam-helper-subtitle { margin-top: 2px; font-size: 12px; color: #667085; } .exam-helper-toggle { flex: 0 0 auto; height: 30px; padding: 0 10px; border: 1px solid #cfd7e3; border-radius: 6px; background: #ffffff; color: #344054; cursor: pointer; font: inherit; } .exam-helper-actions { display: flex; flex: 0 0 auto; gap: 6px; } .exam-helper-fill, .exam-helper-debug { flex: 0 0 auto; height: 30px; padding: 0 10px; border: 1px solid #b9d4c2; border-radius: 6px; background: #f0fbf3; color: #087443; cursor: pointer; font: inherit; } .exam-helper-debug { border-color: #cfd7e3; background: #ffffff; color: #344054; } .exam-helper-toggle:hover { background: #eef2f7; } .exam-helper-fill:hover { background: #dff7e7; } .exam-helper-debug:hover { background: #eef2f7; } .exam-helper-body { max-height: calc(100vh - 160px); overflow: auto; padding: 10px 12px 12px; } .exam-helper-empty { padding: 18px 8px; color: #667085; text-align: center; } .exam-helper-card { padding: 10px 0; border-top: 1px solid #edf1f5; } .exam-helper-card:first-child { border-top: 0; } .exam-helper-card-title { font-weight: 650; color: #101828; word-break: break-word; } .exam-helper-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } .exam-helper-pill { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; background: #eef2f7; color: #475467; font-size: 12px; } .exam-helper-options { margin-top: 8px; padding: 8px; border-radius: 6px; background: #f8fafc; color: #475467; } .exam-helper-option { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 6px; padding: 2px 0; } .exam-helper-answer { margin-top: 8px; padding: 8px; border: 1px solid #b7dfc2; border-radius: 6px; background: #f0fbf3; } .exam-helper-answer-main { font-weight: 700; color: #087443; } .exam-helper-reason { margin-top: 4px; color: #3f5f4b; word-break: break-word; } .exam-helper-waiting { margin-top: 8px; color: #667085; } .exam-helper-error { margin-top: 8px; padding: 8px; border: 1px solid #f4b6b6; border-radius: 6px; background: #fff3f3; color: #b42318; } #exam-answer-sidebar.is-collapsed { width: 220px; } #exam-answer-sidebar.is-collapsed .exam-helper-body { display: none; } `; document.documentElement.appendChild(style); } function renderPanel() { const panel = document.getElementById('exam-answer-sidebar'); if (!panel) return; const status = panel.querySelector('[data-role="status"]'); const body = panel.querySelector('[data-role="body"]'); const toggle = panel.querySelector('.exam-helper-toggle'); const questions = [...state.questions.values()]; panel.classList.toggle('is-collapsed', CONFIG.collapsed); toggle.textContent = CONFIG.collapsed ? '展开' : '收起'; status.textContent = `${state.status} · ${questions.length} 题`; if (!questions.length) { body.innerHTML = '
还没有题目数据
'; return; } body.innerHTML = questions.map((question, index) => renderQuestion(question, index)).join(''); } function renderQuestion(question, index) { const answer = state.answers.get(question.id); const typeText = getQuestionTypeText(question.question_type); return `
${index + 1}. ${escapeHtml(question.title)}
ID ${escapeHtml(question.id)} ${escapeHtml(typeText)} ${escapeHtml(question.score)} 分
${question.options.map(renderOption).join('')}
${renderAnswer(answer)}
`; } function renderOption(option) { return `
${escapeHtml(option.n)}. ${escapeHtml(decodeHtml(option.q))}
`; } function renderAnswer(answer) { if (!answer) { return '
等待 AI 分析...
'; } if (answer.error) { return `
${escapeHtml(answer.error)}
`; } const optionText = Array.isArray(answer.option_n) ? answer.option_n.join(', ') : answer.option_n || ''; const answerText = Array.isArray(answer.answer) ? answer.answer.join(';') : answer.answer || ''; return `
建议答案:${escapeHtml(optionText)} ${escapeHtml(answerText)}
置信度:${escapeHtml(answer.confidence ?? '-')}
${escapeHtml(answer.reason || '')}
`; } function getQuestionTypeText(type) { const map = { 1: '单选题', 2: '多选题', 5: '判断题' }; return map[type] || `题型 ${type}`; } function decodeHtml(value) { const textarea = document.createElement('textarea'); textarea.innerHTML = String(value ?? ''); return textarea.value; } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } })();