HTML实现DOCX文档版题库图文考试系统(修订)

HTML实现DOCX文档版题库图文考试系统(修订)

支持保留DOCX文档版题库的复杂样式:加粗、斜体、颜色等排版属性。

适用场景

教师/培训师制作图文并茂的选择题试卷,通过浏览器快速进行课堂练习或自我测试,无需搭建任何平台。

运行截图与使用说明****

使用说明

1.准备 DOCX 文件题库,格式要求:

· 每道题以 "第x题"(支持空格)开头,如"第1题"、"第2题"。

· 选项行以 #A. 或 #B. 开头(可识别最多 26 个字母)。 如:

#A. 这是选项A

#B. 这是选项B

#C. 这是选项C

#D. 这是选项D

· 题干和选项中的图片会被自动提取并显示在原位置。

意,题目分界("第x题")和选项标记(#A.)必须严格标记。

2.配置答案:在每道题 选项的的后面 设置答案行 格式为 答案 选项字符。

3.打开 HTML → 选择文件 → 开始答题 → 点击上一题/下一题切换 → 点击 交卷 查看分数和对错。

测试用 DOCX 文件题库 示例

我这里内容如下:

第1题

以下饼图中,......

空白占据50%是

#A.A图

#B. B图

#C. C图

#D. D图

答案: A

第2题

这是一个表格 示例题

这是表格

|-------------|-------------|-------------|
| | 特点1 | 特点1 |
| 项目1 | 合格 | 不合格 |
| 项目2 | 符合要求 | 需要改进 |
| 项目3 | 不标准 | 标准 |

比较好的项目是:

#A. 项目1

#B. 项目2

#C. 项目3

答案: B

源码

(改进版)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>智能图文考试系统 - 改进版</title>
  <script src="https://unpkg.com/jszip/dist/jszip.min.js"></script>
  <style>
    * {
      box-sizing: border-box;
    }
    body {
      font-family: 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
      margin: 0;
      background: #f0f2f5;
      padding: 20px;
    }
    #app {
      max-width: 1200px;
      margin: 0 auto;
    }
    #toolbar {
      background: white;
      padding: 18px 24px;
      border-radius: 20px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.05);
      margin-bottom: 24px;
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 16px;
    }
    .file-area {
      display: flex;
      align-items: center;
      gap: 12px;
      flex-wrap: wrap;
    }
    #status {
      color: #2c3e66;
      background: #eef2ff;
      padding: 6px 14px;
      border-radius: 30px;
      font-size: 0.85rem;
      font-weight: 500;
    }
    .nav-buttons {
      display: flex;
      gap: 12px;
      margin-left: auto;
      align-items: center;
    }
    button {
      background: white;
      border: 1px solid #cbd5e1;
      padding: 8px 20px;
      border-radius: 40px;
      font-weight: 600;
      cursor: pointer;
      transition: all 0.2s ease;
      font-size: 0.9rem;
      color: #1e293b;
    }
    button:hover:not(:disabled) {
      background: #f1f5f9;
      border-color: #94a3b8;
      transform: translateY(-1px);
    }
    button:active {
      transform: translateY(1px);
    }
    #submitBtn {
      background: #2563eb;
      border-color: #2563eb;
      color: white;
    }
    #submitBtn:hover:not(:disabled) {
      background: #1d4ed8;
    }
    .question-card {
      background: white;
      border-radius: 24px;
      padding: 28px 30px;
      margin-bottom: 28px;
      box-shadow: 0 8px 20px rgba(0,0,0,0.05);
      transition: all 0.2s;
      border-left: 6px solid #e2e8f0;
    }
    .question-card.correct {
      border-left-color: #10b981;
      background: #f0fdf9;
    }
    .question-card.incorrect {
      border-left-color: #ef4444;
      background: #fef2f2;
    }
    .question-card.unanswered {
      border-left-color: #f59e0b;
      background: #fffbeb;
    }
    .question-title {
      font-size: 1.4rem;
      font-weight: 700;
      margin-bottom: 16px;
      color: #0f172a;
      border-bottom: 2px solid #eef2ff;
      padding-bottom: 10px;
    }
    .question-body {
      margin: 16px 0;
      line-height: 1.5;
      color: #1e293b;
    }
    /* 样式由内联 style 控制,这里只做基础重置 */
    .options {
      margin: 20px 0 12px 18px;
      background: #f8fafc;
      padding: 16px 20px;
      border-radius: 20px;
    }
    .options label {
      display: flex;
      align-items: baseline;
      gap: 10px;
      margin: 12px 0;
      cursor: pointer;
      font-size: 1rem;
      padding: 6px 8px;
      border-radius: 16px;
      transition: background 0.1s;
    }
    .options label:hover {
      background: #eef2ff;
    }
    .options input[type="radio"] {
      width: 18px;
      height: 18px;
      margin-top: 2px;
      accent-color: #2563eb;
    }
    .feedback {
      margin-top: 18px;
      padding: 12px 18px;
      border-radius: 18px;
      font-weight: 600;
      background: #ffffffd9;
      border-left: 4px solid;
    }
    .result-summary {
      background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
      color: white;
      padding: 20px 28px;
      border-radius: 32px;
      margin: 20px 0;
      font-size: 1.25rem;
      font-weight: 600;
      display: flex;
      justify-content: space-between;
      align-items: center;
      box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1);
    }
    #scoreDisplay {
      display: none;
    }
    .table-wrapper {
      overflow-x: auto;
      margin: 20px 0;
      border-radius: 16px;
      border: 1px solid #e2e8f0;
    }
    .exam-table {
      width: 100%;
      border-collapse: collapse;
      background: white;
      font-size: 0.95rem;
    }
    .exam-table td, .exam-table th {
      border: 1px solid #cbd5e1;
      padding: 10px 12px;
      vertical-align: top;
      background-color: white;
    }
    .exam-table th {
      background-color: #f8fafc;
      font-weight: 600;
    }
    img {
      max-width: 100%;
      border-radius: 12px;
      box-shadow: 0 2px 6px rgba(0,0,0,0.1);
      margin: 12px 0;
    }
    #questionIndex {
      font-weight: 600;
      background: #eef2ff;
      padding: 6px 16px;
      border-radius: 40px;
      font-size: 0.9rem;
    }
    @media (max-width: 700px) {
      .question-card { padding: 20px; }
      #toolbar { flex-direction: column; align-items: stretch; }
      .nav-buttons { margin-left: 0; justify-content: center; }
    }
  </style>
</head>
<body>
<div id="app">
  <div id="toolbar">
    <div class="file-area">
      <input type="file" id="fileInput" accept=".docx" style="padding: 6px;">
      <span id="status">📄 请上传 .docx 试卷</span>
    </div>
    <div class="nav-buttons">
      <button id="prevBtn" disabled>◀ 上一题</button>
      <button id="nextBtn" disabled>下一题 ▶</button>
      <button id="submitBtn" style="display:none;">✅ 交卷</button>
      <span id="questionIndex"></span>
    </div>
  </div>
  <div id="exam-container"></div>
  <div id="scoreDisplay"></div>
</div>

<script>
  const fileInput = document.getElementById('fileInput');
  const statusSpan = document.getElementById('status');
  const container = document.getElementById('exam-container');
  const prevBtn = document.getElementById('prevBtn');
  const nextBtn = document.getElementById('nextBtn');
  const submitBtn = document.getElementById('submitBtn');
  const questionIndexSpan = document.getElementById('questionIndex');
  const scoreDisplayDiv = document.getElementById('scoreDisplay');

  let questions = [];
  let userAnswers = {};
  let currentQuestion = 0;
  let examSubmitted = false;
  let imageMap = {};

  const NS = {
    w: 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
    r: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
    wp: 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing',
    a: 'http://schemas.openxmlformats.org/drawingml/2006/main',
    pic: 'http://schemas.openxmlformats.org/drawingml/2006/picture'
  };

  function getElementPlainText(elem) {
    const texts = elem.getElementsByTagNameNS(NS.w, 't');
    return Array.from(texts).map(t => t.textContent).join('');
  }

  // 将 w:rPr 转换为内联 CSS 样式字符串
  function getInlineStyleFromRPr(rPr) {
    if (!rPr) return '';
    const styles = [];
    
    // 加粗 <w:b/>
    if (rPr.getElementsByTagNameNS(NS.w, 'b').length > 0) styles.push('font-weight:bold');
    // 斜体 <w:i/>
    if (rPr.getElementsByTagNameNS(NS.w, 'i').length > 0) styles.push('font-style:italic');
    // 下划线 <w:u/> (一般有值即代表下划线)
    const uNode = rPr.getElementsByTagNameNS(NS.w, 'u')[0];
    if (uNode) styles.push('text-decoration:underline');
    // 删除线 <w:strike/>
    if (rPr.getElementsByTagNameNS(NS.w, 'strike').length > 0) styles.push('text-decoration:line-through');
    // 颜色 <w:color w:val="FF0000"/>
    const colorNode = rPr.getElementsByTagNameNS(NS.w, 'color')[0];
    if (colorNode) {
      let colorVal = colorNode.getAttribute('w:val');
      if (colorVal && colorVal !== 'auto') {
        if (!colorVal.startsWith('#')) colorVal = '#' + colorVal;
        styles.push(`color:${colorVal}`);
      }
    }
    // 高亮背景 <w:highlight w:val="yellow"/>
    const highlightNode = rPr.getElementsByTagNameNS(NS.w, 'highlight')[0];
    if (highlightNode) {
      let highVal = highlightNode.getAttribute('w:val');
      if (highVal) {
        // 常见高亮色转CSS背景色
        const highlightMap = { yellow: '#ffff00', green: '#00ff00', cyan: '#00ffff', magenta: '#ff00ff', blue: '#0000ff', red: '#ff0000', darkBlue: '#00008b', darkCyan: '#008b8b', darkGreen: '#006400', darkMagenta: '#8b008b', darkRed: '#8b0000', darkYellow: '#808000' };
        const bgColor = highlightMap[highVal] || '#ffff00';
        styles.push(`background-color:${bgColor}`);
      }
    }
    // 字体大小 <w:sz w:val="半磅值"/> (1/2 point)
    const szNode = rPr.getElementsByTagNameNS(NS.w, 'sz')[0];
    if (szNode) {
      let szVal = szNode.getAttribute('w:val');
      if (szVal) {
        let pt = parseInt(szVal, 10) / 2;
        styles.push(`font-size:${pt}pt`);
      }
    }
    return styles.join(';');
  }

  // 增强版 run 转 HTML (完整支持样式、图片、换行)
  function runToHtml(run) {
    // 图片处理
    const drawings = run.getElementsByTagNameNS(NS.w, 'drawing');
    if (drawings.length > 0) {
      const blip = drawings[0].getElementsByTagNameNS(NS.a, 'blip')[0];
      if (blip) {
        const embed = blip.getAttributeNS(NS.r, 'embed');
        if (embed && imageMap[embed]) {
          return `<img src="${imageMap[embed]}" alt="图片" style="max-width:100%; border-radius:12px; margin:8px 0;">`;
        }
      }
    }

    // 获取文本内容
    let textContent = '';
    const textNodes = run.getElementsByTagNameNS(NS.w, 't');
    for (let tNode of textNodes) {
      textContent += tNode.textContent;
    }
    // 处理换行 <w:br/>
    const breaks = run.getElementsByTagNameNS(NS.w, 'br');
    if (breaks.length > 0 && textContent === '') {
      textContent = '<br>';
    } else if (breaks.length > 0) {
      textContent += '<br>';
    }
    if (!textContent && textContent !== '<br>') return '';

    // 获取样式
    const rPr = run.getElementsByTagNameNS(NS.w, 'rPr')[0];
    const inlineStyle = getInlineStyleFromRPr(rPr);
    
    if (textContent === '<br>') return '<br>';
    if (inlineStyle) {
      return `<span style="${inlineStyle}">${textContent}</span>`;
    }
    return textContent;
  }

  // 段落转 HTML(保持段落内 run 的顺序,不额外加 p 标签,由外部控制)
  function paragraphToHtml(paragraphElem) {
    let html = '';
    const runs = paragraphElem.getElementsByTagNameNS(NS.w, 'r');
    for (let run of runs) {
      html += runToHtml(run);
    }
    // 如果段落只有图片(无 run 内容但有 drawing 直接子级)
    if (html.trim() === '') {
      const drawings = paragraphElem.getElementsByTagNameNS(NS.w, 'drawing');
      for (let draw of drawings) {
        const blip = draw.getElementsByTagNameNS(NS.a, 'blip')[0];
        if (blip) {
          const embed = blip.getAttributeNS(NS.r, 'embed');
          if (embed && imageMap[embed]) {
            html += `<img src="${imageMap[embed]}" style="max-width:100%; border-radius:12px;">`;
          }
        }
      }
    }
    return html;
  }

  // 单元格内容提取(保留内部段落样式及嵌套表格)
  function extractCellHtml(cell) {
    let innerHtml = '';
    const paragraphs = cell.getElementsByTagNameNS(NS.w, 'p');
    for (let p of paragraphs) {
      const paraHtml = paragraphToHtml(p);
      if (paraHtml.trim()) {
        innerHtml += `<div style="margin:4px 0;">${paraHtml}</div>`;
      }
    }
    // 处理嵌套表格
    const nestedTables = cell.getElementsByTagNameNS(NS.w, 'tbl');
    for (let tbl of nestedTables) {
      innerHtml += tableToHtml(tbl);
    }
    if (innerHtml === '') innerHtml = '&nbsp;';
    return innerHtml;
  }

  // 表格转 HTML(支持合并单元格)
  function tableToHtml(tableElem) {
    const rows = tableElem.getElementsByTagNameNS(NS.w, 'tr');
    if (!rows.length) return '';
    
    const rowData = [];
    const vMergeMap = {}; // 记录已被垂直合并占用的格子
    
    for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
      const row = rows[rowIdx];
      const cells = row.getElementsByTagNameNS(NS.w, 'tc');
      let colIdx = 0;
      while (vMergeMap[`${rowIdx},${colIdx}`]) {
        colIdx++;
      }
      const rowCells = [];
      for (let cell of cells) {
        const cellHtml = extractCellHtml(cell);
        let colspan = 1;
        let rowspan = 1;
        const tcPr = cell.getElementsByTagNameNS(NS.w, 'tcPr')[0];
        if (tcPr) {
          const gridSpan = tcPr.getElementsByTagNameNS(NS.w, 'gridSpan')[0];
          if (gridSpan) {
            const spanVal = gridSpan.getAttribute('w:val');
            if (spanVal) colspan = parseInt(spanVal, 10) || 1;
          }
          const vMerge = tcPr.getElementsByTagNameNS(NS.w, 'vMerge')[0];
          if (vMerge) {
            const val = vMerge.getAttribute('w:val');
            if (val === 'restart') {
              let mergeCount = 1;
              for (let nextRow = rowIdx+1; nextRow < rows.length; nextRow++) {
                const nextCells = rows[nextRow].getElementsByTagNameNS(NS.w, 'tc');
                if (nextCells.length > colIdx) {
                  const nextTcPr = nextCells[colIdx].getElementsByTagNameNS(NS.w, 'tcPr')[0];
                  if (nextTcPr) {
                    const nextVMerge = nextTcPr.getElementsByTagNameNS(NS.w, 'vMerge')[0];
                    if (nextVMerge && !nextVMerge.getAttribute('w:val')) {
                      mergeCount++;
                      vMergeMap[`${nextRow},${colIdx}`] = true;
                      continue;
                    }
                  }
                }
                break;
              }
              rowspan = mergeCount;
            } else {
              continue; // 被合并单元格跳过
            }
          }
        }
        rowCells.push({ html: cellHtml, colspan, rowspan, colIdx });
        for (let i = 0; i < colspan; i++) {
          vMergeMap[`${rowIdx},${colIdx + i}`] = true;
        }
        colIdx += colspan;
      }
      rowData.push(rowCells);
    }
    
    let html = '<div class="table-wrapper"><table class="exam-table">';
    for (let i = 0; i < rowData.length; i++) {
      html += '<tr>';
      let colPos = 0;
      for (let cell of rowData[i]) {
        while (colPos < cell.colIdx) {
          html += '<td style="border:1px solid #cbd5e1; padding:8px;">&nbsp;</td>';
          colPos++;
        }
        const colspanAttr = cell.colspan > 1 ? ` colspan="${cell.colspan}"` : '';
        const rowspanAttr = cell.rowspan > 1 ? ` rowspan="${cell.rowspan}"` : '';
        html += `<td${colspanAttr}${rowspanAttr} style="border:1px solid #cbd5e1; padding:10px 12px; vertical-align:top;">${cell.html}</td>`;
        colPos += cell.colspan;
      }
      html += '</tr>';
    }
    html += '</table></div>';
    return html;
  }

  // 提取答案行(单独一行 "答案 X")
  function extractAnswerFromParagraph(paragraphElem) {
    const rawText = getElementPlainText(paragraphElem).trim();
    const regex = /答案\s*[::]?\s*([A-Z]+)/i;
    const match = rawText.match(regex);
    if (match) {
      let answerStr = match[1].toUpperCase();
      if (answerStr.length > 1) answerStr = answerStr.charAt(0);
      return answerStr;
    }
    return null;
  }

  // 解析题目(保留样式、表格)
  async function parseQuestionsWithStyle(xmlDoc) {
    const body = xmlDoc.getElementsByTagNameNS(NS.w, 'body')[0];
    const children = body.children;
    const questionRegex = /^第\s*\d+\s*[题卷]/;
    let currentQ = null;
    const result = [];

    for (let elem of children) {
      if (elem.tagName === 'w:p') {
        const text = getElementPlainText(elem).trim();
        if (questionRegex.test(text)) {
          if (currentQ) result.push(currentQ);
          currentQ = {
            title: text,
            htmlBody: '',
            options: [],
            correctAnswer: null
          };
          continue;
        }
        if (currentQ) {
          const answer = extractAnswerFromParagraph(elem);
          if (answer) {
            currentQ.correctAnswer = answer;
            continue;
          }
          const optionMatch = text.match(/^#([A-Z])\.\s*(.*)/);
          if (optionMatch) {
            currentQ.options.push({
              label: optionMatch[1],
              text: optionMatch[2] || ''
            });
          } else {
            const paraHtml = paragraphToHtml(elem);
            if (paraHtml.trim() !== '') {
              currentQ.htmlBody += `<p style="margin:0.5em 0; line-height:1.5;">${paraHtml}</p>`;
            }
          }
        }
      } else if (elem.tagName === 'w:tbl' && currentQ) {
        currentQ.htmlBody += tableToHtml(elem);
      }
    }
    if (currentQ) result.push(currentQ);
    return result;
  }

  async function loadImages(zip) {
    const relsFile = zip.file('word/_rels/document.xml.rels');
    if (!relsFile) return;
    const relsXml = await relsFile.async('string');
    const relsDoc = new DOMParser().parseFromString(relsXml, 'text/xml');
    const relationships = relsDoc.getElementsByTagNameNS('*', 'Relationship');
    for (let rel of relationships) {
      const id = rel.getAttribute('Id');
      const target = rel.getAttribute('Target');
      const type = rel.getAttribute('Type');
      if (type && type.includes('image')) {
        let imgPath = 'word/' + target;
        const imgFile = zip.file(imgPath);
        if (imgFile) {
          const blob = await imgFile.async('blob');
          const dataUrl = await new Promise((resolve) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result);
            reader.readAsDataURL(blob);
          });
          imageMap[id] = dataUrl;
        }
      }
    }
  }

  function resetState() {
    questions = [];
    userAnswers = {};
    currentQuestion = 0;
    examSubmitted = false;
    imageMap = {};
    container.innerHTML = '';
    scoreDisplayDiv.style.display = 'none';
    scoreDisplayDiv.innerHTML = '';
    submitBtn.style.display = 'none';
    prevBtn.disabled = true;
    nextBtn.disabled = true;
    questionIndexSpan.textContent = '';
    statusSpan.textContent = '📄 请上传 .docx 试卷';
  }

  function showQuestion(index) {
    if (examSubmitted) {
      renderAllQuestions();
      return;
    }
    if (!questions.length) return;
    if (index < 0) index = 0;
    if (index >= questions.length) index = questions.length - 1;
    currentQuestion = index;
    const q = questions[currentQuestion];
    const chosen = userAnswers[currentQuestion] || null;

    let optionsHtml = '';
    if (q.options.length) {
      optionsHtml = '<div class="options">';
      q.options.forEach(opt => {
        const checked = (chosen === opt.label) ? 'checked' : '';
        optionsHtml += `
          <label>
            <input type="radio" name="singleQ" value="${opt.label}" ${checked}
                   onchange="window.selectAnswer(${currentQuestion}, '${opt.label}')">
            <strong>${opt.label}.</strong> ${opt.text}
          </label>`;
      });
      optionsHtml += '</div>';
    } else {
      optionsHtml = '<div class="options" style="color:#6c757d;">⚠️ 未识别选项(请检查格式 #A. 内容)</div>';
    }

    container.innerHTML = `
      <div class="question-card">
        <div class="question-title">${escapeHtml(q.title)}</div>
        <div class="question-body">${q.htmlBody || ''}</div>
        ${optionsHtml}
      </div>
    `;
    questionIndexSpan.textContent = `${currentQuestion + 1} / ${questions.length}`;
    prevBtn.disabled = (currentQuestion === 0);
    nextBtn.disabled = (currentQuestion === questions.length - 1);
  }

  function renderAllQuestions() {
    container.innerHTML = '';
    questions.forEach((q, idx) => {
      const card = document.createElement('div');
      card.className = 'question-card';
      const chosen = userAnswers[idx];
      const correctAns = q.correctAnswer;
      if (examSubmitted && correctAns) {
        const isCorrect = (chosen === correctAns);
        if (isCorrect) card.classList.add('correct');
        else if (chosen) card.classList.add('incorrect');
        else card.classList.add('unanswered');
      } else if (examSubmitted && !correctAns) {
        card.classList.add('unanswered');
      }

      let optionsHtml = '<div class="options">';
      if (q.options.length) {
        q.options.forEach(opt => {
          const checked = (chosen === opt.label) ? 'checked' : '';
          const disabledAttr = examSubmitted ? 'disabled' : '';
          optionsHtml += `
            <label>
              <input type="radio" name="q${idx}" value="${opt.label}" ${checked} ${disabledAttr}>
              <strong>${opt.label}.</strong> ${opt.text}
            </label>`;
        });
      } else {
        optionsHtml += '<span>⚠️ 未识别选项</span>';
      }
      optionsHtml += '</div>';

      let feedbackHtml = '';
      if (examSubmitted) {
        if (correctAns) {
          if (chosen === correctAns) feedbackHtml = `<div class="feedback" style="color:#10b981; border-left-color:#10b981;">✔ 回答正确</div>`;
          else if (chosen) feedbackHtml = `<div class="feedback" style="color:#dc2626; border-left-color:#dc2626;">✘ 错误,正确答案是 ${correctAns}</div>`;
          else feedbackHtml = `<div class="feedback" style="color:#f59e0b; border-left-color:#f59e0b;">⚠ 未作答,正确答案是 ${correctAns}</div>`;
        } else {
          feedbackHtml = `<div class="feedback" style="color:#6c757d;">❗ 文档中未设置"答案 X"行,无法评分</div>`;
        }
      }

      card.innerHTML = `
        <div class="question-title">${escapeHtml(q.title)}</div>
        <div class="question-body">${q.htmlBody || ''}</div>
        ${optionsHtml}
        ${feedbackHtml}
      `;
      container.appendChild(card);
    });
    questionIndexSpan.textContent = `共 ${questions.length} 题`;
    prevBtn.disabled = true;
    nextBtn.disabled = true;
  }

  function escapeHtml(str) {
    if (!str) return '';
    return str.replace(/[&<>]/g, function(m) {
      if (m === '&') return '&amp;';
      if (m === '<') return '&lt;';
      if (m === '>') return '&gt;';
      return m;
    });
  }

  window.selectAnswer = function(qIndex, label) {
    if (examSubmitted) return;
    userAnswers[qIndex] = label;
  };

  function submitExam() {
    if (examSubmitted) return;
    let unansweredCount = 0;
    for (let i = 0; i < questions.length; i++) {
      if (!userAnswers[i]) unansweredCount++;
    }
    if (unansweredCount > 0) {
      if (!confirm(`还有 ${unansweredCount} 道题未作答,确定交卷吗?`)) return;
    }
    examSubmitted = true;
    submitBtn.disabled = true;
    
    let score = 0;
    let validScoreCount = 0;
    for (let i = 0; i < questions.length; i++) {
      const correct = questions[i].correctAnswer;
      if (correct) {
        validScoreCount++;
        if (userAnswers[i] === correct) score++;
      }
    }
    const totalValid = validScoreCount || questions.length;
    const percent = totalValid > 0 ? Math.round((score / totalValid) * 100) : 0;
    scoreDisplayDiv.style.display = 'block';
    scoreDisplayDiv.innerHTML = `
      <div class="result-summary">
        <span>📊 得分:${score} / ${totalValid} 题(有效题目 ${totalValid})</span>
        <span>🎯 正确率 ${percent}%</span>
      </div>
    `;
    renderAllQuestions();
  }

  fileInput.addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;
    resetState();
    statusSpan.textContent = '⏳ 解析文档中,保留样式与表格...';
    try {
      const arrayBuffer = await file.arrayBuffer();
      const zip = await JSZip.loadAsync(arrayBuffer);
      await loadImages(zip);
      const documentXml = await zip.file('word/document.xml').async('string');
      const parser = new DOMParser();
      const xmlDoc = parser.parseFromString(documentXml, 'text/xml');
      questions = await parseQuestionsWithStyle(xmlDoc);
      
      if (!questions.length) {
        statusSpan.textContent = '❌ 未检测到以"第X题"开头的题目,请检查格式';
        return;
      }
      let answerDetectedCount = questions.filter(q => q.correctAnswer).length;
      userAnswers = {};
      questions.forEach((_, idx) => userAnswers[idx] = null);
      
      statusSpan.innerHTML = `✅ 成功加载 ${questions.length} 道题 | 识别答案 ${answerDetectedCount}/${questions.length} 题 `;
      if (answerDetectedCount < questions.length) {
        statusSpan.innerHTML += ` ⚠️ 部分题目无答案,请在对应题后添加"答案 A"行`;
      }
      submitBtn.style.display = 'inline-block';
      submitBtn.disabled = false;
      prevBtn.disabled = false;
      nextBtn.disabled = false;
      examSubmitted = false;
      showQuestion(0);
    } catch (err) {
      console.error(err);
      statusSpan.textContent = '❌ 解析失败: ' + err.message;
    }
  });

  submitBtn.addEventListener('click', submitExam);
  prevBtn.addEventListener('click', () => showQuestion(currentQuestion - 1));
  nextBtn.addEventListener('click', () => showQuestion(currentQuestion + 1));
</script>
</body>
</html>

局限与改进方向

单次考试,无数据持久化:刷新页面或重新选择文件会清空所有答案,适合随堂测验或练习,不适合需要存档的正式考试。

添加倒计时器:可设置考试总时长,时间到自动交卷。

题型支持:目前仅支持单选题,可扩展判断题(对/错)、多选题(复选框),通过不同的选项标记区分(如 #A. 为单选,#[A] 为多选)。

相关推荐
宁雨桥6 小时前
从跨项目预览到分层架构:一次 `postMessage` 封装的深度思考
前端·架构·postmessage
问征夫以前路6 小时前
Promise知识点回顾
前端·javascript
行走的陀螺仪6 小时前
JavaScript 算法详解:10大经典算法,通俗易懂,从入门到精通
开发语言·javascript·算法
拓荒牛儿7 小时前
前端内存可观测实践
前端
yqcoder7 小时前
异步的魔法:深入解析 async/await 原理与编译本质
前端·javascript
iiiiyu7 小时前
面向对象和集合编程题
java·开发语言·前端·数据结构·算法·编程语言
taocarts_bidfans7 小时前
2026跨境SaaS工具选型指南:Taoify与Shopify/Shopyy/Ueeshop深度对比
java·前端·javascript·跨境电商·独立站
环信7 小时前
环信Flutter UIKit适配鸿蒙实战指南
前端
秋秋20237 小时前
做了个 AI 对话页面才发现,流式渲染没想象中那么简单
前端·aigc