ChoiceWorkModal.vue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. <template>
  2. <Modal :visible="visible" :width="1024" :closeButton="true" @update:visible="val => emit('update:visible', val)">
  3. <div class="wp_tool wp_tool45" v-if="workData">
  4. <div class="wp_t45_title">题目内容</div>
  5. <div
  6. class="s_b_m_toolItem"
  7. v-for="(item, index) in workData.testJson"
  8. :key="index + '_' + workData.id"
  9. >
  10. <div class="s_b_m_ti_title">
  11. <span>{{ index + 1 }}</span>
  12. <svg
  13. width="16"
  14. height="16"
  15. viewBox="0 0 16 16"
  16. fill="none"
  17. xmlns="http://www.w3.org/2000/svg"
  18. >
  19. <path
  20. d="M15.3536 8.35355C15.5488 8.15829 15.5488 7.84171 15.3536 7.64645L12.1716 4.46447C11.9763 4.2692 11.6597 4.2692 11.4645 4.46447C11.2692 4.65973 11.2692 4.97631 11.4645 5.17157L14.2929 8L11.4645 10.8284C11.2692 11.0237 11.2692 11.3403 11.4645 11.5355C11.6597 11.7308 11.9763 11.7308 12.1716 11.5355L15.3536 8.35355ZM1 8.5H15V7.5H1V8.5Z"
  21. fill="#3681FC"
  22. />
  23. </svg>
  24. <span style="display: flex;align-items: center;"
  25. >{{ item.type == 1 ? "单选题:" : "多选题:"
  26. }}<span v-html="renderedFormula(item.teststitle)"></span>
  27. </span>
  28. </div>
  29. <div
  30. class="s_b_m_ti_option"
  31. v-for="(item2, index2) in item.checkList"
  32. :key="index + '_' + index2 + 'index2T'"
  33. :class="{
  34. s_b_m_ti_o_choice:
  35. item.type == '1'
  36. ? workData.testJson[index].userAnswer === index2
  37. : workData.testJson[index].userAnswer.includes(index2)
  38. }"
  39. >
  40. <div class="s_b_m_ti_o_btn">
  41. <span class="s_b_m_ti_o_btn1" v-if="item.type == 1">
  42. <span
  43. v-if="workData.testJson[index].userAnswer === index2"
  44. ></span>
  45. </span>
  46. <span class="s_b_m_ti_o_btn2" v-else>
  47. <span
  48. v-if="workData.testJson[index].userAnswer.includes(index2)"
  49. >
  50. </span>
  51. </span>
  52. </div>
  53. <span>
  54. <img
  55. v-if="item2.imgType && item2.imgType === 1"
  56. :src="item2.src"
  57. alt=""
  58. @click.stop="openPreview(item2)"
  59. />
  60. <span v-else>{{ item2 }}</span>
  61. </span>
  62. </div>
  63. </div>
  64. </div>
  65. </Modal>
  66. <!-- 预览放大(带缩放/拖拽/旋转/工具栏) -->
  67. <Teleport to="body">
  68. <div v-if="previewVisible" class="image-preview" @click.self="closePreview" @wheel.prevent="onWheel">
  69. <div class="image-preview__toolbar">
  70. <button @click.stop="zoomOut">-</button>
  71. <button @click.stop="zoomIn">+</button>
  72. <button @click.stop="resetTransform">重置</button>
  73. <button @click.stop="rotateLeft">⟲</button>
  74. <button @click.stop="rotateRight">⟳</button>
  75. <button @click.stop="toggleFit">{{ fitMode ? '实际大小' : '适应屏幕' }}</button>
  76. <button @click.stop="closePreview">关闭</button>
  77. </div>
  78. <div class="image-preview__stage"
  79. @mousedown="onDragStart"
  80. @mousemove="onDragMove"
  81. @mouseup="onDragEnd"
  82. @mouseleave="onDragEnd"
  83. @dblclick.stop="toggleZoom">
  84. <img :src="imageUrl" alt="预览" class="image-preview__img"
  85. :style="imgStyle" draggable="false" />
  86. </div>
  87. </div>
  88. </Teleport>
  89. </template>
  90. <script lang="ts" setup>
  91. import { computed, ref, watch } from 'vue'
  92. import Modal from '@/components/Modal.vue'
  93. import 'katex/dist/katex.min.css'
  94. import katex from 'katex'
  95. const props = defineProps<{
  96. visible: boolean
  97. work: any
  98. }>()
  99. const emit = defineEmits<{
  100. (e: 'update:visible', v: boolean): void
  101. }>()
  102. const visible = computed({
  103. get: () => props.visible,
  104. set: (v: boolean) => emit('update:visible', v)
  105. })
  106. const renderedFormula = computed(() => {
  107. /**
  108. * 渲染公式文本,支持带HTML标签的内容
  109. * @param {string} val
  110. * @returns {string}
  111. */
  112. const render = (val: string): string => {
  113. if (typeof val !== 'string') return ''
  114. const input: string = val.trim().replace(/[\u200B-\u200D\uFEFF]/g, '')
  115. try {
  116. // 检查是否包含HTML标签
  117. const tagReg = /<([a-zA-Z][\w\-]*)([^>]*)>([\s\S]*?)<\/\1>/g
  118. if (!tagReg.test(input)) {
  119. // 纯文本,整体渲染
  120. try {
  121. return katex.renderToString(input, {
  122. throwOnError: false,
  123. strict: false,
  124. output: 'htmlAndMathml'
  125. })
  126. }
  127. catch {
  128. return input // 渲染失败原样输出
  129. }
  130. }
  131. else {
  132. // 有标签,对每个标签内容渲染
  133. return input.replace(tagReg, (match, tag, attrs, inner) => {
  134. let html = inner
  135. try {
  136. html = katex.renderToString(inner.trim(), {
  137. throwOnError: false,
  138. strict: false,
  139. output: 'htmlAndMathml'
  140. })
  141. }
  142. catch {
  143. // 渲染失败,保留原内容
  144. }
  145. return `<${tag}${attrs}>${html}</${tag}>`
  146. })
  147. }
  148. }
  149. catch (e) {
  150. // console.error('KaTeX渲染错误:', e)
  151. return input
  152. }
  153. }
  154. return render
  155. })
  156. const workData = ref<any>(null)
  157. const previewVisible = ref(false)
  158. const scale = ref(1)
  159. const rotate = ref(0)
  160. const offsetX = ref(0)
  161. const offsetY = ref(0)
  162. const dragging = ref(false)
  163. const lastX = ref(0)
  164. const lastY = ref(0)
  165. const fitMode = ref(true)
  166. const imageUrl = ref('')
  167. watch(() => props.visible, (newVal) => {
  168. if (props.work && newVal) {
  169. workData.value = JSON.parse(decodeURIComponent(props.work.content))
  170. }
  171. }, {
  172. immediate: true,
  173. })
  174. const imgStyle = computed(() => {
  175. const t = `translate(${offsetX.value}px, ${offsetY.value}px) rotate(${rotate.value}deg) scale(${scale.value})`
  176. return {
  177. transform: t
  178. }
  179. })
  180. const openPreview = (item: any) => {
  181. imageUrl.value = item.src
  182. previewVisible.value = true
  183. nextTickFit()
  184. }
  185. const closePreview = () => {
  186. previewVisible.value = false
  187. }
  188. const zoomStep = 0.2
  189. const minScale = 0.2
  190. const maxScale = 6
  191. const zoomIn = () => {
  192. fitMode.value = false; scale.value = Math.min(maxScale, +(scale.value + zoomStep).toFixed(2))
  193. }
  194. const zoomOut = () => {
  195. fitMode.value = false; scale.value = Math.max(minScale, +(scale.value - zoomStep).toFixed(2))
  196. }
  197. const resetTransform = () => {
  198. scale.value = 1; rotate.value = 0; offsetX.value = 0; offsetY.value = 0; fitMode.value = true; nextTickFit()
  199. }
  200. const rotateLeft = () => {
  201. rotate.value = (rotate.value - 90) % 360
  202. }
  203. const rotateRight = () => {
  204. rotate.value = (rotate.value + 90) % 360
  205. }
  206. const toggleZoom = () => {
  207. fitMode.value = false
  208. scale.value = scale.value >= 1.8 ? 1 : 2
  209. }
  210. const toggleFit = () => {
  211. fitMode.value = !fitMode.value
  212. nextTickFit()
  213. }
  214. const nextTickFit = () => {
  215. // 适应屏幕时复位位置与缩放
  216. if (fitMode.value) {
  217. scale.value = 1
  218. offsetX.value = 0
  219. offsetY.value = 0
  220. }
  221. }
  222. const onWheel = (e: WheelEvent) => {
  223. if (e.deltaY > 0) zoomOut()
  224. else zoomIn()
  225. }
  226. const onDragStart = (e: MouseEvent) => {
  227. dragging.value = true
  228. lastX.value = e.clientX
  229. lastY.value = e.clientY
  230. }
  231. const onDragMove = (e: MouseEvent) => {
  232. if (!dragging.value) return
  233. const dx = e.clientX - lastX.value
  234. const dy = e.clientY - lastY.value
  235. lastX.value = e.clientX
  236. lastY.value = e.clientY
  237. offsetX.value += dx
  238. offsetY.value += dy
  239. }
  240. const onDragEnd = () => {
  241. dragging.value = false
  242. }
  243. </script>
  244. <style lang="scss" scoped>
  245. .wp_tool {
  246. width: 100%;
  247. height: auto;
  248. max-height: 80vh;
  249. display: flex;
  250. flex-direction: column;
  251. align-items: center;
  252. padding: 40px 0;
  253. overflow-y: auto;
  254. }
  255. .wp_tool45 {
  256. height: auto;
  257. }
  258. .wp_t45_title {
  259. font-size: 3em;
  260. font-weight: 300;
  261. margin: 20px 0 40px 0;
  262. }
  263. .s_b_m_toolItem {
  264. width: 100%;
  265. height: auto;
  266. margin-bottom: 40px;
  267. }
  268. .s_b_m_ti_option {
  269. width: 100%;
  270. height: auto;
  271. padding: 15px 15px 15px 15px;
  272. display: flex;
  273. flex-wrap: wrap;
  274. background-color: #f3f7fd;
  275. border-radius: 30px;
  276. margin: 10px 0 10px 0px;
  277. box-sizing: border-box;
  278. }
  279. .s_b_m_ti_option > span > img {
  280. max-height: 150px;
  281. border-radius: 2px;
  282. cursor: pointer;
  283. }
  284. .s_b_m_ti_o_btn {
  285. width: 20px;
  286. height: 100%;
  287. min-height: 20px;
  288. display: flex;
  289. justify-content: center;
  290. align-items: center;
  291. margin-right: 10px;
  292. }
  293. .s_b_m_ti_o_btn > span {
  294. width: 20px;
  295. height: 20px;
  296. display: block;
  297. box-sizing: border-box;
  298. border: solid 1px #3681fc;
  299. overflow: hidden;
  300. }
  301. .s_b_m_ti_o_btn > span > span {
  302. width: 100%;
  303. height: 100%;
  304. display: flex;
  305. justify-content: center;
  306. align-items: center;
  307. background-color: #3681fc;
  308. position: relative;
  309. }
  310. .s_b_m_ti_o_btn1 {
  311. border-radius: 50%;
  312. }
  313. .s_b_m_ti_o_btn1 > span::after {
  314. content: "";
  315. width: 8px;
  316. height: 8px;
  317. position: absolute;
  318. background-color: #fff;
  319. border-radius: 50%;
  320. }
  321. .s_b_m_ti_o_btn2 {
  322. border-radius: 2px;
  323. }
  324. .s_b_m_ti_o_btn2 > span::after {
  325. content: "";
  326. width: 8px;
  327. height: 8px;
  328. position: absolute;
  329. background-color: #fff;
  330. }
  331. .s_b_m_ti_o_choice {
  332. border: solid 1px #3681fc;
  333. }
  334. .s_b_m_ti_title {
  335. display: flex;
  336. align-items: center;
  337. }
  338. .s_b_m_ti_title > span:nth-of-type(1) {
  339. font-size: 30px;
  340. font-weight: bold;
  341. color: #3681fc;
  342. }
  343. .s_b_m_ti_title > svg {
  344. width: 30px;
  345. height: 30px;
  346. margin: 0 20px 0 5px;
  347. }
  348. .s_b_m_ti_title > span:nth-of-type(2) {
  349. font-size: 20px;
  350. font-weight: bold;
  351. }
  352. .s_b_m_ti_title > div {
  353. font-size: 30px;
  354. font-weight: bold;
  355. }
  356. </style>