Browse Source

feat: 考试流程跳转

huangziyang 1 week ago
parent
commit
f5008c035e

+ 29 - 0
App.vue

@@ -33,4 +33,33 @@ button {
     border: 0;
   }
 }
+
+.button {
+  background-color: $primary;
+  font-weight: 500;
+  font-size: 32rpx;
+  color: #fff;
+  margin: 0;
+  width: 100%;
+  border-radius: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 0;
+  &::after {
+    border: 0;
+  }
+}
+
+.tip {
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 24rpx;
+  color: #999999;
+}
+
+.button.plain {
+  background: $uni-primary-light;
+  color: $primary;
+}
 </style>

+ 7 - 5
components/Container/Container.vue

@@ -136,7 +136,6 @@ watchEffect(() => {
 });
 
 const onScroll = debounce((e) => {
-  console.log(e);
   emit("onScroll", e);
 }, 100);
 
@@ -146,7 +145,7 @@ defineExpose({
 </script>
 
 <template>
-  <view class="container">
+  <view class="container" :style="{ background: bgColor }">
     <view
       class="title"
       :style="{
@@ -165,17 +164,17 @@ defineExpose({
       />
     </view>
     <scroll-view
-      scroll-with-animation
       enable-flex
       enable-passive
       enhanced
       paging-enabled
-      scroll-anchoring
       show-scrollbar
       enable-back-to-top
+      scroll-with-animation
+      scroll-anchoring
+      :scroll-x="scrollX"
       :scroll-y="scrollY"
       :scroll-top="scrollTop"
-      :scroll-x="scrollX"
       :scroll-into-view="scrollIntoView"
       @scroll="onScroll"
       v-if="!empty"
@@ -240,6 +239,9 @@ defineExpose({
   position: relative;
   padding: 24rpx 24rpx 0;
   box-sizing: content-box;
+  display: flex;
+  flex-direction: column;
+  gap: 24rpx;
 }
 
 .bottom-text {

+ 32 - 9
components/Modal/Modal.vue

@@ -28,11 +28,34 @@ const props = defineProps({
     type: Boolean,
     default: true,
   },
+  onClose: {
+    type: Function,
+    default: () => {},
+  },
+  onSubmit: {
+    type: Function,
+    default: () => {},
+  },
+  submitter: {
+    type: Object,
+    default: () => ({}),
+  },
 });
-const emit = defineEmits(["change", "close", "submit"]);
+const emit = defineEmits(["update:open"]);
 
 const onChange = (e) => {
-  emit("change", e);
+  emit("update:open", e.show);
+};
+
+const close = () => {
+  emit("update:open", false);
+};
+
+const c = async (fn) => {
+  const res = await fn();
+  if (res) {
+    close();
+  }
 };
 
 watchEffect(() => {
@@ -70,19 +93,19 @@ defineExpose({
     >
       <view class="title">
         <slot name="title">{{ title }}</slot>
-        <uni-icons
-          type="closeempty"
-          class="icons"
-          @click="popup?.close"
-        ></uni-icons>
+        <uni-icons type="closeempty" class="icons" @click="close"></uni-icons>
       </view>
       <view class="content">
         <slot></slot>
       </view>
       <view v-if="footer" class="footer">
         <slot name="footer">
-          <button class="plain" @click="emit('close')">取消</button>
-          <button class="primary" @click="emit('submit')">确认</button>
+          <button class="plain" @click="c(onClose)">
+            {{ submitter.closeText || "取消" }}
+          </button>
+          <button class="primary" @click="c(onSubmit)">
+            {{ submitter.text || "确定" }}
+          </button>
         </slot>
       </view>
     </view>

+ 58 - 50
components/Topic/Questions.vue

@@ -2,27 +2,49 @@
   <view
     class="question-item"
     :class="{
-      'question-item-correct': mode === 'practice' && isSelected && isCorrect,
-      'question-item-wrong': mode === 'practice' && isSelected && !isCorrect,
-      'question-item-selected': mode === 'exam' && isSelected
+      'question-item-selected':
+        question.checked && !props.showResult && styleCount !== 6,
+      'question-item-correct':
+        question.checked && props.showResult && question.isRight && styleCount !== 6,
+      'question-item-wrong':
+        question.checked && props.showResult && !question.isRight && styleCount !== 6,
+    }"
+    :style="{
+      border: styleCount !== 6 ? '1px solid #ccc' : 0,
+      padding: styleCount !== 6 ? '28rpx;' : 0,
     }"
     @tap="handleClick"
   >
     <view
       class="option-label"
       :class="{
-        'option-label-correct': mode === 'practice' && isSelected && isCorrect,
-        'option-label-wrong': mode === 'practice' && isSelected && !isCorrect,
-        'option-label-selected': mode === 'exam' && isSelected
+        'option-label-selected': question.checked && !props.showResult,
+        'option-label-correct':
+          question.checked && props.showResult && question.isRight,
+        'option-label-wrong':
+          question.checked && props.showResult && !question.isRight,
       }"
     >
-      {{ TopicMapList[index] }}
+      {{ question.value }}
     </view>
-    <view class="option-text">{{ question.label }}</view>
-    <view class="option-icon" v-if="isSelected && !isCorrect">
+    <uvParse
+      :content="replaceImageDimensions(question.label)"
+      class="option-text"
+    ></uvParse>
+    <view
+      class="option-icon"
+      v-if="
+        question.checked && props.showResult && !question.isRight && styleCount !== 6
+      "
+    >
       <uni-icons type="closeempty" color="#f00"></uni-icons>
     </view>
-    <view class="option-icon" v-if="isSelected && isCorrect">
+    <view
+      class="option-icon"
+      v-if="
+        question.checked && props.showResult && question.isRight && styleCount !== 6
+      "
+    >
       <uni-icons type="checkmarkempty" color="#00be00"></uni-icons>
     </view>
   </view>
@@ -30,19 +52,13 @@
 
 <script setup>
 import { computed } from "vue";
-
-const TopicMapList = ["A", "B", "C", "D", "E"];
-
+import uvParse from "../../uni_modules/uv-parse/components/uv-parse/uv-parse.vue";
 // Props 定义
 const props = defineProps({
   answerList: {
     type: Array,
     default: () => [],
   },
-  index: {
-    type: Number,
-    required: true,
-  },
   selectCount: {
     type: Array,
     default: () => [],
@@ -53,48 +69,42 @@ const props = defineProps({
     default: () => ({
       label: "",
       value: 0,
+      checked: false,
     }),
   },
   parindex: {
     type: Number,
     required: true,
   },
-  mode: {
-    type: String,
-    default: "exam", // practice: 练习模式, exam: 考试模式
+  showResult: {
+    type: Boolean,
+    default: false,
   },
+  styleCount: Number,
 });
 
 // Emits 定义
-const emit = defineEmits(["select", "showAnswer"]);
-
-// 计算属性
-const isSelected = computed(() =>
-  props.selectCount.includes(props.question.value)
-);
-const isCorrect = computed(() =>
-  props.answerList.includes(props.question.value)
-);
+const emit = defineEmits(["select"]);
+
+const replaceImageDimensions = (str) =>
+  str
+    .replace(
+      /<img([^>]*?)style="([^"]*?)width:\s*[^;]+;\s*height:\s*[^;]+;([^"]*?)"([^>]*?)\/?>/g,
+      '<img$1style="$2width:100%;height:100%;$3"$4/>'
+    )
+    .replace(
+      /<img([^>]*?)width="[^"]*"([^>]*?)height="[^"]*"([^>]*?)\/?>/g,
+      '<img$1width="100%"$2height="100%"$3/>'
+    );
 
 // 方法
 const handleClick = () => {
-  // 如果已经选中,则取消选择
-  if (isSelected.value) {
-    emit("select", props.question.value, props.parindex);
-    return;
-  }
-
-  // 如果是练习模式,且已经达到答案数量,则不允许继续选择
-  if (props.mode === 'practice' && props.selectCount.length === props.answerList.length) {
-    return;
-  }
-
-  // 如果是练习模式,且即将完成所有选择,则显示答案
-  if (props.mode === 'practice' && props.answerList.length - 1 === props.selectCount.length) {
-    emit("showAnswer", true);
-  }
-
-  emit("select", props.question.value, props.parindex);
+  if (props.showResult) return;
+  emit("select", {
+    ...props.question,
+    pid: props.parindex,
+    style: props.styleCount,
+  });
 };
 </script>
 
@@ -103,11 +113,8 @@ const handleClick = () => {
 
 .question-item {
   display: flex;
-  padding: 0 16px;
-  height: 76rpx;
-  border: 1px solid #ccc;
   border-radius: 8px;
-  background-color: #FAFBFD;
+  background-color: #fafbfd;
   gap: 12px;
   align-items: center;
   color: #333;
@@ -154,5 +161,6 @@ const handleClick = () => {
 
 .option-text {
   flex: 1;
+  white-space: normal;
 }
 </style>

+ 0 - 377
components/Topic/Topic.vue

@@ -1,377 +0,0 @@
-view
-<template>
-  <Container
-    :scrollX="true"
-    :scrollY="false"
-    :scroll-into-view="`item-${nowIndex}`"
-    :title="title"
-    @onSafeAreaChange="onSafeAreaChange"
-    v-bind="$attrs"
-  >
-    <template v-for="(item, parindex) in topics.ques" :key="parindex">
-      <view
-        :id="`item-${parindex}`"
-        v-if="parindex === nowIndex"
-        class="topic-item"
-        :style="{
-          width: `${safeArea.width}px`,
-          height: `${safeArea.height}px`,
-          flexShrink: 0, // 解决宽度无效问题
-        }"
-      >
-        <!-- 头部 -->
-        <view class="topic-header">
-          <view class="topic-header-left">
-            <view class="topic-type">
-              {{ item.anslist?.length > 1 ? "多选题" : "单选题" }}
-            </view>
-            <view class="topic-count">
-              第{{ parindex + 1 }}题/共{{ topics.ques.length }}题
-            </view>
-          </view>
-          <view class="star-icon" @tap="handleStar(item)">
-            <uni-icons
-              :type="item.star ? 'star-filled' : 'star'"
-              size="20"
-              color="#fe2624"
-            />
-            {{ item.star ? "已" : "" }}收藏
-          </view>
-        </view>
-
-        <!-- 问题内容 -->
-        <view class="topic-content">
-          <view class="question-text">{{ item.title }}</view>
-          <questions
-            v-for="(question, index) in item.questions"
-            :key="index"
-            :answer-list="
-              Array.isArray(item.anslist) ? item.anslist : [item.anslist]
-            "
-            :index="index"
-            :select-count="selectIndexList[parindex] || []"
-            :question="question"
-            :parindex="parindex"
-            :mode="mode"
-            @select="handleSelect"
-            @show-answer="(index) => setShowAnswer(index, parindex)"
-          />
-        </view>
-
-        <!-- 答案展示 -->
-        <view
-          v-if="showAnswer[parindex] && mode === 'practice'"
-          class="answer-section"
-        >
-          <view class="answer-content">
-            <view class="answer-row">
-              <view class="answer-item border-r-primary">
-                正确答案:
-                <text class="answer-text">{{ getRightAnswer(parindex) }}</text>
-              </view>
-              <view class="answer-item">
-                我的答案:
-                <text class="answer-text">{{ getMyAnswer(parindex) }}</text>
-              </view>
-            </view>
-          </view>
-        </view>
-
-        <!-- 底部按钮 -->
-        <view class="button-group">
-          <button
-            v-if="parindex >= 1 && parindex < topics.ques.length - 1"
-            class="prev-btn"
-            @tap="handlePage(item, parindex, 'prevPage')"
-          >
-            上一题
-          </button>
-          <button
-            v-if="parindex < topics.ques.length - 1"
-            class="next-btn"
-            @tap="handlePage(item, parindex, 'nextPage')"
-          >
-            下一题
-          </button>
-          <slot v-if="parindex === topics.ques.length - 1" name="end-footer">
-          </slot>
-        </view>
-      </view>
-    </template>
-  </Container>
-</template>
-
-<script setup>
-import { ref, onMounted } from "vue";
-import Questions from "./Questions.vue";
-import Container from "../Container/Container.vue";
-
-const TopicMapList = ["A", "B", "C", "D", "E"];
-const safeArea = ref({});
-
-// Props 定义
-const props = defineProps({
-  topics: {
-    type: Object,
-    default: () => ({}),
-  },
-  onStar: {
-    type: Function,
-    default: null,
-  },
-  type: {
-    type: String,
-    default: "radio",
-  },
-  mode: {
-    type: String,
-    default: "practice", // practice: 练习模式, exam: 考试模式
-  },
-  title: String,
-});
-
-// Emits 定义
-const emit = defineEmits(["prevPage", "nextPage", "answerChange"]);
-
-// 响应式数据
-const nowIndex = ref(0);
-const showAnswer = ref([]);
-const selectIndexList = ref([]);
-
-// 生命周期钩子
-onMounted(() => {
-  const systemInfo = uni.getSystemInfoSync();
-  selectIndexList.value = Array(props.topics.ques.length)
-    .fill()
-    .map(() => []);
-  showAnswer.value = Array(props.topics.ques.length).fill(false);
-});
-
-// 方法
-const handleStar = (item) => {
-  if (!props.onStar) return;
-  props.onStar(item).then((res) => {
-    uni.showToast({
-      title: res ? "已加入收藏" : "已移除收藏",
-      icon: "none",
-    });
-  });
-};
-
-const handleSelect = (value, parindex) => {
-  const arr = selectIndexList.value[parindex];
-  const currentTopic = props.topics.ques[parindex];
-  const isSingleChoice =
-    !Array.isArray(currentTopic.anslist) || currentTopic.anslist.length === 1;
-
-  // 如果点击已选中的选项,则取消选择
-  if (arr.includes(value)) {
-    selectIndexList.value[parindex] = arr.filter((item) => item !== value);
-    emit("answerChange", {
-      questionIndex: parindex,
-      answers: selectIndexList.value[parindex],
-      isSingleChoice,
-    });
-    return;
-  }
-
-  // 如果是考试模式
-  if (props.mode === "exam") {
-    // 如果是单选题,直接替换
-    if (isSingleChoice) {
-      selectIndexList.value[parindex] = [value];
-    } else {
-      // 多选题直接添加
-      arr.push(value);
-    }
-    emit("answerChange", {
-      questionIndex: parindex,
-      answers: selectIndexList.value[parindex],
-      isSingleChoice,
-    });
-    return;
-  }
-
-  // 练习模式逻辑
-  const max = Array.isArray(currentTopic.anslist)
-    ? currentTopic.anslist.length
-    : 1;
-
-  // 如果已达到最大选择数,则不允许继续选择
-  if (arr.length >= max) {
-    return;
-  }
-
-  // 如果是单选题,直接替换
-  if (max === 1) {
-    selectIndexList.value[parindex] = [value];
-  } else {
-    // 添加新选择
-    arr.push(value);
-  }
-
-  // 如果是练习模式,且即将完成所有选择,则显示答案
-  if (props.mode === "practice" && max - 1 === arr.length) {
-    emit("showAnswer", true);
-  }
-};
-
-const setShowAnswer = (value, parindex) => {
-  showAnswer.value[parindex] = value;
-};
-
-const getRightAnswer = (index) => {
-  const topic = props.topics.ques[index];
-  const answers = Array.isArray(topic.anslist)
-    ? topic.anslist
-    : [topic.anslist];
-  return answers
-    .map((value) => {
-      const question = topic.questions.find((q) => q.value === value);
-      return question ? TopicMapList[topic.questions.indexOf(question)] : "";
-    })
-    .join("");
-};
-
-const getMyAnswer = (parindex) => {
-  const topic = props.topics.ques[parindex];
-  return (selectIndexList.value[parindex] || [])
-    .map((value) => {
-      const question = topic.questions.find((q) => q.value === value);
-      return question ? TopicMapList[topic.questions.indexOf(question)] : "";
-    })
-    .join("");
-};
-
-const handlePage = (item, index, type) => {
-  nowIndex.value = index + (type === "prevPage" ? -1 : 1);
-  emit(type, { item, index });
-};
-
-const handleNextPage = (item, index, type) => {
-  nowIndex.value = index + 1;
-  emit("nextPage", { item, index });
-};
-
-const onSafeAreaChange = (s) => {
-  safeArea.value = s;
-};
-</script>
-
-<style lang="scss" scoped>
-@import "@/uni.scss";
-
-.topic-container {
-  width: 100vw;
-  overflow: hidden;
-  position: relative;
-}
-
-.topic-item {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  position: relative;
-  box-sizing: border-box;
-}
-
-.topic-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-}
-
-.topic-header-left {
-  display: flex;
-  gap: 8px;
-  align-items: center;
-}
-
-.topic-type {
-  border: 1px solid $uni-primary;
-  padding: 0 8px;
-  border-radius: 6px;
-  color: $uni-primary;
-  font-weight: 600;
-  font-size: 14px;
-}
-
-.topic-count {
-  color: #333;
-  font-size: 14px;
-}
-
-.topic-content {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.question-text {
-  font-weight: bold;
-  font-size: 14px;
-  white-space: normal;
-}
-
-.answer-section {
-  flex: 1;
-  border-radius: 16rpx;
-  border: 1px solid #ddd;
-  padding: 24rpx;
-  display: flex;
-  flex-direction: column;
-  gap: 32rpx;
-  overflow: scroll;
-}
-
-.answer-row {
-  font-size: 14px;
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-.answer-item {
-  display: flex;
-  gap: 8px;
-  align-items: center;
-  padding-right: 12px;
-}
-
-.border-r-primary {
-  border-right: 2px solid $uni-primary;
-}
-
-.answer-text {
-  color: $uni-primary;
-}
-
-.button-group {
-  display: flex;
-  gap: 8px;
-  position: sticky;
-  bottom: 0;
-  margin-top: auto;
-}
-
-.prev-btn {
-  flex: 1;
-  background-color: $uni-primary-light;
-  color: $uni-primary;
-}
-
-.next-btn {
-  flex: 1;
-  background-color: $uni-primary;
-  color: #fff;
-}
-
-.star-icon {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  font-weight: 500;
-  font-size: 20rpx;
-  color: #000000;
-}
-</style>

+ 445 - 0
components/Topic/TopicExam.vue

@@ -0,0 +1,445 @@
+<template>
+  <Container
+    :scrollX="true"
+    :scrollY="true"
+    :scroll-into-view="`item-${nowIndex}`"
+    :title="title"
+    @onSafeAreaChange="onSafeAreaChange"
+    :onBack="onBack"
+    v-bind="$attrs"
+    v-if="!open"
+  >
+    <template v-for="(item, parindex) in data" :key="parindex">
+      <view
+        :id="`item-${parindex}`"
+        v-if="parindex === nowIndex"
+        class="topic-item"
+        :style="{
+          width: `${safeArea.width}px`,
+          height: `${safeArea.height}px`,
+          flexShrink: 0, // 解决宽度无效问题
+        }"
+      >
+        <!-- 头部 -->
+        <view class="topic-header">
+          <view class="topic-header-left">
+            <view class="topic-type"> {{ styleMap[item.style] }}题 </view>
+            <view class="topic-count">
+              第{{ parindex + 1 }}题/共{{ total }}题
+            </view>
+          </view>
+          <view class="star-icon" @tap="handleStar(item)">
+            <uni-icons
+              :type="item.star ? 'star-filled' : 'star'"
+              size="20"
+              color="#fe2624"
+            />
+            {{ item.star ? "已" : "" }}收藏
+          </view>
+        </view>
+
+        <!-- 问题内容 -->
+        <view class="topic-content">
+          <uvParse
+            v-if="item.isImage"
+            :content="item.title"
+            class="question-text"
+          ></uvParse>
+          <rich-text
+            v-else
+            :nodes="item.title"
+            class="question-text"
+          ></rich-text>
+          <uvParse
+            :content="item.questions_ex"
+            v-if="item.style === 6 && item.isImage"
+            class="question-text"
+          ></uvParse>
+          <rich-text
+            v-else
+            :nodes="item.questions_ex"
+            class="question-text"
+          ></rich-text>
+          <questions
+            v-if="item.style !== 6"
+            v-for="(question, index) in item.questions"
+            :key="index"
+            :answerList="item.ansList"
+            :index="index"
+            :styleCount="item.style"
+            :question="question"
+            :showResult="item.showResult"
+            :parindex="parindex"
+            @select="handleSelect"
+          />
+          <view class="other" v-else>
+            <questions
+              v-for="(question, index) in item.questions"
+              :key="index"
+              :answerList="item.ansList"
+              :index="index"
+              :styleCount="item.style"
+              :question="question"
+              :showResult="item.showResult"
+              :parindex="parindex"
+              @select="handleSelect"
+            />
+          </view>
+        </view>
+
+        <!-- 答案展示 -->
+        <view v-if="item.showResult" class="answer-section">
+          <view class="answer-content">
+            <view class="answer-row">
+              <view class="answer-item border-r-primary">
+                正确答案:
+                <text class="answer-text">{{
+                  item.ansList.map((i) => i.label).join("")
+                }}</text>
+              </view>
+              <view class="answer-item">
+                我的答案:
+                <text class="answer-text">{{
+                  item.selectAns.map((i) => i.value).join("")
+                }}</text>
+              </view>
+              <view
+                class="tip"
+                :style="{
+                  color: item.isRight ? '#00be00' : '#f00',
+                }"
+                >{{ item.isRight ? "太棒了~" : "再接再励!" }}</view
+              >
+            </view>
+            <view class="parsing">
+              <p>解析:</p>
+              <scroll-view
+                :show-scrollbar="false"
+                scroll-y
+                class="parsing-text"
+              >
+                <rich-text :nodes="item.explain"></rich-text>
+              </scroll-view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 底部按钮 -->
+        <view class="button-group">
+          <button
+            v-if="parindex >= 1 && item.showResult"
+            class="prev-btn"
+            @tap="handlePage(item, parindex, 'prevPage')"
+          >
+            上一题
+          </button>
+          <button
+            v-if="parindex + 1 < total && item.showResult"
+            class="next-btn"
+            @tap="handlePage(item, parindex, 'nextPage')"
+          >
+            下一题
+          </button>
+          <button
+            v-if="parindex + 1 === total && item.showResult"
+            @click="emit('lookReport', data, submitter)"
+          >
+            查看报告
+          </button>
+          <button
+            v-if="!item.showResult"
+            @click="questionSubmit(item, parindex)"
+          >
+            提交
+          </button>
+        </view>
+      </view>
+    </template>
+  </Container>
+  <Modal
+    v-model:open="open"
+    title="温馨提示"
+    :submitter="submitter"
+    :onClose="onClose"
+    :onSubmit="onSubmit"
+  >
+    <view :style="{ margin: '10px 0' }">{{ submitter.context }}</view>
+  </Modal>
+</template>
+
+<script setup>
+// 答题组件
+import { ref, watchEffect } from "vue";
+import Questions from "./Questions.vue";
+import Container from "../Container/Container.vue";
+import Modal from "../Modal/Modal.vue";
+import uvParse from "@/uni_modules/uv-parse/components/uv-parse/uv-parse.vue";
+const styleMap = {
+  2: "单选",
+  3: "多选",
+  6: "配伍",
+};
+
+const submitter = ref({
+  text: "提交查看",
+  closeText: "直接退出",
+  context: "您本次答题还未提交, 确定要退出答题吗?",
+  isEndQuestion: false,
+});
+
+const safeArea = ref({}); // 安全区域
+const data = ref([]); // 题目数据
+const open = ref(false); // 是否显示弹窗
+let r = null; // 异步方法
+
+// Props 定义
+const props = defineProps({
+  topics: {
+    type: Array,
+    default: () => [],
+  },
+  onStar: {
+    type: Function,
+    default: null,
+  },
+  total: {
+    type: Number,
+    default: 0,
+  },
+  title: String,
+});
+
+watchEffect(() => {
+  data.value = props.topics;
+});
+
+// Emits 定义
+const emit = defineEmits([
+  "prevPage",
+  "nextPage",
+  "answerChange",
+  "lookReport",
+]);
+
+const onBack = () =>
+  new Promise((resolve) => {
+    const isEndQuestion =
+      nowIndex.value === props.total - 1 &&
+      data.value[nowIndex.value].showResult;
+
+    if (isEndQuestion) {
+      submitter.value = {
+        ...submitter.value,
+        text: "查看报告",
+        context:
+          "您的答题报告已准备就绪!退出后将无法查看本次分析结果,且不保留本次答题记录,确定要放弃吗?",
+        isEndQuestion,
+      };
+    }
+    open.value = true;
+    r = resolve;
+  });
+
+const onClose = () => r(true);
+const onSubmit = () =>
+  new Promise((res) => {
+    emit("lookReport", data.value, submitter.value);
+    res(true);
+  });
+// 响应式数据
+const nowIndex = ref(0);
+
+// 判断答案是否正确
+const isRight = (selectAns, rightAns) => {
+  return selectAns.every((item) => rightAns.includes(item));
+};
+
+// 收藏方法
+const handleStar = (item) => {
+  if (!props.onStar) return;
+  props.onStar(item).then((res) => {
+    uni.showToast({
+      title: res ? "已加入收藏" : "已移除收藏",
+      icon: "none",
+    });
+  });
+};
+
+const handleSelect = ({ pid, checked, index, style }) => {
+  // 如果不是多选,就取消其他的选项
+  if (style !== 3) {
+    data.value[pid].questions = data.value[pid].questions.map((q) => ({
+      ...q,
+      checked: false,
+    }));
+    data.value[pid].selectAns = [];
+  }
+  // 更新选项
+  const item = data.value[pid].questions[index];
+  data.value[pid].questions[index].checked = !checked;
+  data.value[pid].questions[index].isRight = isRight(
+    [item.value],
+    data.value[pid].ansList.map((q) => q.label)
+  );
+  
+
+  // 更新答案
+  data.value[pid].selectAns = data.value[pid].questions
+    .filter((q) => q.checked)
+    .map((q) => q.value);
+};
+
+const questionSubmit = (item) => {
+  item.showResult = true;
+  item.selectAns = item.questions.filter((q) => q.checked);
+  item.isRight = isRight(
+    item.selectAns.map((q) => q.value),
+    item.ansList.map((q) => q.label)
+  );
+};
+
+const handlePage = (item, index, type) => {
+  nowIndex.value = index + (type === "prevPage" ? -1 : 1);
+  emit(type, { item, index });
+};
+
+const onSafeAreaChange = (s) => {
+  safeArea.value = s;
+};
+</script>
+
+<style lang="scss" scoped>
+@import "@/uni.scss";
+.other {
+  display: flex;
+  gap: 12px;
+}
+.parsing-text {
+  height: 381rpx;
+  white-space: normal;
+}
+.answer-content {
+  display: flex;
+  flex-direction: column;
+  gap: 45rpx;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 500;
+  font-size: 32rpx;
+  color: #666666;
+}
+.topic-container {
+  width: 100vw;
+  overflow: hidden;
+  position: relative;
+}
+
+.topic-item {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  position: relative;
+  box-sizing: border-box;
+}
+
+.topic-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.topic-header-left {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.topic-type {
+  border: 1px solid $uni-primary;
+  padding: 0 8px;
+  border-radius: 6px;
+  color: $uni-primary;
+  font-weight: 600;
+  font-size: 14px;
+}
+
+.topic-count {
+  color: #333;
+  font-size: 14px;
+}
+
+.topic-content {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.question-text {
+  font-weight: bold;
+  font-size: 14px;
+  white-space: normal;
+}
+
+.answer-section {
+  border-radius: 16rpx;
+  border: 1px solid #ddd;
+  padding: 24rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 32rpx;
+}
+
+.answer-row {
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.answer-item {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  padding-right: 12px;
+}
+
+.tip {
+  margin-left: auto;
+}
+
+.border-r-primary {
+  border-right: 2px solid $uni-primary;
+}
+
+.answer-text {
+  color: $uni-primary;
+}
+
+.button-group {
+  display: flex;
+  gap: 8px;
+  position: sticky;
+  bottom: 0;
+  margin-top: auto;
+}
+
+.prev-btn {
+  flex: 1;
+  background-color: $uni-primary-light;
+  color: $uni-primary;
+}
+
+.next-btn {
+  flex: 1;
+  background-color: $uni-primary;
+  color: #fff;
+}
+
+.star-icon {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  font-weight: 500;
+  font-size: 20rpx;
+  color: #000000;
+}
+</style>

+ 445 - 0
components/Topic/TopicPractice.vue

@@ -0,0 +1,445 @@
+<template>
+  <Container
+    :scrollX="true"
+    :scrollY="true"
+    :scroll-into-view="`item-${nowIndex}`"
+    :title="title"
+    @onSafeAreaChange="onSafeAreaChange"
+    :onBack="onBack"
+    v-bind="$attrs"
+    v-if="!open"
+  >
+    <template v-for="(item, parindex) in data" :key="parindex">
+      <view
+        :id="`item-${parindex}`"
+        v-if="parindex === nowIndex"
+        class="topic-item"
+        :style="{
+          width: `${safeArea.width}px`,
+          height: `${safeArea.height}px`,
+          flexShrink: 0, // 解决宽度无效问题
+        }"
+      >
+        <!-- 头部 -->
+        <view class="topic-header">
+          <view class="topic-header-left">
+            <view class="topic-type"> {{ styleMap[item.style] }}题 </view>
+            <view class="topic-count">
+              第{{ parindex + 1 }}题/共{{ total }}题
+            </view>
+          </view>
+          <view class="star-icon" @tap="handleStar(item)">
+            <uni-icons
+              :type="item.star ? 'star-filled' : 'star'"
+              size="20"
+              color="#fe2624"
+            />
+            {{ item.star ? "已" : "" }}收藏
+          </view>
+        </view>
+
+        <!-- 问题内容 -->
+        <view class="topic-content">
+          <uvParse
+            v-if="item.isImage"
+            :content="item.title"
+            class="question-text"
+          ></uvParse>
+          <rich-text
+            v-else
+            :nodes="item.title"
+            class="question-text"
+          ></rich-text>
+          <uvParse
+            :content="item.questions_ex"
+            v-if="item.style === 6 && item.isImage"
+            class="question-text"
+          ></uvParse>
+          <rich-text
+            v-else
+            :nodes="item.questions_ex"
+            class="question-text"
+          ></rich-text>
+          <questions
+            v-if="item.style !== 6"
+            v-for="(question, index) in item.questions"
+            :key="index"
+            :answerList="item.ansList"
+            :index="index"
+            :styleCount="item.style"
+            :question="question"
+            :showResult="item.showResult"
+            :parindex="parindex"
+            @select="handleSelect"
+          />
+          <view class="other" v-else>
+            <questions
+              v-for="(question, index) in item.questions"
+              :key="index"
+              :answerList="item.ansList"
+              :index="index"
+              :styleCount="item.style"
+              :question="question"
+              :showResult="item.showResult"
+              :parindex="parindex"
+              @select="handleSelect"
+            />
+          </view>
+        </view>
+
+        <!-- 答案展示 -->
+        <view v-if="item.showResult" class="answer-section">
+          <view class="answer-content">
+            <view class="answer-row">
+              <view class="answer-item border-r-primary">
+                正确答案:
+                <text class="answer-text">{{
+                  item.ansList.map((i) => i.label).join("")
+                }}</text>
+              </view>
+              <view class="answer-item">
+                我的答案:
+                <text class="answer-text">{{
+                  item.selectAns.map((i) => i.value).join("")
+                }}</text>
+              </view>
+              <view
+                class="tip"
+                :style="{
+                  color: item.isRight ? '#00be00' : '#f00',
+                }"
+                >{{ item.isRight ? "太棒了~" : "再接再励!" }}</view
+              >
+            </view>
+            <view class="parsing">
+              <p>解析:</p>
+              <scroll-view
+                :show-scrollbar="false"
+                scroll-y
+                class="parsing-text"
+              >
+                <rich-text :nodes="item.explain"></rich-text>
+              </scroll-view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 底部按钮 -->
+        <view class="button-group">
+          <button
+            v-if="parindex >= 1 && item.showResult"
+            class="prev-btn"
+            @tap="handlePage(item, parindex, 'prevPage')"
+          >
+            上一题
+          </button>
+          <button
+            v-if="parindex + 1 < total && item.showResult"
+            class="next-btn"
+            @tap="handlePage(item, parindex, 'nextPage')"
+          >
+            下一题
+          </button>
+          <button
+            v-if="parindex + 1 === total && item.showResult"
+            @click="emit('lookReport', data, submitter)"
+          >
+            查看报告
+          </button>
+          <button
+            v-if="!item.showResult"
+            @click="questionSubmit(item, parindex)"
+          >
+            提交
+          </button>
+        </view>
+      </view>
+    </template>
+  </Container>
+  <Modal
+    v-model:open="open"
+    title="温馨提示"
+    :submitter="submitter"
+    :onClose="onClose"
+    :onSubmit="onSubmit"
+  >
+    <view :style="{ margin: '10px 0' }">{{ submitter.context }}</view>
+  </Modal>
+</template>
+
+<script setup>
+// 答题组件
+import { ref, watchEffect } from "vue";
+import Questions from "./Questions.vue";
+import Container from "../Container/Container.vue";
+import Modal from "../Modal/Modal.vue";
+import uvParse from "@/uni_modules/uv-parse/components/uv-parse/uv-parse.vue";
+const styleMap = {
+  2: "单选",
+  3: "多选",
+  6: "配伍",
+};
+
+const submitter = ref({
+  text: "提交查看",
+  closeText: "直接退出",
+  context: "您本次答题还未提交, 确定要退出答题吗?",
+  isEndQuestion: false,
+});
+
+const safeArea = ref({}); // 安全区域
+const data = ref([]); // 题目数据
+const open = ref(false); // 是否显示弹窗
+let r = null; // 异步方法
+
+// Props 定义
+const props = defineProps({
+  topics: {
+    type: Array,
+    default: () => [],
+  },
+  onStar: {
+    type: Function,
+    default: null,
+  },
+  total: {
+    type: Number,
+    default: 0,
+  },
+  title: String,
+});
+
+watchEffect(() => {
+  data.value = props.topics;
+});
+
+// Emits 定义
+const emit = defineEmits([
+  "prevPage",
+  "nextPage",
+  "answerChange",
+  "lookReport",
+]);
+
+const onBack = () =>
+  new Promise((resolve) => {
+    const isEndQuestion =
+      nowIndex.value === props.total - 1 &&
+      data.value[nowIndex.value].showResult;
+
+    if (isEndQuestion) {
+      submitter.value = {
+        ...submitter.value,
+        text: "查看报告",
+        context:
+          "您的答题报告已准备就绪!退出后将无法查看本次分析结果,且不保留本次答题记录,确定要放弃吗?",
+        isEndQuestion,
+      };
+    }
+    open.value = true;
+    r = resolve;
+  });
+
+const onClose = () => r(true);
+const onSubmit = () =>
+  new Promise((res) => {
+    emit("lookReport", data.value, submitter.value);
+    res(true);
+  });
+// 响应式数据
+const nowIndex = ref(0);
+
+// 判断答案是否正确
+const isRight = (selectAns, rightAns) => {
+  return selectAns.every((item) => rightAns.includes(item));
+};
+
+// 收藏方法
+const handleStar = (item) => {
+  if (!props.onStar) return;
+  props.onStar(item).then((res) => {
+    uni.showToast({
+      title: res ? "已加入收藏" : "已移除收藏",
+      icon: "none",
+    });
+  });
+};
+
+const handleSelect = ({ pid, checked, index, style }) => {
+  // 如果不是多选,就取消其他的选项
+  if (style !== 3) {
+    data.value[pid].questions = data.value[pid].questions.map((q) => ({
+      ...q,
+      checked: false,
+    }));
+    data.value[pid].selectAns = [];
+  }
+  // 更新选项
+  const item = data.value[pid].questions[index];
+  data.value[pid].questions[index].checked = !checked;
+  data.value[pid].questions[index].isRight = isRight(
+    [item.value],
+    data.value[pid].ansList.map((q) => q.label)
+  );
+  
+
+  // 更新答案
+  data.value[pid].selectAns = data.value[pid].questions
+    .filter((q) => q.checked)
+    .map((q) => q.value);
+};
+
+const questionSubmit = (item) => {
+  item.showResult = true;
+  item.selectAns = item.questions.filter((q) => q.checked);
+  item.isRight = isRight(
+    item.selectAns.map((q) => q.value),
+    item.ansList.map((q) => q.label)
+  );
+};
+
+const handlePage = (item, index, type) => {
+  nowIndex.value = index + (type === "prevPage" ? -1 : 1);
+  emit(type, { item, index });
+};
+
+const onSafeAreaChange = (s) => {
+  safeArea.value = s;
+};
+</script>
+
+<style lang="scss" scoped>
+@import "@/uni.scss";
+.other {
+  display: flex;
+  gap: 12px;
+}
+.parsing-text {
+  height: 381rpx;
+  white-space: normal;
+}
+.answer-content {
+  display: flex;
+  flex-direction: column;
+  gap: 45rpx;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 500;
+  font-size: 32rpx;
+  color: #666666;
+}
+.topic-container {
+  width: 100vw;
+  overflow: hidden;
+  position: relative;
+}
+
+.topic-item {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  position: relative;
+  box-sizing: border-box;
+}
+
+.topic-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.topic-header-left {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.topic-type {
+  border: 1px solid $uni-primary;
+  padding: 0 8px;
+  border-radius: 6px;
+  color: $uni-primary;
+  font-weight: 600;
+  font-size: 14px;
+}
+
+.topic-count {
+  color: #333;
+  font-size: 14px;
+}
+
+.topic-content {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.question-text {
+  font-weight: bold;
+  font-size: 14px;
+  white-space: normal;
+}
+
+.answer-section {
+  border-radius: 16rpx;
+  border: 1px solid #ddd;
+  padding: 24rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 32rpx;
+}
+
+.answer-row {
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.answer-item {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  padding-right: 12px;
+}
+
+.tip {
+  margin-left: auto;
+}
+
+.border-r-primary {
+  border-right: 2px solid $uni-primary;
+}
+
+.answer-text {
+  color: $uni-primary;
+}
+
+.button-group {
+  display: flex;
+  gap: 8px;
+  position: sticky;
+  bottom: 0;
+  margin-top: auto;
+}
+
+.prev-btn {
+  flex: 1;
+  background-color: $uni-primary-light;
+  color: $uni-primary;
+}
+
+.next-btn {
+  flex: 1;
+  background-color: $uni-primary;
+  color: #fff;
+}
+
+.star-icon {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  font-weight: 500;
+  font-size: 20rpx;
+  color: #000000;
+}
+</style>

+ 30 - 9
pages.json

@@ -56,6 +56,27 @@
         "navigationBarTitleText": "订单记录"
       }
     },
+    {
+      "path": "pages/real/index",
+      "style": {
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "往年真题"
+      }
+    },
+    {
+      "path": "pages/real/history",
+      "style": {
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "往年真题"
+      }
+    },
+    {
+      "path": "pages/real/exam",
+      "style": {
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "考试"
+      }
+    },
     {
       "path": "pages/recharge/index",
       "style": {
@@ -87,32 +108,32 @@
   },
   "tabBar": {
     "color": "#7A7E83",
-    "selectedColor": "#3cc51f",
+    "selectedColor": "#3F75FF",
     "borderStyle": "black",
     "backgroundColor": "#ffffff",
     "list": [
       {
         "pagePath": "pages/home/index",
-        "iconPath": "static/icons/c1.png",
-        "selectedIconPath": "static/icons/c2.png",
+        "iconPath": "static/icons/home.png",
+        "selectedIconPath": "static/icons/home_select.png",
         "text": "首页"
       },
       {
         "pagePath": "pages/learn/index",
-        "iconPath": "static/icons/c1.png",
-        "selectedIconPath": "static/icons/c2.png",
+        "iconPath": "static/icons/learn.png",
+        "selectedIconPath": "static/icons/learn_select.png",
         "text": "学习本"
       },
       {
         "pagePath": "pages/challenge/index",
-        "iconPath": "static/icons/c1.png",
-        "selectedIconPath": "static/icons/c2.png",
+        "iconPath": "static/icons/challenge.png",
+        "selectedIconPath": "static/icons/challenge_select.png",
         "text": "挑战"
       },
       {
         "pagePath": "pages/user/index",
-        "iconPath": "static/icons/c1.png",
-        "selectedIconPath": "static/icons/c2.png",
+        "iconPath": "static/icons/user.png",
+        "selectedIconPath": "static/icons/user_select.png",
         "text": "我的"
       }
     ]

+ 21 - 13
pages/home/index.vue

@@ -1,12 +1,5 @@
 <template>
-  <Container
-    title="首页"
-    :showBack="false"
-    :scrollStyle="{
-      paddingLeft: 0,
-      paddingRight: 0,
-    }"
-  >
+  <Container title="首页" :showBack="false">
     <view class="home">
       <!-- 倒计时 -->
       <view class="time">
@@ -45,7 +38,7 @@
             <template v-for="(item, index) in list" :key="item.id">
               <view v-if="current === index" class="grid">
                 <view
-                  v-for="i in list[0]?.children"
+                  v-for="i in item?.children"
                   class="flex"
                   @click="clickClass(i)"
                 >
@@ -61,7 +54,15 @@
     <uni-section title="往年真题" type="line">
       <!-- 往年真题 -->
       <view class="grid-3">
-        <view v-for="item in 7" class="flex">
+        <view
+          v-for="item in 7"
+          class="flex"
+          @click="
+            toReal({
+              title: '2025真题',
+            })
+          "
+        >
           <view class="bg-red"></view>
           <view> 执业药师{{ item }} </view>
         </view>
@@ -79,15 +80,22 @@ import { request } from "../../utils/request";
 import { arrayToTree } from "../../utils";
 
 const current = ref(0);
-const items = ref(["标签1", "标签2"]);
+const items = ref(["", "标签2"]);
 const list = ref([]);
 
 const clickClass = ({ id }) => {
   router.push({
     url: "/pages/regulations/index",
     params: {
-      id
-    }
+      id,
+    },
+  });
+};
+
+const toReal = (item) => {
+  router.push({
+    url: "/pages/real/index",
+    params: item,
   });
 };
 

+ 274 - 0
pages/real/exam.vue

@@ -0,0 +1,274 @@
+<script setup name="Exam">
+import TopicExam from "../../components/Topic/TopicExam.vue";
+import { ref, onMounted } from "vue";
+import { getRoute } from "../../utils/router";
+import { request } from "../../utils/request";
+import { useTimeStore } from "@/store/time";
+import Container from "../../components/Container/Container.vue";
+
+const Time = useTimeStore();
+
+const TopicMapList = ["A", "B", "C", "D", "E"];
+
+const title = ref("");
+const total = ref(100000);
+const correct = ref({
+  rate: 0, // 正确率
+  error: 0, // 错误数
+  right: 0, // 正确数
+  not: 0, // 未答题
+});
+
+const showReport = ref(false);
+const submitter = ref({
+  closeText: "直接退出",
+  context: "您本次答题还未提交, 确定要退出答题吗?",
+  isEndQuestion: false,
+  text: "提交查看",
+  totalTime: {
+    formatTime: "00:03",
+    totalTime: 2892,
+  },
+});
+
+const pageParams = ref({
+  page: 1,
+  limit: 999,
+});
+const data = ref([]);
+
+const getList = async (params) => {
+  if (pageParams.value.page * pageParams.value.limit >= total.value) return;
+  const res = await request(
+    "api/question_bank/question_reception/topic/get_chapter_topic",
+    {
+      ...params,
+      id: getRoute().params.id,
+    }
+  );
+  total.value = res.data.total;
+  data.value.push(
+    ...res.data.data.map((item) => {
+      let questions = [];
+      const ans = item.correct_answer.split(",");
+      const ansList = [];
+      for (let i = 0; i < item.question_count; i++) {
+        const current = TopicMapList[i];
+        if (ans.includes(current)) {
+          ansList.push({
+            label: current,
+            value: i,
+          });
+        }
+        const v =
+          item[`select_${current.toLowerCase()}`].replace(/<br\s*\/?>/g, "") ||
+          "";
+        questions.push({
+          label: v,
+          value: current,
+          checked: false,
+          index: i,
+        });
+      }
+
+      return {
+        ...item,
+        questions, // 题目
+        ansList, // 正确答案
+        selectAns: [], // 选择的答案
+        showResult: false, // 是否展示答案
+        isRight: false, // 是否正确
+        isImage: item.title.includes("<img"),
+      };
+    })
+  );
+  pageParams.value.page++;
+};
+
+const nextPage = (e) => {
+  if (pageParams.value.page * pageParams.value.limit - e.index - 1 !== 1)
+    return;
+  getList(pageParams.value);
+};
+
+const lookReport = (d, s) => {
+  data.value = d;
+  showReport.value = true;
+  const totalTime = Time.end();
+  submitter.value = {
+    ...s,
+    totalTime,
+  };
+  const r = data.value.filter((item) => item.isRight).length;
+  const n = data.value.filter((item) => !item.showResult).length;
+  correct.value = {
+    rate: (r / total.value) * 100,
+    right: r,
+    error: total.value - r - n,
+    not: n,
+  };
+};
+
+onMounted(() => {
+  Time.start();
+  const params = getRoute().params;
+  title.value = params.title;
+  getList(pageParams.value);
+});
+</script>
+
+<template>
+  <TopicExam
+    :title="title"
+    :total="total"
+    mode="practice"
+    :topics="data"
+    @nextPage="nextPage"
+    @lookReport="lookReport"
+    border
+    v-if="!showReport"
+  />
+  <Container v-else title="练习报告">
+    <uni-card>
+      <view class="card">
+        <view class="card-time">
+          <view>{{ submitter.totalTime?.formatTime }}</view>
+          <view>学习时长</view>
+        </view>
+        <view class="card-time">
+          <view>{{ correct.rate.toFixed(2) }}%</view>
+          <view>正确率</view>
+        </view>
+      </view>
+    </uni-card>
+    <view class="reslut">
+      <view class="header">
+        <view>答题卡</view>
+        <view class="right-error">
+          <view class="right">答对({{ correct.right }})</view>
+          <view class="error">答错({{ correct.error }})</view>
+          <view class="not">未作({{ correct.not }})</view>
+        </view>
+      </view>
+      <view class="group">
+        <view
+          class="item"
+          :class="{
+            right: it.isRight && it.showResult,
+            error: !it.isRight && it.showResult,
+          }"
+          v-for="(it, index) in data"
+          :key="it.id"
+          >{{ index + 1 }}</view
+        >
+      </view>
+    </view>
+    <template #footer>
+      <view class="footer">
+        <view class="button plain">答题解析</view>
+        <view class="button">炫耀一下</view>
+      </view>
+    </template>
+  </Container>
+</template>
+
+<style lang="scss" scoped>
+@import "@/uni.scss";
+
+.card {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 40rpx 50rpx;
+  &-time {
+    font-family: PingFang SC, PingFang SC;
+    font-weight: 500;
+    font-size: 28rpx;
+    color: #999999;
+    display: flex;
+    flex-direction: column;
+    gap: 10rpx;
+    align-items: center;
+  }
+}
+
+.reslut {
+  margin: 0 30rpx;
+  padding: 20rpx 50rpx;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 500;
+  font-size: 28rpx;
+  color: #333333;
+  background: #fff;
+  border-radius: 10rpx;
+  .header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    .right-error {
+      display: flex;
+      gap: 20rpx;
+      font-weight: 400;
+      font-size: 20rpx;
+      color: #333333;
+      @mixin type($color) {
+        display: flex;
+        align-items: center;
+        gap: 10rpx;
+        &::before {
+          content: "";
+          width: 16rpx;
+          height: 16rpx;
+          border-radius: 50%;
+          display: block;
+          background: $color;
+        }
+      }
+      .right {
+        @include type($success);
+      }
+      .error {
+        @include type($error);
+      }
+      .not {
+        @include type($default);
+      }
+    }
+  }
+
+  .group {
+    display: grid;
+    grid-template-columns: repeat(6, 1fr);
+    gap: 20rpx;
+    margin-top: 20rpx;
+
+    .item {
+      width: 72rpx;
+      height: 72rpx;
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background: $default;
+      font-family: PingFang SC, PingFang SC;
+      font-weight: 500;
+      font-size: 28rpx;
+      color: #ffffff;
+    }
+    .item.right {
+      background: $success;
+    }
+    .item.error {
+      background: $error;
+    }
+  }
+}
+
+.footer {
+  display: flex;
+  gap: 20rpx;
+  align-items: center;
+  padding-top: 20rpx;
+}
+</style>

+ 91 - 0
pages/real/history.vue

@@ -0,0 +1,91 @@
+<template>
+  <Container title="往年真题">
+    <view class="user">
+      <image
+        class="avatar"
+        :src="userInfo.userpic"
+        width="40"
+        height="40"
+        v-if="userInfo.userpic"
+      />
+      <view class="name">{{ userInfo.username }}</view>
+    </view>
+    <view class="exam-info">
+      <view class="item">
+        <view class="score">100分</view>
+        <view class="title">总分</view>
+      </view>
+      <view class="item">
+        <view class="score">100题</view>
+        <view class="title">题量</view>
+      </view>
+      <view class="item">
+        <view class="score">150分钟</view>
+        <view class="title">时长</view>
+      </view>
+    </view>
+    <view class="tip"
+      >注:题型可能会含有单选题、多选题、配伍题、综合分析题;请注意考试时长,无论答题是否完全,到时自动交卷。</view
+    >
+    <template #footer>
+      <button @click="onClick">开始考试</button>
+    </template>
+  </Container>
+</template>
+
+<script setup name="history">
+import { ref, onMounted } from "vue";
+import Container from "../../components/Container/Container.vue";
+import { router } from "../../utils/router";
+
+const userInfo = ref({
+  userpic: "https://img-cdn-qiniu.dcloud.net.cn/uniapp/images/uni@2x.png",
+  username: "Hi, 这里是历史题库",
+});
+
+const onClick = () => {
+  router.push({
+    url: "/pages/real/exam",
+    params: {
+      title: "考试",
+    },
+  });
+};
+onMounted(() => {
+  userInfo.value = uni.getStorageSync("userInfo");
+});
+</script>
+
+<style scoped lang="scss">
+.user {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  padding-left: 20rpx;
+
+  .avatar {
+    border-radius: 50%;
+    height: 96rpx;
+    width: 96rpx;
+  }
+}
+
+.exam-info {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 20rpx;
+  padding: 20rpx;
+  .item {
+    display: flex;
+    align-items: center;
+    flex-direction: column;
+    gap: 30rpx;
+    .title {
+      font-family: PingFang SC, PingFang SC;
+      font-weight: 500;
+      font-size: 28rpx;
+      color: #999999;
+    }
+  }
+}
+</style>

+ 107 - 0
pages/real/index.vue

@@ -0,0 +1,107 @@
+<template>
+  <Container :title="title">
+    <view class="p-20">
+      <uni-segmented-control
+        :flex="false"
+        :current="current"
+        :values="list.map((i) => i.name)"
+        style-type="text"
+        @clickItem="(e) => (current = e.currentIndex)"
+      />
+      <!-- 执业药师 -->
+      <template v-for="(item, index) in list" :key="item.id">
+        <view v-if="current === index" class="grid">
+          <view v-for="i in item?.children" class="flex" @click="clickClass(i)">
+            <view
+              class="bg-red"
+              :style="{
+                background: '#d9d9d9',
+              }"
+              >{{ i.name }}</view
+            >
+          </view>
+        </view>
+      </template>
+    </view>
+  </Container>
+</template>
+
+<script setup name="real">
+import { ref, onMounted } from "vue";
+import Container from "../../components/Container/Container.vue";
+import { getRoute, router } from "../../utils/router";
+import { arrayToTree } from "../../utils";
+import { request } from "../../utils/request";
+
+const title = ref("");
+const list = ref([]);
+const current = ref(0);
+
+const clickClass = (item) => {
+  router.push({
+    url: "/pages/real/history",
+    params: {
+      id: item.id,
+      title: item.name,
+    },
+  });
+}
+
+onMounted(async () => {
+  const params = getRoute().params;
+  title.value = params.title;
+  const res = await request(
+    "api/question_bank/question_reception/chapter/get",
+    {},
+    "POST"
+  );
+  list.value = arrayToTree({
+    list: res.data,
+  });
+});
+</script>
+
+<style scoped lang="scss">
+.title {
+  font-family: "PingFang SC, PingFang SC";
+  font-weight: 700;
+  font-size: 32rpx;
+  color: #000000;
+}
+
+.p-20 {
+  padding: 0 30rpx 30rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 20rpx;
+}
+
+.grid-3 {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 20rpx;
+  height: 100%;
+}
+
+.flex {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+  align-items: center;
+  justify-content: center;
+}
+
+.bg-red {
+  width: 146rpx;
+  height: 198rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+</style>

+ 4 - 7
pages/regulations/index.vue

@@ -9,7 +9,7 @@
       v-model="value"
       class="class-content"
       @change="onChnage"
-      v-show="showContainer"
+      v-show="!showContainer"
     >
       <view class="free-content">
         <uni-collapse-item
@@ -83,7 +83,7 @@
     <Modal
       ref="popup"
       background-color="#fff"
-      :open="!showContainer"
+      v-model:open="showContainer"
       @change="onChange"
       title="邀请新客户解锁课程"
     >
@@ -107,7 +107,7 @@ import { getRoute, router } from "../../utils/router";
 import { request } from "../../utils/request";
 const collapse = ref(null);
 const popup = ref(null);
-const showContainer = ref(true);
+const showContainer = ref(false);
 const value = ref("");
 const instance = getCurrentInstance();
 const safeArea = ref({});
@@ -125,6 +125,7 @@ const onClickButton = (item) => {
     params: {
       id: item.id,
       parent_id: item.parent_id,
+      name: item.name
     },
   });
 };
@@ -151,10 +152,6 @@ const onChnage = () => {
   resolveHeight();
 };
 
-const onChange = ({ show }) => {
-  showContainer.value = !show;
-};
-
 const onClickMask = () => {
   showContainer.value = false;
 };

+ 7 - 5
pages/regulations/learing.vue

@@ -2,7 +2,7 @@
 import Container from "../../components/Container/Container.vue";
 import uvParse from "../../uni_modules/uv-parse/components/uv-parse/uv-parse.vue";
 import { ref, onMounted } from "vue";
-import { router } from "../../utils/router";
+import { getRoute, router } from "../../utils/router";
 
 const context = ref(
   `<p style="margin: 14.2pt 0pt 0.0001pt; text-align: left; font-family: Arial; color: #000000; font-size: 10.5pt;"><span style="font-family: 黑体; color: #703060; font-size: 12.5000pt;"><span style="font-family: 黑体;">考</span> <span style="font-family: 黑体;">点</span></span><span style="font-family: 黑体; color: #703060; font-size: 12.5000pt;">&nbsp;</span><span style="font-family: 黑体; color: #703060; font-size: 12.5000pt;">4</span><strong><span style="font-family: 黑体; color: #703060; font-weight: bold; font-size: 12.5000pt;"><span style="font-family: 黑体;">国家基本药物制度概述</span></span></strong><span style="font-family: 黑体; color: #703060; font-size: 12.5000pt;">&nbsp;</span><span style="font-family: 黑体; color: #703060; font-size: 12.5000pt;"><span style="font-family: 黑体;">★</span></span></p>
@@ -99,6 +99,7 @@ const context = ref(
 <p>&nbsp;</p>`
 );
 const scrollTop = ref(0);
+let se = 0;
 
 const normalText = (text) =>
   text
@@ -122,17 +123,18 @@ const normalText = (text) =>
     );
 
 const onScroll = (e) => {
-  scrollTop.value = e.detail.scrollTop;
+  se = e.detail.scrollTop;
 };
 
 const onSubmit = () => {
   console.log("onSubmit", scrollTop.value);
+  const params = getRoute().params;
   // TODO: 提交滚动条位置,并跳转页面
   router.push({
     url: `/pages/regulations/practice`,
     params: {
-      title: "学习",
-      id: 2,
+      title: params.name,
+      id: params.id,
     },
   });
 };
@@ -157,7 +159,7 @@ onMounted(() => {
     :onBack="onBack"
   >
     <uv-parse :content="context" :selectable="true" />
-    <button @click="onSubmit">已学完, 开启练习模式</button>
+    <text class="button" @click="onSubmit">已学完, 开启练习模式</text>
   </Container>
 </template>
 

+ 258 - 53
pages/regulations/practice.vue

@@ -1,69 +1,274 @@
 <script setup name="practice">
-import Topic from "../../components/Topic/Topic.vue";
+import TopicPractice from "../../components/Topic/TopicPractice.vue";
 import { ref, onMounted } from "vue";
 import { getRoute } from "../../utils/router";
+import { request } from "../../utils/request";
+import { useTimeStore } from "@/store/time";
+import Container from "../../components/Container/Container.vue";
+
+const Time = useTimeStore();
+
+const TopicMapList = ["A", "B", "C", "D", "E"];
 
 const title = ref("");
-const topics = ref({
-  ques: [
-    {
-      id: 1,
-      title:
-        "测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目测试题目",
-      questions: [
-        { label: "测试1", value: 1 },
-        { label: "测试2", value: 2 },
-        { label: "测试3", value: 3 },
-        { label: "测试4", value: 4 },
-        { label: "测试5", value: 5 },
-      ],
-      anslist: [2],
-      selectAns: [],
-    },
-    {
-      id: 2,
-      title: "测试题目",
-      questions: [
-        { label: "测试1", value: 1 },
-        { label: "测试2", value: 2 },
-        { label: "测试3", value: 3 },
-        { label: "测试4", value: 4 },
-        { label: "测试5", value: 5 },
-      ],
-      anslist: [2, 4],
-      selectAns: [],
-    },
-    {
-      id: 3,
-      title: "测试题目",
-      mode: "综合分析题",
-      questions: [
-        { label: "测试1", value: 1 },
-        { label: "测试2", value: 2 },
-        { label: "测试3", value: 3 },
-        { label: "测试4", value: 4 },
-        { label: "测试5", value: 5 },
-      ],
-      anslist: [2, 3],
-      selectAns: [],
-    },
-  ],
+const total = ref(100000);
+const correct = ref({
+  rate: 0, // 正确率
+  error: 0, // 错误数
+  right: 0, // 正确数
+  not: 0, // 未答题
 });
 
-const onBack = () =>
-  new Promise((resolve) => {
-    // 提交滚动条位置,并跳转页面
-    console.log(111);
-    
-    resolve(false);
-  });
+const showReport = ref(false);
+const submitter = ref({
+  closeText: "直接退出",
+  context: "您本次答题还未提交, 确定要退出答题吗?",
+  isEndQuestion: false,
+  text: "提交查看",
+  totalTime: {
+    formatTime: "00:03",
+    totalTime: 2892,
+  },
+});
+
+const pageParams = ref({
+  page: 1,
+  limit: 999,
+});
+const data = ref([]);
+
+const getList = async (params) => {
+  if (pageParams.value.page * pageParams.value.limit >= total.value) return;
+  const res = await request(
+    "api/question_bank/question_reception/topic/get_chapter_topic",
+    {
+      ...params,
+      id: getRoute().params.id,
+    }
+  );
+  total.value = res.data.total;
+  data.value.push(
+    ...res.data.data.map((item) => {
+      let questions = [];
+      const ans = item.correct_answer.split(",");
+      const ansList = [];
+      for (let i = 0; i < item.question_count; i++) {
+        const current = TopicMapList[i];
+        if (ans.includes(current)) {
+          ansList.push({
+            label: current,
+            value: i,
+          });
+        }
+        const v =
+          item[`select_${current.toLowerCase()}`].replace(/<br\s*\/?>/g, "") ||
+          "";
+        questions.push({
+          label: v,
+          value: current,
+          checked: false,
+          index: i,
+        });
+      }
+
+      return {
+        ...item,
+        questions, // 题目
+        ansList, // 正确答案
+        selectAns: [], // 选择的答案
+        showResult: false, // 是否展示答案
+        isRight: false, // 是否正确
+        isImage: item.title.includes("<img"),
+      };
+    })
+  );
+  pageParams.value.page++;
+};
+
+const nextPage = (e) => {
+  if (pageParams.value.page * pageParams.value.limit - e.index - 1 !== 1)
+    return;
+  getList(pageParams.value);
+};
+
+const lookReport = (d, s) => {
+  data.value = d;
+  showReport.value = true;
+  const totalTime = Time.end();
+  submitter.value = {
+    ...s,
+    totalTime,
+  };
+  const r = data.value.filter((item) => item.isRight).length;
+  const n = data.value.filter((item) => !item.showResult).length;
+  correct.value = {
+    rate: (r / total.value) * 100,
+    right: r,
+    error: total.value - r - n,
+    not: n,
+  };
+};
 
 onMounted(() => {
+  Time.start();
   const params = getRoute().params;
   title.value = params.title;
+  getList(pageParams.value);
 });
 </script>
 
 <template>
-  <Topic :title="title" mode="practice" :onBack="onBack" :topics="topics" border> </Topic>
+  <TopicPractice
+    :title="title"
+    :total="total"
+    mode="practice"
+    :topics="data"
+    @nextPage="nextPage"
+    @lookReport="lookReport"
+    border
+    v-if="!showReport"
+  />
+  <Container v-else title="练习报告">
+    <uni-card>
+      <view class="card">
+        <view class="card-time">
+          <view>{{ submitter.totalTime?.formatTime }}</view>
+          <view>学习时长</view>
+        </view>
+        <view class="card-time">
+          <view>{{ correct.rate.toFixed(2) }}%</view>
+          <view>正确率</view>
+        </view>
+      </view>
+    </uni-card>
+    <view class="reslut">
+      <view class="header">
+        <view>答题卡</view>
+        <view class="right-error">
+          <view class="right">答对({{ correct.right }})</view>
+          <view class="error">答错({{ correct.error }})</view>
+          <view class="not">未作({{ correct.not }})</view>
+        </view>
+      </view>
+      <view class="group">
+        <view
+          class="item"
+          :class="{
+            right: it.isRight && it.showResult,
+            error: !it.isRight && it.showResult,
+          }"
+          v-for="(it, index) in data"
+          :key="it.id"
+          >{{ index + 1 }}</view
+        >
+      </view>
+    </view>
+    <template #footer>
+      <view class="footer">
+        <view class="button plain">答题解析</view>
+        <view class="button">炫耀一下</view>
+      </view>
+    </template>
+  </Container>
 </template>
+
+<style lang="scss" scoped>
+@import "@/uni.scss";
+
+.card {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 40rpx 50rpx;
+  &-time {
+    font-family: PingFang SC, PingFang SC;
+    font-weight: 500;
+    font-size: 28rpx;
+    color: #999999;
+    display: flex;
+    flex-direction: column;
+    gap: 10rpx;
+    align-items: center;
+  }
+}
+
+.reslut {
+  margin: 0 30rpx;
+  padding: 20rpx 50rpx;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 500;
+  font-size: 28rpx;
+  color: #333333;
+  background: #fff;
+  border-radius: 10rpx;
+  .header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    .right-error {
+      display: flex;
+      gap: 20rpx;
+      font-weight: 400;
+      font-size: 20rpx;
+      color: #333333;
+      @mixin type($color) {
+        display: flex;
+        align-items: center;
+        gap: 10rpx;
+        &::before {
+          content: "";
+          width: 16rpx;
+          height: 16rpx;
+          border-radius: 50%;
+          display: block;
+          background: $color;
+        }
+      }
+      .right {
+        @include type($success);
+      }
+      .error {
+        @include type($error);
+      }
+      .not {
+        @include type($default);
+      }
+    }
+  }
+
+  .group {
+    display: grid;
+    grid-template-columns: repeat(6, 1fr);
+    gap: 20rpx;
+    margin-top: 20rpx;
+
+    .item {
+      width: 72rpx;
+      height: 72rpx;
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background: $default;
+      font-family: PingFang SC, PingFang SC;
+      font-weight: 500;
+      font-size: 28rpx;
+      color: #ffffff;
+    }
+    .item.right {
+      background: $success;
+    }
+    .item.error {
+      background: $error;
+    }
+  }
+}
+
+.footer {
+  display: flex;
+  gap: 20rpx;
+  align-items: center;
+  padding-top: 20rpx;
+}
+</style>

BIN
static/icons/c1.png


BIN
static/icons/c2.png


BIN
static/icons/c3.png


BIN
static/icons/c4.png


BIN
static/icons/c5.png


BIN
static/icons/c6.png


BIN
static/icons/c7.png


BIN
static/icons/c8.png


BIN
static/icons/c9.png


BIN
static/icons/challenge.png


BIN
static/icons/challenge_select.png


BIN
static/icons/home.png


BIN
static/icons/home_select.png


BIN
static/icons/learn.png


BIN
static/icons/learn_select.png


BIN
static/icons/user.png


BIN
static/icons/user_select.png


BIN
static/logo.png


BIN
static/uni.png


+ 35 - 0
store/time.js

@@ -0,0 +1,35 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+export const useTimeStore = defineStore("time", () => {
+  const time = ref({
+    startTime: 0,
+    endTime: 0,
+  });
+
+  const start = () => {
+    time.value.startTime = Date.now();
+  };
+
+  // 将时间戳转为mm/ss的格式
+  const formatTime = (time) => {
+    const minutes = Math.floor(time / 60000);
+    const seconds = ((time % 60000) / 1000).toFixed(0);
+    return `${minutes < 10 ? "0" : ""}${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
+  };
+
+  const end = () => {
+    time.value.endTime = Date.now();
+    const totalTime = time.value.endTime - time.value.startTime;
+    time.value = {
+      startTime: 0,
+      endTime: 0,
+    };
+    return {
+      totalTime,
+      formatTime: formatTime(totalTime),
+    };
+  };
+
+  return { time, start, end };
+});