Răsfoiți Sursa

feat:增加执药考试做题进度与真题考试记录

yangting 6 zile în urmă
părinte
comite
1116ec7c4b
20 a modificat fișierele cu 2864 adăugiri și 23 ștergeri
  1. 961 0
      pharmacy_dump.sql
  2. 134 0
      yaoshi-records-prototype/api.js
  3. 333 0
      yaoshi-records-prototype/app.js
  4. 89 0
      yaoshi-records-prototype/index.html
  5. 245 0
      yaoshi-records-prototype/mock.js
  6. 570 0
      yaoshi-records-prototype/styles.css
  7. 62 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizPracticeProgressController.java
  8. 21 9
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRealExamController.java
  9. 21 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/PracticeProgressSaveRequest.java
  10. 39 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizPracticeProgress.java
  11. 2 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserExamRecord.java
  12. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizPracticeProgressMapper.java
  13. 25 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/PracticeProgressService.java
  14. 4 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RealExamService.java
  15. 206 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/PracticeProgressServiceImpl.java
  16. 67 12
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java
  17. 3 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamRecordVO.java
  18. 23 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeAnsweredVO.java
  19. 30 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeProgressVO.java
  20. 20 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RecordSummaryVO.java

Fișier diff suprimat deoarece este prea mare
+ 961 - 0
pharmacy_dump.sql


+ 134 - 0
yaoshi-records-prototype/api.js

@@ -0,0 +1,134 @@
+/* ============================================================
+ * api.js · API 桩层(签名即未来真实接口,替换实现即可接后端)
+ * ============================================================ */
+
+const delay = (ms = 300) => new Promise(res => setTimeout(res, ms));
+
+/* 计算每条记录的"第 N 次"(同试卷+同科目按时间正序编号) */
+function withNth(records) {
+  const groups = {};
+  [...records]
+    .sort((a, b) => a.finishedAt.localeCompare(b.finishedAt))
+    .forEach(r => {
+      const key = r.paperId + '|' + r.subject;
+      groups[key] = groups[key] || [];
+      groups[key].push(r);
+    });
+  const nthMap = {};
+  Object.values(groups).forEach(g => {
+    if (g.length > 1) g.forEach((r, i) => { nthMap[r.id] = i + 1; });
+  });
+  return nthMap;
+}
+
+/**
+ * GET /api/papers
+ * → { code, data: Paper[] }   Paper: { id, yearLabel, title, subjectCount }
+ */
+async function fetchPapers() {
+  await delay(200);
+  // TODO: replace with fetch('/api/papers') — keep the shape identical
+  return { code: 0, data: DB.papers };
+}
+
+/**
+ * GET /api/subjects?paperId=
+ * → { code, data: string[] }  按试卷科目数返回科目列表
+ */
+async function fetchSubjects(paperId) {
+  await delay(120);
+  // TODO: replace with fetch(`/api/subjects?paperId=${paperId}`)
+  const p = DB.papers.find(x => x.id === paperId);
+  return { code: 0, data: DB.subjects.slice(0, p ? p.subjectCount : 7) };
+}
+
+/**
+ * GET /api/records
+ * → { code, data: Record[] }  时间倒序;Record 附 stats 派生统计与 nth(第几次)
+ */
+async function fetchRecords() {
+  await delay(350);
+  // TODO: replace with fetch('/api/records') — keep the shape identical
+  const nthMap = withNth(DB.records);
+  const data = [...DB.records]
+    .sort((a, b) => b.finishedAt.localeCompare(a.finishedAt))
+    .map(r => ({ ...r, stats: recordStats(r), nth: nthMap[r.id] || 0 }));
+  return { code: 0, data };
+}
+
+/**
+ * GET /api/records/summary
+ * → { code, data: { count, avgAccuracy, totalDurationSec } }
+ */
+async function fetchRecordSummary() {
+  await delay(250);
+  // TODO: replace with fetch('/api/records/summary')
+  const recs = DB.records;
+  const count = recs.length;
+  const avgAccuracy = count
+    ? recs.reduce((s, r) => s + recordStats(r).accuracy, 0) / count
+    : 0;
+  const totalDurationSec = recs.reduce((s, r) => s + r.durationSec, 0);
+  return { code: 0, data: { count, avgAccuracy, totalDurationSec } };
+}
+
+/**
+ * GET /api/records/:id
+ * → { code, data: Record | null }  含完整 wrongQuestions 错题明细
+ */
+async function fetchRecordDetail(id) {
+  await delay(250);
+  // TODO: replace with fetch(`/api/records/${id}`)
+  const r = DB.records.find(x => x.id === id);
+  if (!r) return { code: 404, data: null };
+  return { code: 0, data: { ...r, stats: recordStats(r) } };
+}
+
+/**
+ * POST /api/records  { paperId, subject }
+ * → { code, data: Record }  交卷并生成做题记录
+ * 原型中模拟交卷:生成一条演示记录写入 DB.records 最前,计分口径与 recordStats 一致(满分 120)
+ */
+async function submitExam(paperId, subject) {
+  await delay(300);
+  // TODO: replace with fetch('/api/records', { method: 'POST', body: ... })
+  const paper = DB.papers.find(p => p.id === paperId);
+  const wrong = 3 + (DB.records.length % 9);                 // 3~11 道错题,演示用
+  const pool = DB.records.flatMap(r => r.wrongQuestions);    // 复用现有 mock 题库,保持单一数据源
+  const fallback = { no: 1, type: '最佳选择题', stem: '(演示题干)', options: ['选项A', '选项B', '选项C', '选项D'], userAnswer: 'B', correctAnswer: 'A', analysis: '演示解析。' };
+  const wrongQuestions = Array.from({ length: wrong }, (_, i) => ({
+    ...(pool.length ? pool[i % pool.length] : fallback),
+    no: (i * 13) % 120 + 1
+  }));
+  const now = new Date();
+  const p2 = n => String(n).padStart(2, '0');
+  const rec = {
+    id: 'r' + now.getTime(),
+    paperId,
+    yearLabel: paper ? paper.yearLabel : '',
+    subject,
+    finishedAt: `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())} ${p2(now.getHours())}:${p2(now.getMinutes())}`,
+    durationSec: 2400 + (DB.records.length % 5) * 300,
+    wrongQuestions
+  };
+  DB.records.unshift(rec);
+  return { code: 0, data: { ...rec, stats: recordStats(rec) } };
+}
+
+/**
+ * DELETE /api/records/:id
+ * → { code, data: null }  删除单条做题记录
+ */
+async function deleteRecord(id) {
+  await delay(150);
+  // TODO: replace with fetch(`/api/records/${id}`, { method: 'DELETE' })
+  const i = DB.records.findIndex(r => r.id === id);
+  if (i >= 0) DB.records.splice(i, 1);
+  return { code: 0, data: null };
+}
+
+// 提交做题反馈 —— 未来替换为 POST /api/records/:id/feedback
+async function submitFeedback(recordId, payload) {
+  await delay(200);
+  return { code: 0, data: { recordId, ...payload } };
+}

+ 333 - 0
yaoshi-records-prototype/app.js

@@ -0,0 +1,333 @@
+/* ============================================================
+ * app.js · 视图渲染 + hash 路由 + 交互(数据全部来自 api.js 桩层)
+ * 路由:#/papers(真题列表) #/records(做题记录) #/detail?id=(成绩详情)
+ * ============================================================ */
+
+const $ = s => document.querySelector(s);
+const $$ = s => document.querySelectorAll(s);
+
+/* ---------- 内联 SVG 图标(Lucide 规范,离线零依赖) ---------- */
+const ICONS = {
+  back: '<polyline points="15 18 9 12 15 6"/>',
+  chevronRight: '<polyline points="9 18 15 12 9 6"/>',
+  chevronDown: '<polyline points="6 9 12 15 18 9"/>',
+  arrowRight: '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
+  play: '<polygon points="6 3 20 12 6 21 6 3"/>',
+  history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><polyline points="12 7 12 12 15 15"/>',
+  clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
+  target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
+  calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
+  checkCircle: '<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="m9 12 2 2 4-4"/>',
+  info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
+  xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
+  inbox: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>'
+};
+const icon = (name, size = 20) =>
+  `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`;
+
+/* ---------- 工具 ---------- */
+const state = { papers: [], drawerPaper: null };
+const pct = x => (x * 100).toFixed(1) + '%';
+const fmtDur = sec => `${Math.floor(sec / 60)}分${String(sec % 60).padStart(2, '0')}秒`;
+const fmtTotal = sec => {
+  const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
+  return h ? `${h}小时${m ? m + '分' : ''}` : `${m}分钟`;
+};
+const fmtShort = dt => dt.slice(5); // "08-22 21:14"
+
+let toastTimer = null;
+function toast(msg) {
+  const el = $('#toast');
+  el.textContent = msg;
+  el.hidden = false;
+  requestAnimationFrame(() => el.classList.add('show'));
+  clearTimeout(toastTimer);
+  toastTimer = setTimeout(() => {
+    el.classList.remove('show');
+    setTimeout(() => { el.hidden = true; }, 240);
+  }, 2200);
+}
+
+/* ---------- 屏 1:真题列表 ---------- */
+async function renderPapers() {
+  const list = $('#paperList');
+  if (!state.papers.length) {
+    const { data } = await fetchPapers();
+    state.papers = data;
+  }
+  list.innerHTML = state.papers.map((p, i) => `
+    <div class="paper-card anim-item" style="--i:${i + 1}">
+      <div class="paper-info">
+        <span class="year-tag">${p.yearLabel}</span>
+        <h2 class="paper-title">${p.title}</h2>
+        <p class="paper-sub">选择 ${p.subjectCount} 科之一进入模考</p>
+      </div>
+      <button class="btn-primary" data-paper="${p.id}">开始做题 ${icon('arrowRight', 16)}</button>
+    </div>`).join('');
+  list.querySelectorAll('[data-paper]').forEach(btn =>
+    btn.addEventListener('click', () => openDrawer(btn.dataset.paper)));
+}
+
+/* ---------- 科目抽屉 ---------- */
+async function openDrawer(paperId) {
+  const p = state.papers.find(x => x.id === paperId);
+  if (!p) return;
+  state.drawerPaper = p;
+  $('#drawerSub').textContent = p.title;
+  $('#subjectList').innerHTML = '<div class="skl" style="height:220px"></div>';
+  showDrawer(true);
+  const { data: subjects } = await fetchSubjects(paperId);
+  if (state.drawerPaper !== p) return; // 抽屉已关闭
+  $('#subjectList').innerHTML = subjects.map((s, i) => `
+    <li class="subject-row anim-item" style="--i:${i}">
+      <span class="subject-no">${i + 1}</span>
+      <span class="subject-name">${s}</span>
+      <button class="btn-primary btn-sm" data-subject="${s}">${icon('play', 13)} 开始</button>
+    </li>`).join('');
+  $('#subjectList').querySelectorAll('[data-subject]').forEach(btn =>
+    btn.addEventListener('click', async () => {
+      const subject = btn.dataset.subject;
+      // 原型演示:点击"开始"即模拟完整交卷流程 —— 生成记录 → 落成绩详情页 → Toast 提示
+      const { data: rec } = await submitExam(paperId, subject);
+      showDrawer(false);
+      location.hash = '#/detail?id=' + rec.id;
+      setTimeout(() => toast('已生成做题记录'), 350);
+    }));
+}
+
+function showDrawer(open) {
+  const mask = $('#drawerMask'), drawer = $('#subjectDrawer');
+  if (open) {
+    mask.hidden = false;
+    drawer.hidden = false;
+    requestAnimationFrame(() => { mask.classList.add('open'); drawer.classList.add('open'); });
+  } else {
+    state.drawerPaper = null;
+    mask.classList.remove('open');
+    drawer.classList.remove('open');
+    setTimeout(() => { mask.hidden = true; drawer.hidden = true; }, 300);
+  }
+}
+
+/* ---------- 屏 2:做题记录 ---------- */
+async function renderRecords() {
+  const summaryEl = $('#recordsSummary'), listEl = $('#recordList');
+  summaryEl.innerHTML = '';
+  listEl.innerHTML = '<div class="skl"></div><div class="skl"></div><div class="skl"></div>';
+  const [{ data: sum }, { data: recs }] = await Promise.all([fetchRecordSummary(), fetchRecords()]);
+
+  summaryEl.innerHTML = `
+    <div class="summary-cell anim-item" style="--i:0">
+      <div class="summary-num">${sum.count}<small>次</small></div>
+      <div class="summary-label">累计做题</div>
+    </div>
+    <div class="summary-cell anim-item" style="--i:1">
+      <div class="summary-num">${fmtTotal(sum.totalDurationSec)}</div>
+      <div class="summary-label">累计用时</div>
+    </div>`;
+
+  if (!recs.length) {
+    listEl.innerHTML = `
+      <div class="empty-state">
+        <div class="empty-icon">${icon('inbox', 34)}</div>
+        <p>还没有做题记录</p>
+        <small>回到真题列表,完成任意科目后自动记录</small>
+      </div>`;
+    return;
+  }
+
+  listEl.innerHTML = recs.map((r, i) => `
+    <div class="rec-swipe anim-item" style="--i:${i + 1}">
+      <button class="rec-delete" type="button" data-del="${r.id}">删除</button>
+      <button class="rec-card" data-rec="${r.id}">
+        <div class="rec-top">
+          <div class="rec-name">
+            <div class="rec-tags">
+              <span class="year-tag">${r.yearLabel}</span>
+              ${r.nth ? `<span class="nth-chip">第 ${r.nth} 次</span>` : ''}
+            </div>
+            <span class="rec-subject">${r.subject}</span>
+          </div>
+          <div class="rec-score"><b>${r.stats.score}</b><small>满分 ${r.stats.total}</small></div>
+        </div>
+        <div class="rec-meta">
+          <span>${icon('clock', 14)} ${fmtDur(r.durationSec)}</span>
+          <span>${icon('calendar', 14)} ${fmtShort(r.finishedAt)}</span>
+          <span class="rec-go">${icon('chevronRight', 16)}</span>
+        </div>
+      </button>
+    </div>`).join('');
+  bindRecordCards(listEl);
+}
+
+/* ---------- 记录卡:点击进详情 / 左滑露出删除键(Pointer Events,鼠标与触摸通用) ---------- */
+const REVEAL_W = 72;
+let openCard = null;      // 当前露出删除键的卡片
+let suppressUntil = 0;    // 滑动结束后短时间抑制点击跳转
+
+function closeOpenCard() {
+  if (openCard) { openCard.style.transform = 'translateX(0)'; openCard = null; }
+}
+
+function bindRecordCards(listEl) {
+  listEl.querySelectorAll('.rec-swipe').forEach(wrap => {
+    const card = wrap.querySelector('.rec-card');
+    let startX = 0, startY = 0, dragging = false, swiping = false;
+
+    card.addEventListener('pointerdown', e => {
+      startX = e.clientX; startY = e.clientY;
+      dragging = true; swiping = false;
+      card.style.transition = 'none';
+    });
+    card.addEventListener('pointermove', e => {
+      if (!dragging) return;
+      const dx = e.clientX - startX, dy = e.clientY - startY;
+      if (!swiping && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) swiping = true;
+      if (swiping) {
+        const base = openCard === card ? -REVEAL_W : 0;
+        const x = Math.max(-REVEAL_W - 12, Math.min(0, base + dx));
+        card.style.transform = `translateX(${x}px)`;
+      }
+    });
+    const end = e => {
+      if (!dragging) return;
+      dragging = false;
+      card.style.transition = '';
+      if (!swiping) return;
+      suppressUntil = Date.now() + 350;
+      const dx = e.clientX - startX;
+      const shouldOpen = (openCard === card ? -REVEAL_W + dx : dx) < -REVEAL_W / 2;
+      if (shouldOpen) {
+        closeOpenCard();
+        card.style.transform = `translateX(-${REVEAL_W}px)`;
+        openCard = card;
+      } else {
+        card.style.transform = 'translateX(0)';
+        if (openCard === card) openCard = null;
+      }
+    };
+    card.addEventListener('pointerup', end);
+    card.addEventListener('pointercancel', end);
+
+    card.addEventListener('click', () => {
+      if (Date.now() < suppressUntil) return;  // 刚滑完,不触发跳转
+      if (openCard) { closeOpenCard(); return; } // 有露出删除键的卡片时,先收起
+      location.hash = '#/detail?id=' + card.dataset.rec;
+    });
+
+    wrap.querySelector('[data-del]').addEventListener('click', () => {
+      closeOpenCard();
+      openConfirm(wrap.querySelector('[data-del]').dataset.del);
+    });
+  });
+}
+
+/* ---------- 删除确认弹窗 ---------- */
+let pendingDeleteId = null;
+function openConfirm(id) { pendingDeleteId = id; $('#modalMask').hidden = false; }
+function closeConfirm() { pendingDeleteId = null; $('#modalMask').hidden = true; }
+
+/* ---------- 屏 3:成绩详情 ---------- */
+async function renderDetail(id) {
+  const { data: r } = await fetchRecordDetail(id);
+  if (!r) {
+    toast('记录不存在或已删除');
+    location.hash = '#/records';
+    return;
+  }
+  const pass = r.stats.score >= 72; // 执业药师 120 分制,72 分及格
+  $('#scorePanelWrap').innerHTML = `
+    <div class="score-panel anim-item" style="--i:0">
+      <div class="score-paper">${r.yearLabel} · ${r.subject} · 交卷于 ${r.finishedAt}</div>
+      <div class="done-pill">本次练习已完成</div>
+      <div class="score-ring"><b>${r.stats.score}</b><span>分(${pass ? '及格' : '不及格'})</span></div>
+      <div class="score-stats">
+        <div class="score-stat"><b>${r.stats.total}</b><span>答题总数</span></div>
+        <div class="score-stat"><b>${r.stats.correct}</b><span>答对题目</span></div>
+        <div class="score-stat"><b>${r.stats.wrong}</b><span>答错题目</span></div>
+      </div>
+    </div>`;
+
+  $('#wrongTitle').innerHTML = `${icon('xCircle', 17)} 错题回顾与专家解析`;
+
+  $('#wrongList').innerHTML = r.wrongQuestions.map((q, i) => `
+    <div class="wq-card anim-item" style="--i:${i + 1}">
+      <button class="wq-head" type="button">
+        <span class="wq-tag">错题 ${i + 1}</span>
+        <span class="wq-collapse">${icon('chevronDown', 16)}</span>
+      </button>
+      <div class="wq-body">
+        <p class="wq-stem">${q.no}. ${q.stem}</p>
+        <div class="wq-opts">
+          ${q.options.map((op, oi) => {
+            const L = 'ABCDE'[oi];
+            let cls = 'opt', mark = '';
+            if (L === q.correctAnswer) { cls += ' correct'; mark = `<span class="opt-icon">${icon('checkCircle', 17)}</span>`; }
+            else if (L === q.userAnswer) { cls += ' wrong'; }
+            return `<div class="${cls}"><span class="opt-letter">${L}</span><span>${op}</span>${mark}</div>`;
+          }).join('')}
+        </div>
+        <div class="wq-ans">
+          <span>你的答案:<b class="ans-u">${q.userAnswer}</b></span>
+          <span>正确答案:<b class="ans-r">${q.correctAnswer}</b></span>
+        </div>
+        <div class="analysis-head">${icon('info', 15)} 考点权威解析:</div>
+        <div class="analysis-body">${q.analysis}</div>
+      </div>
+    </div>`).join('');
+
+  // 点"错题 N"行收起/展开整题
+  $('#wrongList').querySelectorAll('.wq-head').forEach(head =>
+    head.addEventListener('click', () => head.closest('.wq-card').classList.toggle('collapsed')));
+}
+
+/* ---------- hash 路由 ---------- */
+function route() {
+  const m = (location.hash || '#/papers').match(/^#\/(\w+)(?:\?(.*))?$/);
+  let name = m ? m[1] : 'papers';
+  if (!['papers', 'records', 'detail'].includes(name)) name = 'papers';
+  const query = (m && m[2]) || '';
+  $$('.screen').forEach(s => { s.hidden = s.dataset.screen !== name; });
+  window.scrollTo(0, 0);
+  if (name === 'records') renderRecords();
+  if (name === 'detail') renderDetail(new URLSearchParams(query).get('id'));
+}
+
+/* ---------- 初始化 ---------- */
+function init() {
+  // 静态位图标挂载
+  $$('.nav-back').forEach(b => { b.innerHTML = icon('back', 22); });
+  $('#entryIcon').innerHTML = icon('history', 20);
+  $('#entryArrow').innerHTML = icon('chevronRight', 18);
+
+  // 返回:records/detail 回上一屏;papers 屏复刻原界面返回键(原型内仅提示)
+  $$('[data-action="back"]').forEach(b => b.addEventListener('click', () => history.back()));
+  $('[data-action="papers-back"]').addEventListener('click', () =>
+    toast('原型演示:此处返回小程序首页'));
+
+  // 做题记录入口 → 独立记录页
+  $('#recordsEntry').addEventListener('click', () => { location.hash = '#/records'; });
+
+  // 遮罩点击关闭抽屉
+  $('#drawerMask').addEventListener('click', () => showDrawer(false));
+
+  // 详情页底部:再练一次(原型演示)/ 返回首页
+  $('#btnRetry').addEventListener('click', () => toast('原型演示:重新进入本科目答题'));
+  $('#btnBackHome').addEventListener('click', () => { location.hash = '#/papers'; });
+
+  // 删除确认弹窗:取消 / 点遮罩关闭;确认后删除并重刷列表
+  $('#modalCancel').addEventListener('click', closeConfirm);
+  $('#modalMask').addEventListener('click', e => { if (e.target === e.currentTarget) closeConfirm(); });
+  $('#modalConfirm').addEventListener('click', async () => {
+    if (!pendingDeleteId) return;
+    await deleteRecord(pendingDeleteId);
+    closeConfirm();
+    renderRecords();
+    toast('已删除');
+  });
+
+  window.addEventListener('hashchange', route);
+  renderPapers();
+  route();
+}
+init();

+ 89 - 0
yaoshi-records-prototype/index.html

@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>执业药师真题 · 做题记录功能原型</title>
+<link rel="stylesheet" href="styles.css">
+</head>
+<body>
+<div class="app" id="app">
+
+  <!-- ========== 屏 1:往年真题卷列表 ========== -->
+  <section class="screen" data-screen="papers">
+    <header class="nav">
+      <button class="nav-back" data-action="papers-back" aria-label="返回"></button>
+      <h1 class="nav-title">往年真题卷</h1>
+    </header>
+    <main class="screen-body">
+      <!-- 新增功能入口:做题记录 -->
+      <button class="records-entry" id="recordsEntry" type="button">
+        <span class="entry-icon" id="entryIcon"></span>
+        <span class="entry-text">
+          <strong>我的做题记录</strong>
+        </span>
+        <span class="entry-arrow" id="entryArrow"></span>
+      </button>
+      <div class="paper-list" id="paperList"></div>
+    </main>
+  </section>
+
+  <!-- ========== 屏 2:做题记录 ========== -->
+  <section class="screen" data-screen="records" hidden>
+    <header class="nav">
+      <button class="nav-back" data-action="back" aria-label="返回"></button>
+      <h1 class="nav-title">做题记录</h1>
+    </header>
+    <main class="screen-body">
+      <div class="records-summary" id="recordsSummary"></div>
+      <div class="record-list" id="recordList"></div>
+    </main>
+  </section>
+
+  <!-- ========== 屏 3:成绩详情(错题明细) ========== -->
+  <section class="screen" data-screen="detail" hidden>
+    <header class="nav">
+      <button class="nav-back" data-action="back" aria-label="返回"></button>
+      <h1 class="nav-title">答题结果与评估</h1>
+    </header>
+    <main class="screen-body detail-body">
+      <div id="scorePanelWrap"></div>
+      <h2 class="section-title wrong-title" id="wrongTitle"></h2>
+      <div class="wrong-list" id="wrongList"></div>
+    </main>
+    <footer class="detail-actions">
+      <button class="btn-outline" id="btnRetry" type="button">再练一次</button>
+      <button class="btn-primary btn-home" id="btnBackHome" type="button">返回首页</button>
+    </footer>
+  </section>
+
+  <!-- ========== 科目选择抽屉 ========== -->
+  <div class="drawer-mask" id="drawerMask" hidden></div>
+  <div class="drawer" id="subjectDrawer" hidden>
+    <div class="drawer-grab"></div>
+    <h2 class="drawer-title">请选择考试科目</h2>
+    <p class="drawer-sub" id="drawerSub"></p>
+    <ol class="subject-list" id="subjectList"></ol>
+  </div>
+
+  <!-- Toast -->
+  <!-- 删除确认弹窗 -->
+  <div class="modal-mask" id="modalMask" hidden>
+    <div class="modal">
+      <p class="modal-title">删除这条做题记录?</p>
+      <p class="modal-desc">删除后不可恢复</p>
+      <div class="modal-btns">
+        <button class="modal-btn" id="modalCancel" type="button">取消</button>
+        <button class="modal-btn modal-btn-danger" id="modalConfirm" type="button">删除</button>
+      </div>
+    </div>
+  </div>
+
+  <div class="toast" id="toast" hidden></div>
+</div>
+
+<script src="mock.js"></script>
+<script src="api.js"></script>
+<script src="app.js"></script>
+</body>
+</html>

+ 245 - 0
yaoshi-records-prototype/mock.js

@@ -0,0 +1,245 @@
+/* ============================================================
+ * mock.js · 单一数据源(接后端时整体替换为接口返回即可)
+ * 派生统计规则:total=120,wrong=wrongQuestions.length,
+ * correct=total-wrong,score=correct,accuracy=correct/total
+ * ============================================================ */
+
+// 错题快捷构造:题号/题型/题干/选项(A-E)/我的答案/正确答案/解析
+const Q = (no, type, stem, options, userAnswer, correctAnswer, analysis) =>
+  ({ no, type, stem, options, userAnswer, correctAnswer, analysis });
+
+const DB = {
+  /* 7 个考试科目(抽屉顺序与线上保持一致) */
+  subjects: [
+    '药事管理与法规',
+    '中药学专业知识(一)',
+    '中药学专业知识(二)',
+    '中药学综合知识与技能',
+    '药学专业知识(一)',
+    '药学专业知识(二)',
+    '药学综合知识与技能'
+  ],
+
+  /* 真题卷(2015-2025,2022 年分两批) */
+  papers: [
+    { id: 'p2025',  yearLabel: '2025年真题',   title: '2025年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2024',  yearLabel: '2024年真题',   title: '2024年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2023',  yearLabel: '2023年真题',   title: '2023年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2022b', yearLabel: '2022(2)年真题', title: '2022(2)年执业药师全国统考真题全卷', subjectCount: 6 },
+    { id: 'p2022a', yearLabel: '2022(1)年真题', title: '2022(1)年执业药师全国统考真题全卷', subjectCount: 7 },
+    { id: 'p2021',  yearLabel: '2021年真题',   title: '2021年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2020',  yearLabel: '2020年真题',   title: '2020年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2019',  yearLabel: '2019年真题',   title: '2019年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2018',  yearLabel: '2018年真题',   title: '2018年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2017',  yearLabel: '2017年真题',   title: '2017年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2016',  yearLabel: '2016年真题',   title: '2016年执业药师全国统考真题全卷',   subjectCount: 7 },
+    { id: 'p2015',  yearLabel: '2015年真题',   title: '2015年执业药师全国统考真题全卷',   subjectCount: 7 }
+  ],
+
+  /* 做题记录:保留每一次刷题(同年份+同科目可出现多条),时间倒序由 API 层排序 */
+  records: [
+    {
+      id: 'r08', paperId: 'p2025', yearLabel: '2025年真题', subject: '药事管理与法规',
+      durationSec: 4104, finishedAt: '2026-08-22 21:14',
+      wrongQuestions: [
+        Q(7, '最佳选择题', '药品生产、经营企业和医疗机构发现新的、严重的药品不良反应,应当在多长时间内报告',
+          ['立即报告', '15日内报告', '30日内报告', '60日内报告', '每季度汇总报告'],
+          'C', 'B', '新的、严重的药品不良反应应当在15日内报告;其中死亡病例须立即报告。'),
+        Q(21, '最佳选择题', '医疗机构麻醉药品处方至少应当保存',
+          ['1年', '2年', '3年', '4年', '5年'],
+          'E', 'C', '麻醉药品和第一类精神药品处方保存3年;医疗用毒性药品、第二类精神药品处方保存2年。'),
+        Q(31, '最佳选择题', '对已确认发生严重不良反应的药品,药品监督管理部门可以采取的紧急控制措施是',
+          ['责令修改说明书', '暂停生产、销售和使用', '直接注销药品批准文号', '罚款后允许继续销售', '限制广告发布'],
+          'A', 'B', '对已确认发生严重不良反应的药品,由药监部门采取暂停生产、销售、使用的紧急控制措施,并组织鉴定。'),
+        Q(39, '最佳选择题', '《药品经营许可证》的有效期为',
+          ['3年', '4年', '5年', '6年', '7年'],
+          'A', 'C', '《药品经营许可证》有效期为5年;有效期届满需继续经营的,应在届满前6个月申请换发。')
+      ]
+    },
+    {
+      id: 'r07', paperId: 'p2025', yearLabel: '2025年真题', subject: '药学综合知识与技能',
+      durationSec: 5530, finishedAt: '2026-08-16 16:40',
+      wrongQuestions: [
+        Q(9, '最佳选择题', '服用华法林期间,会明显减弱其抗凝作用的是',
+          ['西柚汁', '富含维生素K的绿叶蔬菜', '阿司匹林', '头孢菌素类', '甲硝唑'],
+          'A', 'B', '维生素K可拮抗华法林的抗凝作用;西柚汁、阿司匹林、甲硝唑等会增强抗凝作用,增加出血风险。'),
+        Q(16, '最佳选择题', '他汀类调血脂药的最佳服药时间是',
+          ['清晨空腹', '早餐后', '午餐后', '睡前', '随餐服用'],
+          'A', 'D', '胆固醇合成高峰在夜间,短效他汀睡前服用疗效最佳。'),
+        Q(24, '最佳选择题', '老年人使用地高辛,其有效治疗血药浓度范围一般为',
+          ['0.5~2.0 ng/ml', '2~4 ng/ml', '5~10 ng/ml', '10~20 ng/ml', '20~30 ng/ml'],
+          'B', 'A', '地高辛治疗窗窄,有效血药浓度为0.5~2.0ng/ml,超过2.0ng/ml易发生中毒。'),
+        Q(32, '最佳选择题', '支气管哮喘急性发作期首选的缓解药物是',
+          ['布地奈德吸入剂', '沙丁胺醇吸入剂', '孟鲁司特钠片', '氨茶碱缓释片', '异丙托溴铵雾化液'],
+          'D', 'B', '短效β2受体激动剂(沙丁胺醇)吸入给药是哮喘急性发作的首选缓解药物。'),
+        Q(40, '最佳选择题', '口服铁剂治疗缺铁性贫血时,同服会妨碍铁吸收的是',
+          ['维生素C', '稀盐酸', '浓茶', '果汁', '维生素B12'],
+          'A', 'C', '茶中的鞣酸与铁结合形成沉淀而妨碍吸收;维生素C和酸性环境可促进铁吸收。'),
+        Q(43, '最佳选择题', '克拉霉素与辛伐他汀合用可导致',
+          ['降压作用增强', '肌病风险增加', '血糖异常升高', '严重心律失常', '出血倾向增加'],
+          'E', 'B', '克拉霉素抑制CYP3A4,使他汀类血药浓度升高,增加肌病甚至横纹肌溶解风险。')
+      ]
+    },
+    {
+      id: 'r06', paperId: 'p2024', yearLabel: '2024年真题', subject: '中药学专业知识(一)',
+      durationSec: 5147, finishedAt: '2026-08-02 20:05',
+      wrongQuestions: [
+        Q(6, '最佳选择题', '黄连中的主要有效成分是',
+          ['麻黄碱', '小檗碱', '苦参碱', '士的宁', '阿托品'],
+          'A', 'B', '黄连主含小檗碱(黄连素),属异喹啉类生物碱,具抗菌、清热燥湿作用。'),
+        Q(14, '最佳选择题', '大黄泻下作用最强的成分是',
+          ['游离蒽醌', '结合型蒽醌', '鞣质', '大黄酸', '大黄素甲醚'],
+          'A', 'B', '大黄泻下有效成分为结合型蒽醌苷(如番泻苷),游离蒽醌几乎无泻下作用,故大黄久煎后泻下力减弱。'),
+        Q(22, '最佳选择题', '何首乌断面特征性的鉴别特征是',
+          ['菊花心', '车轮纹', '云锦花纹', '朱砂点', '星点'],
+          'E', 'C', '何首乌断面皮部有4~11个类圆形异型维管束环列,形成"云锦花纹";"星点"是大黄的鉴别特征。'),
+        Q(30, '最佳选择题', '"四大怀药"不包括',
+          ['地黄', '牛膝', '山药', '菊花', '金银花'],
+          'D', 'E', '四大怀药指河南道地药材怀地黄、怀牛膝、怀山药、怀菊花;金银花不在其列。'),
+        Q(37, '最佳选择题', '酒炙黄连的主要炮制目的是',
+          ['增强止呕作用', '引药上行,缓和苦寒之性', '增强泻火解毒作用', '便于粉碎制剂', '降低药物毒性'],
+          'A', 'B', '酒炙可引药上行,善清上焦火热,并缓和黄连苦寒之性;姜炙黄连则长于止呕。')
+      ]
+    },
+    {
+      id: 'r05', paperId: 'p2025', yearLabel: '2025年真题', subject: '药事管理与法规',
+      durationSec: 4265, finishedAt: '2026-07-25 22:31',
+      wrongQuestions: [
+        Q(5, '最佳选择题', '为实现疫苗全程可追溯,疫苗上市许可持有人应当建立的制度是',
+          ['质量回顾制度', '电子追溯制度', '不良反应补偿制度', '批签发管理制度', '储备调拨制度'],
+          'D', 'B', '《疫苗管理法》规定,疫苗上市许可持有人应当建立疫苗电子追溯系统,实现疫苗全程可追溯。'),
+        Q(12, '最佳选择题', '关于处方药广告发布,下列说法正确的是',
+          ['可在大众传播媒介发布', '只能在指定的医学、药学专业刊物上发布', '经省级药监部门批准可在电视发布', '任何媒介均不得发布', '可在短视频平台发布'],
+          'A', 'B', '处方药只能在国务院卫生行政部门和药品监督管理部门共同指定的医学、药学专业刊物上作广告。'),
+        Q(19, '最佳选择题', '药品经营企业发现已售出药品有严重质量问题,应当立即采取的措施是',
+          ['立即就地销毁', '通知购货单位停售、追回并做好记录', '等待监管部门处理', '降价促销处理', '自行封存下架'],
+          'E', 'B', '发现已售出药品有严重质量问题,应当立即通知购货单位停售、追回并做好记录,同时向药监部门报告。'),
+        Q(26, '最佳选择题', '中药一级保护品种的保护期限分别为',
+          ['30年、20年、10年', '20年、15年、10年', '30年、15年、7年', '10年、7年、5年', '均为20年'],
+          'C', 'A', '中药一级保护品种保护期限分别为30年、20年、10年;二级保护品种为7年。'),
+        Q(33, '最佳选择题', '生产、销售劣药的,并处违法生产、销售药品货值金额的罚款倍数为',
+          ['十倍以上二十倍以下', '一倍以上三倍以下', '十五倍以上三十倍以下', '二倍以上五倍以下', '五倍以上十倍以下'],
+          'C', 'A', '生产、销售劣药处货值金额10~20倍罚款;生产、销售假药处货值金额15~30倍罚款。'),
+        Q(38, '最佳选择题', '国家基本药物的遴选原则是',
+          ['防治必需、安全有效、价格合理、使用方便、中西药并重', '临床必需、价格昂贵、进口优先', '全部为化学药品', '全部为独家品种', '全部为处方药'],
+          'E', 'A', '遴选原则为:防治必需、安全有效、价格合理、使用方便、中西药并重、基本保障、临床首选和基层能够配备。'),
+        Q(42, '最佳选择题', '根据《药品网络销售监督管理办法》,不得通过网络销售的药品是',
+          ['非处方药', '中药饮片', '第二类精神药品', '保健食品', '第一类医疗器械'],
+          'A', 'C', '疫苗、血液制品、麻醉药品、精神药品、医疗用毒性药品、放射性药品等不得通过网络销售。')
+      ]
+    },
+    {
+      id: 'r04', paperId: 'p2023', yearLabel: '2023年真题', subject: '药学专业知识(二)',
+      durationSec: 5793, finishedAt: '2026-07-13 19:58',
+      wrongQuestions: [
+        Q(4, '最佳选择题', '青霉素过敏性休克的首选抢救药物是',
+          ['地塞米松', '肾上腺素', '异丙嗪', '多巴胺', '去甲肾上腺素'],
+          'A', 'B', '肾上腺素是过敏性休克的首选药,应立即皮下或肌内注射0.1%肾上腺素注射液。'),
+        Q(13, '最佳选择题', '伴有肥胖的2型糖尿病患者首选的口服降糖药是',
+          ['格列本脲', '二甲双胍', '阿卡波糖', '瑞格列奈', '吡格列酮'],
+          'C', 'B', '二甲双胍不增加体重且可改善胰岛素抵抗,是肥胖或超重的2型糖尿病患者首选。'),
+        Q(25, '最佳选择题', '卡托普利最常见的不良反应是',
+          ['低血钾', '干咳', '心动过缓', '牙龈增生', '踝关节水肿'],
+          'D', 'B', 'ACEI类药物抑制缓激肽降解,刺激性干咳发生率较高,不能耐受者可换用ARB类。'),
+        Q(34, '最佳选择题', '阿司匹林抗血小板聚集的作用机制是',
+          ['抑制磷酸二酯酶', '阻断ADP受体', '不可逆抑制环氧酶,减少TXA2生成', '拮抗维生素K', '激活纤溶酶原'],
+          'B', 'C', '阿司匹林不可逆抑制血小板环氧酶COX-1,减少血栓素A2(TXA2)生成,从而抑制血小板聚集。'),
+        Q(41, '最佳选择题', '奥美拉唑治疗消化性溃疡,适宜的服用时间是',
+          ['餐后立即服用', '睡前服用', '餐前半小时服用', '与食物同服', '腹痛发作时服用'],
+          'B', 'C', '质子泵抑制剂宜餐前半小时服用,进食刺激胃酸分泌时可达到最佳抑酸效果。')
+      ]
+    },
+    {
+      id: 'r03', paperId: 'p2023', yearLabel: '2023年真题', subject: '中药学综合知识与技能',
+      durationSec: 5309, finishedAt: '2026-06-29 21:47',
+      wrongQuestions: [
+        Q(2, '最佳选择题', '五行相生的正确次序是',
+          ['木生火、火生土、土生金、金生水、水生木', '木生土、土生金、金生火、火生水、水生木', '木生水、水生火、火生金、金生土、土生木', '木生金、金生火、火生土、土生水、水生木', '火生木、木生土、土生水、水生金、金生火'],
+          'B', 'A', '五行相生次序:木生火、火生土、土生金、金生水、水生木。'),
+        Q(10, '最佳选择题', '肝的生理功能是',
+          ['主血脉', '主疏泄与藏血', '主运化', '主纳气', '主行水'],
+          'A', 'B', '肝主疏泄、主藏血;心主血脉,脾主运化,肾主纳气。'),
+        Q(18, '最佳选择题', '患者神疲乏力、少气懒言、自汗,舌淡脉虚,辨证为',
+          ['血虚证', '气虚证', '阴虚证', '阳虚证', '气滞证'],
+          'D', 'B', '神疲乏力、少气懒言、自汗、舌淡脉虚是气虚证的典型表现;阳虚兼有畏寒肢冷。'),
+        Q(27, '最佳选择题', '肾阴虚证宜选用的中成药是',
+          ['金匮肾气丸', '六味地黄丸', '附子理中丸', '四神丸', '济生肾气丸'],
+          'A', 'B', '六味地黄丸滋补肾阴,主治肾阴亏损证;金匮肾气丸、济生肾气丸偏于温补肾阳。'),
+        Q(33, '最佳选择题', '"十八反"中,与乌头相反的药物是',
+          ['甘草', '人参', '半夏', '细辛', '白芍'],
+          'A', 'C', '"半蒌贝蔹及攻乌":乌头反半夏、瓜蒌、贝母、白蔹、白及;甘草反甘遂、大戟、芫花、海藻。'),
+        Q(36, '最佳选择题', '风寒感冒宜选用的中成药是',
+          ['银翘解毒片', '荆防颗粒', '桑菊感冒片', '双黄连口服液', '藿香正气水'],
+          'A', 'B', '风寒感冒治宜辛温解表,选用荆防颗粒;银翘解毒片、桑菊感冒片、双黄连口服液用于风热感冒。'),
+        Q(40, '最佳选择题', '淡白舌多主',
+          ['实热证', '气血两虚、阳虚寒证', '阴虚火旺', '湿热内蕴', '瘀血内阻'],
+          'C', 'B', '淡白舌主气血两虚、阳虚寒证;红舌主热证,绛舌主热入营血,紫舌主瘀血。')
+      ]
+    },
+    {
+      id: 'r02', paperId: 'p2022a', yearLabel: '2022(1)年真题', subject: '药学专业知识(一)',
+      durationSec: 6072, finishedAt: '2026-06-14 20:22',
+      wrongQuestions: [
+        Q(5, '最佳选择题', '某药的消除速度常数k=0.231 h⁻¹,其半衰期约为',
+          ['1小时', '2小时', '3小时', '4小时', '5小时'],
+          'B', 'C', 't₁/₂=0.693/k=0.693÷0.231=3小时。'),
+        Q(12, '最佳选择题', '硝酸甘油治疗心绞痛急性发作宜采用的给药途径是',
+          ['口服', '舌下含服', '皮下注射', '肌内注射', '直肠给药'],
+          'A', 'B', '硝酸甘油口服首过效应明显、生物利用度低;舌下含服可避免首过效应,1~2分钟起效。'),
+        Q(20, '最佳选择题', '关于生物利用度的叙述,错误的是',
+          ['反映药物被吸收进入体循环的速度和程度', '绝对生物利用度以静脉注射剂为参比制剂', '是评价生物等效性的重要指标', '与制剂的处方和工艺无关', '受首过效应影响'],
+          'E', 'D', '生物利用度与制剂处方、制备工艺密切相关,是评价制剂质量的重要指标。'),
+        Q(28, '最佳选择题', '《中国药典》规定普通压制片的崩解时限为',
+          ['5分钟', '15分钟', '30分钟', '45分钟', '60分钟'],
+          'C', 'B', '普通片剂崩解时限为15分钟,薄膜衣片为30分钟,糖衣片为60分钟。'),
+        Q(35, '最佳选择题', '缓释、控释制剂的特点不包括',
+          ['减少给药次数', '血药浓度平稳', '减少毒副作用', '可随时停药,灵活调整剂量', '提高患者顺应性'],
+          'A', 'D', '缓控释制剂剂量调节灵活性差,一般不能掰开、嚼碎服用,也不能随时停药。'),
+        Q(39, '最佳选择题', '表观分布容积(Vd)的药动学意义是',
+          ['反映药物在体内分布广泛程度的特征参数', '药物在体内的真实体液容积', '反映药物消除快慢的指标', '反映药物起效快慢的指标', '反映药物吸收程度的指标'],
+          'B', 'A', '表观分布容积是反映药物在体内分布广泛程度的特征参数,不代表真实的生理容积。')
+      ]
+    },
+    {
+      id: 'r01', paperId: 'p2022a', yearLabel: '2022(1)年真题', subject: '药事管理与法规',
+      durationSec: 4731, finishedAt: '2026-05-30 15:36',
+      wrongQuestions: [
+        Q(3, '最佳选择题', '根据《药品管理法》,下列属于假药的是',
+          ['药品成份的含量不符合国家药品标准', '药品所含成份与国家药品标准规定的成份不符', '超过有效期的药品', '擅自添加防腐剂的药品', '未注明产品批号的药品'],
+          'A', 'B', '所含成份与国家药品标准规定的成份不符的为假药;含量不符合标准、超过有效期等属于劣药。'),
+        Q(8, '最佳选择题', '麻醉药品和第一类精神药品处方的印刷用纸颜色为',
+          ['白色', '淡黄色', '淡绿色', '淡红色', '淡蓝色'],
+          'B', 'D', '麻醉药品和第一类精神药品处方为淡红色;急诊处方为淡黄色,儿科处方为淡绿色,普通处方为白色。'),
+        Q(11, '最佳选择题', '关于医疗机构配制的制剂,下列说法正确的是',
+          ['可在市场上销售', '经批准可在市场上销售', '不得在市场上销售', '可在连锁药店销售', '凭处方可在社会药店调剂'],
+          'B', 'C', '医疗机构制剂凭医师处方在本医疗机构使用,不得在市场上销售或变相销售。'),
+        Q(17, '最佳选择题', '急诊处方一般不得超过',
+          ['1日用量', '3日用量', '5日用量', '7日用量', '15日用量'],
+          'D', 'B', '处方一般不得超过7日用量,急诊处方一般不得超过3日用量。'),
+        Q(23, '最佳选择题', '执业药师注册有效期为',
+          ['2年', '3年', '4年', '5年', '6年'],
+          'B', 'D', '执业药师注册有效期为5年;有效期届满需继续执业的,应在届满前30日内办理延续注册。'),
+        Q(29, '最佳选择题', '按GSP要求,药品批发企业储存药品的库房相对湿度应保持在',
+          ['35%~75%', '40%~80%', '45%~75%', '30%~70%', '50%~80%'],
+          'C', 'A', 'GSP规定储存药品的相对湿度为35%~75%。'),
+        Q(35, '最佳选择题', '医疗用毒性药品每次处方剂量不得超过',
+          ['1日极量', '2日极量', '3日极量', '5日极量', '7日极量'],
+          'C', 'B', '医疗用毒性药品每次处方剂量不得超过2日极量。'),
+        Q(41, '最佳选择题', '下列不得发布广告的药品是',
+          ['非处方药', '处方药', '麻醉药品', '乙类非处方药', '中成药'],
+          'B', 'C', '麻醉药品、精神药品、医疗用毒性药品、放射性药品等依法不得发布广告。'),
+        Q(44, '最佳选择题', '关于国家一级保护野生药材物种,下列说法正确的是',
+          ['可以限量采猎', '凭采猎证采猎', '禁止采猎', '经备案后采猎', '仅限制出口'],
+          'B', 'C', '一级保护野生药材物种禁止采猎、禁止出口;二、三级保护物种须凭采药证采猎。')
+      ]
+    }
+  ]
+};
+
+/* 派生统计:由错题数反推得分与正确率,保证全站数据一致 */
+function recordStats(r) {
+  const total = 120;
+  const wrong = r.wrongQuestions.length;
+  const correct = total - wrong;
+  return { total, wrong, correct, score: correct, accuracy: correct / total };
+}

+ 570 - 0
yaoshi-records-prototype/styles.css

@@ -0,0 +1,570 @@
+/* ============================================================
+   Design Contract tokens · 执业药师真题「做题记录」原型
+   清爽医疗教育风 · 蓝色渐变主导 · 白圆角卡片
+   ============================================================ */
+:root {
+  --primary: #2E6BE6;
+  --primary-2: #4A8DFF;
+  --grad: linear-gradient(135deg, #4A8DFF, #2E6BE6);
+  --primary-light: #EAF2FE;
+  --bg: #F5F7FA;
+  --surface: #FFFFFF;
+  --border: #EDF0F5;
+  --text: #1F2933;
+  --text-sub: #8A94A6;
+  --success: #22B07D;
+  --success-bg: #E8F7F1;
+  --danger: #E65A5A;
+  --danger-bg: #FCECEC;
+  --warning: #F2A93B;
+  --warning-bg: #FDF3E3;
+  --shadow-card: 0 2px 12px rgba(30, 60, 120, .06);
+  --shadow-drawer: 0 -8px 32px rgba(30, 60, 120, .16);
+  --font: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei",
+          "Noto Sans CJK SC", "WenQuanYi Micro Hei", sans-serif;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+html { font-size: 16px; }
+body {
+  font-family: var(--font);
+  color: var(--text);
+  background:
+    radial-gradient(1200px 600px at 20% -10%, #DCE7FA 0%, transparent 55%),
+    radial-gradient(900px 500px at 90% 110%, #D4E0F7 0%, transparent 50%),
+    #E9EEF6;
+  min-height: 100dvh;
+  display: flex;
+  justify-content: center;
+}
+button { font-family: var(--font); border: none; background: none; cursor: pointer; }
+
+/* ---------- 手机容器 ---------- */
+.app {
+  position: relative;
+  width: 100%;
+  max-width: 430px;
+  min-height: 100dvh;
+  background: var(--bg);
+  overflow-x: hidden;
+  box-shadow: 0 0 40px rgba(30, 60, 120, .12);
+}
+
+/* ---------- 导航栏(三屏同构) ---------- */
+.nav {
+  position: sticky;
+  top: 0;
+  z-index: 20;
+  height: 52px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(245, 247, 250, .88);
+  backdrop-filter: blur(10px);
+  border-bottom: 1px solid rgba(237, 240, 245, .8);
+}
+.nav-title { font-size: 17px; font-weight: 600; letter-spacing: .02em; }
+.nav-back {
+  position: absolute;
+  left: 8px;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 36px;
+  height: 36px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  color: var(--text);
+  transition: background .15s;
+}
+.nav-back:active { background: rgba(46, 107, 230, .08); }
+.nav-back svg { width: 22px; height: 22px; }
+
+.screen { display: flex; flex-direction: column; min-height: 100dvh; }
+.screen[hidden] { display: none; }
+.screen-body {
+  flex: 1;
+  padding: 16px 16px calc(24px + env(safe-area-inset-bottom));
+}
+
+/* ---------- 入场动画 ---------- */
+@keyframes fadeUp {
+  from { opacity: 0; transform: translateY(14px); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+.anim-item { animation: fadeUp .45s ease both; animation-delay: calc(var(--i, 0) * 60ms); }
+
+/* ---------- 做题记录入口条 ---------- */
+.records-entry {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px 16px;
+  margin-bottom: 16px;
+  border-radius: 16px;
+  background: linear-gradient(135deg, #EAF2FE 0%, #F4F8FF 100%);
+  border: 1px solid rgba(74, 141, 255, .18);
+  box-shadow: var(--shadow-card);
+  text-align: left;
+  transition: transform .15s, box-shadow .15s;
+}
+.records-entry:active { transform: scale(.985); box-shadow: 0 1px 6px rgba(30,60,120,.08); }
+.entry-icon {
+  flex: none;
+  width: 40px;
+  height: 40px;
+  border-radius: 12px;
+  background: var(--grad);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  box-shadow: 0 4px 10px rgba(46, 107, 230, .28);
+}
+.entry-icon svg { width: 20px; height: 20px; }
+.entry-text { flex: 1; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 2px; }
+.entry-text strong { font-size: 15px; font-weight: 600; }
+.entry-text small { font-size: 12px; color: var(--primary); font-weight: 500; }
+.entry-arrow { color: var(--primary); display: flex; }
+.entry-arrow svg { width: 18px; height: 18px; }
+
+/* ---------- 真题卡片 ---------- */
+.paper-list { display: flex; flex-direction: column; gap: 14px; }
+.paper-card {
+  background: var(--surface);
+  border-radius: 16px;
+  box-shadow: var(--shadow-card);
+  padding: 18px 20px;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+.paper-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
+.year-tag {
+  align-self: flex-start;
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--primary);
+  background: var(--primary-light);
+  border-radius: 8px;
+  padding: 3px 8px;
+  line-height: 1.4;
+}
+.paper-title { font-size: 15px; font-weight: 700; line-height: 1.45; }
+.paper-sub { font-size: 12px; color: var(--text-sub); }
+
+/* 渐变胶囊按钮 */
+.btn-primary {
+  flex: none;
+  height: 40px;
+  padding: 0 18px;
+  border-radius: 999px;
+  background: var(--grad);
+  color: #fff;
+  font-size: 15px;
+  font-weight: 600;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  box-shadow: 0 4px 12px rgba(46, 107, 230, .3);
+  transition: filter .15s, transform .15s;
+  white-space: nowrap;
+}
+.btn-primary:active { filter: brightness(.94); transform: scale(.97); }
+.btn-primary svg { width: 16px; height: 16px; }
+.btn-primary.btn-sm { height: 34px; padding: 0 14px; font-size: 13px; gap: 4px; }
+
+/* ---------- 底部抽屉 ---------- */
+.drawer-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 40;
+  background: rgba(15, 23, 42, .45);
+  opacity: 0;
+  transition: opacity .3s;
+}
+.drawer-mask.open { opacity: 1; }
+.drawer {
+  position: fixed;
+  left: 50%;
+  bottom: 0;
+  transform: translate(-50%, 100%);
+  z-index: 41;
+  width: 100%;
+  max-width: 430px;
+  background: var(--surface);
+  border-radius: 20px 20px 0 0;
+  box-shadow: var(--shadow-drawer);
+  padding: 10px 20px calc(20px + env(safe-area-inset-bottom));
+  transition: transform .3s ease-out;
+  max-height: 78dvh;
+  overflow-y: auto;
+}
+.drawer.open { transform: translate(-50%, 0); }
+.drawer-grab {
+  width: 36px; height: 4px; border-radius: 2px;
+  background: #E2E8F0; margin: 4px auto 14px;
+}
+.drawer-title { font-size: 17px; font-weight: 700; }
+.drawer-sub { font-size: 12px; color: var(--text-sub); margin: 6px 0 14px; }
+.subject-list { list-style: none; display: flex; flex-direction: column; }
+.subject-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 0;
+  border-bottom: 1px solid var(--border);
+}
+.subject-row:last-child { border-bottom: none; }
+.subject-no {
+  flex: none;
+  width: 24px; height: 24px;
+  border-radius: 50%;
+  background: var(--primary-light);
+  color: var(--primary);
+  font-size: 12px;
+  font-weight: 700;
+  display: flex; align-items: center; justify-content: center;
+}
+.subject-name { flex: 1; font-size: 15px; font-weight: 500; }
+
+/* ---------- 记录页汇总 ---------- */
+.records-summary {
+  display: flex;
+  background: var(--surface);
+  border-radius: 16px;
+  box-shadow: var(--shadow-card);
+  padding: 16px 0;
+  margin-bottom: 16px;
+}
+.summary-cell { flex: 1; text-align: center; position: relative; }
+.summary-cell + .summary-cell::before {
+  content: "";
+  position: absolute;
+  left: 0; top: 20%;
+  height: 60%; width: 1px;
+  background: var(--border);
+}
+.summary-num { font-size: 20px; font-weight: 700; color: var(--primary); }
+.summary-num small { font-size: 11px; font-weight: 500; color: var(--text-sub); margin-left: 2px; }
+.summary-label { font-size: 11px; color: var(--text-sub); margin-top: 4px; }
+
+/* ---------- 记录卡片 ---------- */
+.record-list { display: flex; flex-direction: column; gap: 12px; }
+.rec-card {
+  width: 100%;
+  background: var(--surface);
+  border-radius: 16px;
+  box-shadow: var(--shadow-card);
+  padding: 16px 18px;
+  text-align: left;
+  transition: transform .15s;
+}
+.rec-card:active { transform: scale(.985); }
+.rec-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
+.rec-name { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
+.rec-tags { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.nth-chip {
+  font-size: 10px;
+  font-weight: 600;
+  color: var(--warning);
+  background: var(--warning-bg);
+  border-radius: 6px;
+  padding: 2px 6px;
+}
+.rec-subject { font-size: 15px; font-weight: 700; }
+.rec-score { flex: none; text-align: right; line-height: 1.1; }
+.rec-score b { font-size: 26px; font-weight: 700; }
+.rec-score small { font-size: 11px; color: var(--text-sub); display: block; margin-top: 2px; }
+.rec-meta {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  margin-top: 12px;
+  padding-top: 10px;
+  border-top: 1px solid var(--border);
+  font-size: 12px;
+  color: var(--text-sub);
+}
+.rec-meta span { display: inline-flex; align-items: center; gap: 4px; }
+.rec-meta svg { width: 14px; height: 14px; }
+.rec-go { margin-left: auto; color: #C3CBD8; display: flex; }
+.rec-go svg { width: 16px; height: 16px; }
+
+/* ---------- 骨架屏 ---------- */
+@keyframes shimmer { from { background-position: -200px 0; } to { background-position: 200px 0; } }
+.skl {
+  border-radius: 16px;
+  height: 108px;
+  background: linear-gradient(90deg, #EDF1F7 25%, #F7F9FC 50%, #EDF1F7 75%);
+  background-size: 400px 100%;
+  animation: shimmer 1.2s infinite linear;
+}
+
+/* ---------- 空态 ---------- */
+.empty-state {
+  padding: 64px 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12px;
+  color: var(--text-sub);
+}
+.empty-icon {
+  width: 72px; height: 72px;
+  border-radius: 50%;
+  background: var(--primary-light);
+  color: var(--primary);
+  display: flex; align-items: center; justify-content: center;
+}
+.empty-icon svg { width: 34px; height: 34px; }
+.empty-state p { font-size: 14px; font-weight: 600; color: var(--text); }
+.empty-state small { font-size: 12px; }
+
+/* ---------- 成绩面板(按参考图:完成胶囊 + 黄圈分数 + 三列统计) ---------- */
+.score-panel {
+  background: linear-gradient(160deg, #35ABFF 0%, #1C87F2 100%);
+  border-radius: 20px;
+  padding: 18px 20px 22px;
+  color: #fff;
+  box-shadow: 0 8px 24px rgba(28, 135, 242, .32);
+  position: relative;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.score-panel::after {
+  content: "";
+  position: absolute;
+  right: -40px; top: -40px;
+  width: 160px; height: 160px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, .1);
+}
+.score-paper { font-size: 11px; opacity: .85; position: relative; z-index: 1; }
+.done-pill {
+  position: relative;
+  z-index: 1;
+  font-size: 11px;
+  font-weight: 500;
+  background: rgba(255, 255, 255, .22);
+  border-radius: 999px;
+  padding: 5px 14px;
+  margin-top: 6px;
+}
+.score-ring {
+  position: relative;
+  z-index: 1;
+  width: 108px;
+  height: 108px;
+  border-radius: 50%;
+  border: 3px solid #FFD84D;
+  box-shadow: 0 0 0 4px rgba(255, 216, 77, .18);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 2px;
+  margin: 18px 0 20px;
+}
+.score-ring b { font-size: 34px; font-weight: 700; line-height: 1; }
+.score-ring span { font-size: 11px; opacity: .92; }
+.score-stats {
+  display: flex;
+  width: 100%;
+  position: relative;
+  z-index: 1;
+}
+.score-stat { flex: 1; text-align: center; }
+.score-stat b { font-size: 20px; font-weight: 700; display: block; }
+.score-stat span { font-size: 11px; opacity: .85; }
+.score-time { margin-top: 12px; font-size: 11px; opacity: .75; display: flex; align-items: center; gap: 4px; position: relative; z-index: 1; }
+.score-time svg { width: 13px; height: 13px; }
+
+.section-title { font-size: 15px; font-weight: 700; margin: 20px 2px 12px; display: flex; align-items: center; gap: 8px; }
+.section-title small { font-size: 12px; color: var(--text-sub); font-weight: 500; }
+.wrong-title svg { width: 17px; height: 17px; color: var(--danger); }
+
+/* ---------- 错题卡片(按参考图:错题N可收起 + 字母圆圈选项 + 权威解析) ---------- */
+.wrong-list { display: flex; flex-direction: column; gap: 12px; }
+.wq-card { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-card); padding: 14px 16px 16px; }
+.wq-head {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 2px 0;
+}
+.wq-tag { font-size: 13px; font-weight: 700; color: var(--danger); }
+.wq-collapse { margin-left: auto; color: #C3CBD8; display: flex; }
+.wq-collapse svg { width: 16px; height: 16px; transition: transform .25s; transform: rotate(180deg); }
+.wq-card.collapsed .wq-collapse svg { transform: rotate(0); }
+.wq-card.collapsed .wq-body { display: none; }
+.wq-stem { font-size: 14px; line-height: 1.65; margin: 10px 0 12px; }
+.wq-opts { display: flex; flex-direction: column; gap: 8px; }
+.opt {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 12px;
+  border-radius: 10px;
+  font-size: 13px;
+  line-height: 1.5;
+  background: #F7F9FC;
+  color: var(--text);
+}
+.opt-letter {
+  flex: none;
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  border: 1.5px solid #C9D2E0;
+  background: #fff;
+  color: #8A94A6;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  font-weight: 700;
+}
+.opt-icon { flex: none; margin-left: auto; display: flex; align-items: center; }
+.opt-icon svg { width: 17px; height: 17px; }
+.opt.correct { background: #E7F8F0; color: #1D7A4F; }
+.opt.correct .opt-letter { background: #2BB673; border-color: #2BB673; color: #fff; }
+.opt.correct .opt-icon { color: #2BB673; }
+.opt.wrong { background: #FCEBEA; color: #C13B3B; }
+.opt.wrong .opt-letter { background: #E65A5A; border-color: #E65A5A; color: #fff; }
+.wq-ans {
+  display: flex;
+  gap: 18px;
+  margin-top: 12px;
+  font-size: 13px;
+  color: var(--text-sub);
+}
+.wq-ans b { font-weight: 700; }
+.wq-ans .ans-u { color: var(--danger); }
+.wq-ans .ans-r { color: var(--success); }
+.analysis-head {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 14px;
+  color: var(--primary);
+  font-size: 13px;
+  font-weight: 700;
+}
+.analysis-head svg { width: 15px; height: 15px; }
+.analysis-body {
+  margin-top: 8px;
+  font-size: 13px;
+  line-height: 1.8;
+  color: #4B5563;
+}
+
+/* ---------- 详情页底部操作栏 ---------- */
+.detail-body { padding-bottom: calc(88px + env(safe-area-inset-bottom)); }
+.detail-actions {
+  position: fixed;
+  bottom: 0;
+  left: 50%;
+  transform: translateX(-50%);
+  z-index: 30;
+  width: 100%;
+  max-width: 430px;
+  display: flex;
+  gap: 12px;
+  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
+  background: var(--surface);
+  border-top: 1px solid var(--border);
+}
+.btn-outline {
+  flex: 1;
+  height: 44px;
+  border-radius: 999px;
+  border: 1.5px solid var(--primary);
+  background: #fff;
+  color: var(--primary);
+  font-size: 15px;
+  font-weight: 600;
+  transition: filter .15s, transform .15s;
+}
+.btn-outline:active { filter: brightness(.96); transform: scale(.98); }
+.btn-home { flex: 1; height: 44px; justify-content: center; }
+
+/* ---------- Toast ---------- */
+.toast {
+  position: fixed;
+  top: 72px;
+  left: 50%;
+  transform: translateX(-50%) translateY(-8px);
+  z-index: 60;
+  max-width: 340px;
+  background: rgba(31, 41, 51, .92);
+  color: #fff;
+  font-size: 13px;
+  padding: 10px 18px;
+  border-radius: 999px;
+  opacity: 0;
+  transition: opacity .22s, transform .22s;
+  pointer-events: none;
+  text-align: center;
+}
+.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+
+/* ---------- 记录卡左滑删除 ---------- */
+.rec-swipe { position: relative; overflow: hidden; border-radius: 16px; }
+.rec-delete {
+  position: absolute;
+  top: 0; right: 0; bottom: 0;
+  width: 72px;
+  background: var(--danger);
+  color: #fff;
+  font-size: 13px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.rec-swipe .rec-card {
+  position: relative;
+  z-index: 1;
+  width: 100%;
+  transition: transform .22s ease;
+  touch-action: pan-y;
+}
+
+/* ---------- 确认弹窗 ---------- */
+.modal-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 50;
+  background: rgba(23, 32, 50, .45);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.modal-mask[hidden] { display: none; }
+.modal {
+  width: 270px;
+  background: var(--surface);
+  border-radius: 14px;
+  padding: 24px 20px 0;
+  text-align: center;
+  animation: modalPop .18s ease;
+}
+@keyframes modalPop { from { transform: scale(.92); opacity: 0; } }
+.modal-title { font-size: 16px; font-weight: 700; }
+.modal-desc { font-size: 13px; color: var(--text-sub); margin-top: 8px; }
+.modal-btns { display: flex; margin-top: 22px; border-top: 1px solid var(--border); }
+.modal-btn { flex: 1; height: 48px; font-size: 16px; color: var(--text); }
+.modal-btn + .modal-btn { border-left: 1px solid var(--border); }
+.modal-btn:active { background: #F5F7FA; }
+.modal-btn-danger { color: var(--danger); font-weight: 600; }
+
+@media (max-width: 480px) {
+  body { background: var(--bg); }
+  .app { box-shadow: none; }
+}

+ 62 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizPracticeProgressController.java

@@ -0,0 +1,62 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.PracticeProgressSaveRequest;
+import com.xuekairui.quiz.service.PracticeProgressService;
+import com.xuekairui.quiz.vo.PracticeAnsweredVO;
+import com.xuekairui.quiz.vo.PracticeProgressVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/quiz/practiceProgress")
+@RequiredArgsConstructor
+public class UserQuizPracticeProgressController {
+
+    private final PracticeProgressService practiceProgressService;
+
+    /** 保存练习进度(按用户+节覆盖保存) */
+    @PostMapping("/save")
+    public Result<PracticeProgressVO> save(@RequestBody PracticeProgressSaveRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(practiceProgressService.save(userId, param));
+    }
+
+    /** 查询单个节的进度 */
+    @GetMapping("/get")
+    public Result<PracticeProgressVO> get(@RequestParam Long sectionId, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(practiceProgressService.get(userId, sectionId));
+    }
+
+    /** 批量查询多个节的进度(章节列表页进度标识用) */
+    @GetMapping("/listBySectionIds")
+    public Result<List<PracticeProgressVO>> listBySectionIds(@RequestParam String sectionIds, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        List<Long> ids = new java.util.ArrayList<>();
+        for (String s : sectionIds.split(",")) {
+            String t = s.trim();
+            if (!t.isEmpty()) ids.add(Long.valueOf(t));
+        }
+        return Result.success(practiceProgressService.listBySectionIds(userId, ids));
+    }
+
+    /** 清除单个节的进度 */
+    @PostMapping("/clear")
+    public Result<Void> clear(@RequestBody Map<String, Long> param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        practiceProgressService.clear(userId, param.get("sectionId"));
+        return Result.success();
+    }
+
+    /** 查询节下已提交过答案的题目及对错(恢复进度回填用) */
+    @GetMapping("/listAnswered")
+    public Result<List<PracticeAnsweredVO>> listAnswered(@RequestParam Long sectionId, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(practiceProgressService.listAnswered(userId, sectionId));
+    }
+}

+ 21 - 9
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRealExamController.java

@@ -8,8 +8,8 @@ import com.xuekairui.quiz.service.RealExamService;
 import com.xuekairui.quiz.vo.ExamDetailVO;
 import com.xuekairui.quiz.vo.ExamRecordVO;
 import com.xuekairui.quiz.vo.ExamYearVO;
-import com.xuekairui.quiz.vo.PageVO;
 import com.xuekairui.quiz.vo.RealExamYearGroupVO;
+import com.xuekairui.quiz.vo.RecordSummaryVO;
 import com.xuekairui.quiz.vo.StartExamVO;
 import com.xuekairui.quiz.vo.SubmitExamVO;
 import jakarta.servlet.http.HttpServletRequest;
@@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
+import java.util.Map;
 
 @RestController
 @RequestMapping("/api/quiz/realExam")
@@ -25,7 +26,7 @@ public class UserQuizRealExamController {
 
     private final RealExamService realExamService;
 
-    /** 真题年份+科目列表(按年份分组) */
+    /** 真题年份 + 科目列表(按年份分组) */
     @GetMapping("/list")
     public Result<List<RealExamYearGroupVO>> list(
             @RequestParam(required = false) Long subjectId,
@@ -63,15 +64,26 @@ public class UserQuizRealExamController {
         return Result.success(realExamService.submitExam(userId, param.getRecordId(), param.getAnswers(), usedTime));
     }
 
-    /** 考试记录 */
+    /** 做题记录列表(全部,按交卷时间倒序) */
     @GetMapping("/records")
-    public Result<PageVO<ExamRecordVO>> records(
-            @RequestParam(required = false) Long subjectId,
-            @RequestParam(defaultValue = "1") int pageNo,
-            @RequestParam(defaultValue = "10") int pageSize,
-            HttpServletRequest request) {
+    public Result<List<ExamRecordVO>> records(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(realExamService.getExamHistory(userId));
+    }
+
+    /** 做题记录汇总(累计次数 + 累计用时) */
+    @GetMapping("/summary")
+    public Result<RecordSummaryVO> summary(HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(realExamService.getExamHistory(userId, subjectId, pageNo, pageSize));
+        return Result.success(realExamService.getRecordSummary(userId));
+    }
+
+    /** 删除做题记录(软删除) */
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody Map<String, Long> param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        realExamService.deleteRecord(userId, param.get("recordId"));
+        return Result.success();
     }
 
     /** 考试详情 */

+ 21 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/PracticeProgressSaveRequest.java

@@ -0,0 +1,21 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 保存练习进度入参
+ */
+@Data
+public class PracticeProgressSaveRequest {
+    private Long sectionId;
+    private String sectionName;
+    private Long subjectId;
+    /** 当前题号(0 基下标) */
+    private Integer currentIndex;
+    /** 已答题数 */
+    private Integer answeredCount;
+    /** 已选答案:{题目下标: "A"} */
+    private Map<String, String> answers;
+}

+ 39 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizPracticeProgress.java

@@ -0,0 +1,39 @@
+package com.xuekairui.quiz.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+/**
+ * 练习进度(章节练习断点续答)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName(value = "t_quiz_practice_progress", autoResultMap = true)
+public class QuizPracticeProgress {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private Long userId;
+    private Long subjectId;
+    /** 节ID(章节列表"开始学习"传入的章节节点ID) */
+    private Long sectionId;
+    private String sectionName;
+    /** 当前题号(0 基下标) */
+    @Builder.Default
+    private Integer currentIndex = 0;
+    /** 已答题数 */
+    @Builder.Default
+    private Integer answeredCount = 0;
+    /** 已选答案 JSON:{题目下标: "A"} */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private Map<String, String> answers;
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 2 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserExamRecord.java

@@ -30,6 +30,8 @@ public class QuizUserExamRecord {
     private List<Map<String, Object>> answers;
     @Builder.Default
     private Integer status = 0;
+    @Builder.Default
+    private Integer isDeleted = 0;
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;
     @TableField(fill = FieldFill.INSERT_UPDATE)

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizPracticeProgressMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizPracticeProgress;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizPracticeProgressMapper extends BaseMapper<QuizPracticeProgress> {
+}

+ 25 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/PracticeProgressService.java

@@ -0,0 +1,25 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.dto.PracticeProgressSaveRequest;
+import com.xuekairui.quiz.vo.PracticeAnsweredVO;
+import com.xuekairui.quiz.vo.PracticeProgressVO;
+
+import java.util.List;
+
+public interface PracticeProgressService {
+
+    /** 保存练习进度(按 userId+sectionId 覆盖) */
+    PracticeProgressVO save(Long userId, PracticeProgressSaveRequest param);
+
+    /** 查询单个节的进度 */
+    PracticeProgressVO get(Long userId, Long sectionId);
+
+    /** 批量查询多个节的进度(章节列表页进度标识用) */
+    List<PracticeProgressVO> listBySectionIds(Long userId, List<Long> sectionIds);
+
+    /** 清除单个节的进度 */
+    void clear(Long userId, Long sectionId);
+
+    /** 查询节下已提交过答案的题目及对错(恢复进度回填用) */
+    List<PracticeAnsweredVO> listAnswered(Long userId, Long sectionId);
+}

+ 4 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RealExamService.java

@@ -4,8 +4,8 @@ import com.xuekairui.quiz.dto.AnswerItem;
 import com.xuekairui.quiz.vo.ExamDetailVO;
 import com.xuekairui.quiz.vo.ExamRecordVO;
 import com.xuekairui.quiz.vo.ExamYearVO;
-import com.xuekairui.quiz.vo.PageVO;
 import com.xuekairui.quiz.vo.RealExamYearGroupVO;
+import com.xuekairui.quiz.vo.RecordSummaryVO;
 import com.xuekairui.quiz.vo.StartExamVO;
 import com.xuekairui.quiz.vo.SubmitExamVO;
 
@@ -17,6 +17,8 @@ public interface RealExamService {
     StartExamVO startExam(Long userId, String year, Long subjectId);
     void saveProgress(Long userId, Long recordId, List<AnswerItem> answers);
     SubmitExamVO submitExam(Long userId, Long recordId, List<AnswerItem> answers, Integer usedTime);
-    PageVO<ExamRecordVO> getExamHistory(Long userId, Long subjectId, int pageNo, int pageSize);
+    List<ExamRecordVO> getExamHistory(Long userId);
     ExamDetailVO getExamDetail(Long userId, Long recordId);
+    RecordSummaryVO getRecordSummary(Long userId);
+    void deleteRecord(Long userId, Long recordId);
 }

+ 206 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/PracticeProgressServiceImpl.java

@@ -0,0 +1,206 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.dto.PracticeProgressSaveRequest;
+import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.entity.QuizPracticeProgress;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizUserRecord;
+import com.xuekairui.quiz.mapper.QuizChapterMapper;
+import com.xuekairui.quiz.mapper.QuizPracticeProgressMapper;
+import com.xuekairui.quiz.mapper.QuizQuestionMapper;
+import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
+import com.xuekairui.quiz.service.PracticeProgressService;
+import com.xuekairui.quiz.vo.PracticeAnsweredVO;
+import com.xuekairui.quiz.vo.PracticeProgressVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class PracticeProgressServiceImpl implements PracticeProgressService {
+
+    private final QuizPracticeProgressMapper progressMapper;
+    private final QuizChapterMapper chapterMapper;
+    private final QuizQuestionMapper questionMapper;
+    private final QuizUserRecordMapper userRecordMapper;
+
+    @Override
+    @Transactional
+    public PracticeProgressVO save(Long userId, PracticeProgressSaveRequest param) {
+        if (param.getSectionId() == null) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "sectionId不能为空");
+        }
+        QuizPracticeProgress existing = selectOne(userId, param.getSectionId());
+        if (existing == null) {
+            QuizPracticeProgress progress = QuizPracticeProgress.builder()
+                    .userId(userId)
+                    .subjectId(param.getSubjectId())
+                    .sectionId(param.getSectionId())
+                    .sectionName(param.getSectionName())
+                    .currentIndex(param.getCurrentIndex() != null ? param.getCurrentIndex() : 0)
+                    .answeredCount(param.getAnsweredCount() != null ? param.getAnsweredCount() : 0)
+                    .answers(param.getAnswers())
+                    .build();
+            progressMapper.insert(progress);
+        } else {
+            existing.setSubjectId(param.getSubjectId());
+            existing.setSectionName(param.getSectionName());
+            existing.setCurrentIndex(param.getCurrentIndex() != null ? param.getCurrentIndex() : 0);
+            existing.setAnsweredCount(param.getAnsweredCount() != null ? param.getAnsweredCount() : 0);
+            existing.setAnswers(param.getAnswers());
+            progressMapper.updateById(existing);
+        }
+        return get(userId, param.getSectionId());
+    }
+
+    @Override
+    public PracticeProgressVO get(Long userId, Long sectionId) {
+        if (userId == null || sectionId == null) return null;
+        return toVO(selectOne(userId, sectionId));
+    }
+
+    @Override
+    public List<PracticeProgressVO> listBySectionIds(Long userId, List<Long> sectionIds) {
+        if (userId == null || sectionIds == null || sectionIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        List<QuizPracticeProgress> list = progressMapper.selectList(
+                new LambdaQueryWrapper<QuizPracticeProgress>()
+                        .eq(QuizPracticeProgress::getUserId, userId)
+                        .in(QuizPracticeProgress::getSectionId, sectionIds)
+        );
+        List<PracticeProgressVO> result = new ArrayList<>();
+        for (QuizPracticeProgress p : list) {
+            PracticeProgressVO vo = toVO(p);
+            if (vo != null) result.add(vo);
+        }
+        return result;
+    }
+
+    @Override
+    @Transactional
+    public void clear(Long userId, Long sectionId) {
+        if (userId == null || sectionId == null) return;
+        QuizPracticeProgress existing = selectOne(userId, sectionId);
+        if (existing != null) {
+            progressMapper.deleteById(existing.getId());
+        }
+    }
+
+    @Override
+    public List<PracticeAnsweredVO> listAnswered(Long userId, Long sectionId) {
+        if (userId == null || sectionId == null) return new ArrayList<>();
+        // 收集节下所有考点ID,查出该节全部题目(与 listByChapter 顺序一致:按 id 升序)
+        List<Long> examPointIds = collectExamPointIds(sectionId);
+        if (examPointIds.isEmpty()) return new ArrayList<>();
+
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .in(QuizQuestion::getExamPointId, examPointIds)
+                        .eq(QuizQuestion::getIsReal, 0)
+                        .orderByAsc(QuizQuestion::getId)
+        );
+        if (questions.isEmpty()) return new ArrayList<>();
+
+        // 查询用户近期做题记录,按题目归并最近一次作答
+        Map<Long, PracticeAnsweredVO> answeredMap = new LinkedHashMap<>();
+        List<QuizUserRecord> records = userRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .ne(QuizUserRecord::getAnswerType, 99)
+                        .orderByDesc(QuizUserRecord::getCreateTime)
+                        .last("LIMIT 500")
+        );
+        for (QuizUserRecord r : records) {
+            if (r.getAnswers() == null) continue;
+            for (Map<String, Object> ans : r.getAnswers()) {
+                Object qid = ans.get("questionId");
+                if (qid == null) continue;
+                Long questionId = Long.valueOf(qid.toString());
+                if (answeredMap.containsKey(questionId)) continue; // 已有更近的作答
+                String userAnswer = ans.get("userAnswer") != null ? ans.get("userAnswer").toString() : null;
+                if (userAnswer == null || userAnswer.isEmpty()) continue;
+                answeredMap.put(questionId, PracticeAnsweredVO.builder()
+                        .questionId(questionId)
+                        .userAnswer(userAnswer)
+                        .isCorrect(ans.get("isCorrect") != null ? Integer.valueOf(ans.get("isCorrect").toString()) : null)
+                        .build());
+            }
+        }
+
+        // 按题目顺序输出,并补齐正确答案与解析
+        List<PracticeAnsweredVO> result = new ArrayList<>();
+        for (QuizQuestion q : questions) {
+            PracticeAnsweredVO vo = answeredMap.get(q.getId());
+            if (vo == null) continue;
+            vo.setCorrectAnswer(q.getAnswer());
+            vo.setAnalysis(q.getAnalysis());
+            result.add(vo);
+        }
+        return result;
+    }
+
+    private QuizPracticeProgress selectOne(Long userId, Long sectionId) {
+        return progressMapper.selectOne(
+                new LambdaQueryWrapper<QuizPracticeProgress>()
+                        .eq(QuizPracticeProgress::getUserId, userId)
+                        .eq(QuizPracticeProgress::getSectionId, sectionId)
+                        .last("LIMIT 1")
+        );
+    }
+
+    private PracticeProgressVO toVO(QuizPracticeProgress p) {
+        if (p == null) return null;
+        return PracticeProgressVO.builder()
+                .sectionId(p.getSectionId())
+                .sectionName(p.getSectionName())
+                .subjectId(p.getSubjectId())
+                .currentIndex(p.getCurrentIndex())
+                .answeredCount(p.getAnsweredCount())
+                .answers(p.getAnswers())
+                .updateTime(p.getUpdateTime())
+                .build();
+    }
+
+    /**
+     * 收集节点(章/节/考点)下所有考点ID(与 UserQuestionServiceImpl 逻辑一致)
+     */
+    private List<Long> collectExamPointIds(Long sectionId) {
+        List<Long> result = new ArrayList<>();
+        if (sectionId == null) return result;
+        QuizChapter chapter = chapterMapper.selectById(sectionId);
+        if (chapter == null) return result;
+        if (chapter.getLevel() != null && chapter.getLevel() == 4) {
+            result.add(sectionId);
+            return result;
+        }
+        collectDescendantExamPointIds(chapter.getSubjectId(), sectionId, result);
+        return result;
+    }
+
+    private void collectDescendantExamPointIds(Long subjectId, Long parentId, List<Long> result) {
+        List<QuizChapter> children = chapterMapper.selectList(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .eq(QuizChapter::getParentId, parentId)
+        );
+        for (QuizChapter child : children) {
+            if (child.getLevel() != null && child.getLevel() == 4) {
+                result.add(child.getId());
+            } else {
+                collectDescendantExamPointIds(subjectId, child.getId(), result);
+            }
+        }
+    }
+}

+ 67 - 12
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java

@@ -19,6 +19,7 @@ import com.xuekairui.quiz.vo.ExamYearVO;
 import com.xuekairui.quiz.vo.PageVO;
 import com.xuekairui.quiz.vo.RealExamQuestionVO;
 import com.xuekairui.quiz.vo.RealExamYearGroupVO;
+import com.xuekairui.quiz.vo.RecordSummaryVO;
 import com.xuekairui.quiz.vo.StartExamVO;
 import com.xuekairui.quiz.vo.SubmitExamVO;
 import lombok.RequiredArgsConstructor;
@@ -29,6 +30,7 @@ import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -339,22 +341,42 @@ public class RealExamServiceImpl implements RealExamService {
     }
 
     @Override
-    public PageVO<ExamRecordVO> getExamHistory(Long userId, Long subjectId, int pageNo, int pageSize) {
-        LambdaQueryWrapper<QuizUserExamRecord> wrapper = new LambdaQueryWrapper<QuizUserExamRecord>()
-                .eq(QuizUserExamRecord::getUserId, userId)
-                .eq(subjectId != null, QuizUserExamRecord::getSubjectId, subjectId)
-                .eq(QuizUserExamRecord::getStatus, 1)
-                .orderByDesc(QuizUserExamRecord::getCreateTime);
+    public List<ExamRecordVO> getExamHistory(Long userId) {
+        // 查询全部已交卷且未删除的记录,按提交时间倒序
+        List<QuizUserExamRecord> records = examRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserExamRecord>()
+                        .eq(QuizUserExamRecord::getUserId, userId)
+                        .eq(QuizUserExamRecord::getStatus, 1)
+                        .eq(QuizUserExamRecord::getIsDeleted, 0)
+                        .orderByDesc(QuizUserExamRecord::getSubmitTime)
+        );
 
-        long total = examRecordMapper.selectCount(wrapper);
-        wrapper.last("LIMIT " + (pageNo - 1) * pageSize + "," + pageSize);
-        List<QuizUserExamRecord> records = examRecordMapper.selectList(wrapper);
+        // 计算 nth:同 year+subject 按提交时间正序编号,仅多次时返回 nth
+        Map<String, List<QuizUserExamRecord>> groups = records.stream()
+                .collect(Collectors.groupingBy(r -> r.getYear() + "_" + r.getSubjectId()));
+        Map<Long, Integer> nthMap = new HashMap<>();
+        groups.values().forEach(group -> {
+            group.sort(Comparator.comparing(QuizUserExamRecord::getSubmitTime));
+            if (group.size() > 1) {
+                for (int i = 0; i < group.size(); i++) {
+                    nthMap.put(group.get(i).getId(), i + 1);
+                }
+            }
+        });
 
+        // 构建 VO
         List<ExamRecordVO> list = new ArrayList<>();
         for (QuizUserExamRecord r : records) {
+            QuizSubject subject = subjectMapper.selectById(r.getSubjectId());
+            String subjectName = subject != null ? subject.getName() : "";
+            String yearLabel = r.getYear() + "年真题";
+            Integer nth = nthMap.get(r.getId());
+
             list.add(ExamRecordVO.builder()
                     .recordId(r.getId())
                     .year(r.getYear())
+                    .yearLabel(yearLabel)
+                    .subjectName(subjectName)
                     .totalScore(r.getTotalScore())
                     .correctCount(r.getCorrectCount())
                     .wrongCount(r.getWrongCount())
@@ -362,15 +384,48 @@ public class RealExamServiceImpl implements RealExamService {
                     .usedTime(r.getUsedTime())
                     .startTime(r.getStartTime())
                     .submitTime(r.getSubmitTime())
+                    .nth(nth)
                     .build());
         }
 
-        return PageVO.<ExamRecordVO>builder()
-                .total(total)
-                .list(list)
+        return list;
+    }
+
+    @Override
+    public RecordSummaryVO getRecordSummary(Long userId) {
+        List<QuizUserExamRecord> records = examRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserExamRecord>()
+                        .eq(QuizUserExamRecord::getUserId, userId)
+                        .eq(QuizUserExamRecord::getStatus, 1)
+                        .eq(QuizUserExamRecord::getIsDeleted, 0)
+        );
+
+        int count = records.size();
+        int totalDurationSec = records.stream()
+                .mapToInt(r -> r.getUsedTime() != null ? r.getUsedTime() : 0)
+                .sum();
+
+        return RecordSummaryVO.builder()
+                .count(count)
+                .totalDurationSec(totalDurationSec)
                 .build();
     }
 
+    @Override
+    @Transactional
+    public void deleteRecord(Long userId, Long recordId) {
+        QuizUserExamRecord record = examRecordMapper.selectById(recordId);
+        if (record == null) {
+            throw new BusinessException(ErrorCode.QUIZ_RECORD_NOT_FOUND);
+        }
+        if (!record.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.FORBIDDEN);
+        }
+        // 软删除
+        record.setIsDeleted(1);
+        examRecordMapper.updateById(record);
+    }
+
     @Override
     public ExamDetailVO getExamDetail(Long userId, Long recordId) {
         QuizUserExamRecord record = examRecordMapper.selectById(recordId);

+ 3 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamRecordVO.java

@@ -20,6 +20,8 @@ import java.time.LocalDateTime;
 public class ExamRecordVO {
     private Long recordId;
     private String year;
+    private String yearLabel;    // 年份标签(如 "2025年真题")
+    private String subjectName;  // 科目名
     private BigDecimal totalScore;
     private Integer correctCount;
     private Integer wrongCount;
@@ -27,4 +29,5 @@ public class ExamRecordVO {
     private Integer usedTime;
     private LocalDateTime startTime;
     private LocalDateTime submitTime;
+    private Integer nth;         // 第 N 次(同 year+subject 多次刷题时返回)
 }

+ 23 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeAnsweredVO.java

@@ -0,0 +1,23 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 恢复进度时已提交过答案的题目作答结果(用于回填对错展示)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PracticeAnsweredVO {
+    private Long questionId;
+    private String userAnswer;
+    private Integer isCorrect;
+    private String correctAnswer;
+    private String analysis;
+}

+ 30 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeProgressVO.java

@@ -0,0 +1,30 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+/**
+ * 练习进度
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PracticeProgressVO {
+    private Long sectionId;
+    private String sectionName;
+    private Long subjectId;
+    /** 当前题号(0 基下标) */
+    private Integer currentIndex;
+    private Integer answeredCount;
+    /** 已选答案:{题目下标: "A"} */
+    private Map<String, String> answers;
+    private LocalDateTime updateTime;
+}

+ 20 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RecordSummaryVO.java

@@ -0,0 +1,20 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 做题记录汇总
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class RecordSummaryVO {
+    private Integer count;           // 累计做题次数
+    private Integer totalDurationSec; // 累计用时(秒)
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff