Fill.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. <template>
  2. <ModuleBase :type="data.type">
  3. <template #content>
  4. <!-- eslint-disable max-len -->
  5. <div class="fill-wrapper">
  6. <RichText
  7. ref="richText"
  8. v-model="data.content"
  9. :is-fill="true"
  10. toolbar="fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright"
  11. :wordlimit-num="false"
  12. :font-size="data?.unified_attrib?.font_size"
  13. :font-family="data?.unified_attrib?.font"
  14. />
  15. <el-input
  16. v-if="data.property.fill_type === fillTypeList[1].value"
  17. v-model="data.vocabulary"
  18. type="textarea"
  19. :autosize="{ minRows: 2, maxRows: 4 }"
  20. resize="none"
  21. placeholder="请输入词汇,用于选词填空"
  22. />
  23. <span class="tips">在需要加空的内容处插入 3 个或以上的下划线“_”。</span>
  24. <div v-if="data.audio_file_id">
  25. <SoundRecord :wav-blob.sync="data.audio_file_id" />
  26. </div>
  27. <template v-else>
  28. <div :class="['upload-audio-play']">
  29. <UploadAudio
  30. v-if="data.property.audio_generation_method === 'upload'"
  31. :file-id="data.audio_file_id"
  32. :show-upload="!data.audio_file_id"
  33. @upload="uploads"
  34. @deleteFile="deleteFiles"
  35. />
  36. <div v-else-if="data.property.audio_generation_method === 'auto'" class="auto-matic" @click="handleMatic">
  37. <SvgIcon icon-class="voiceprint-line" class="record" />
  38. <span class="auto-btn">{{ data.audio_file_id ? '已生成' : '生成音频' }}</span
  39. >{{ data.audio_file_id ? '成功' : '' }}
  40. </div>
  41. <SoundRecord v-else :wav-blob.sync="data.audio_file_id" />
  42. </div>
  43. </template>
  44. <div>
  45. <el-button @click="identifyText">识别</el-button>
  46. <el-button @click="openMultilingual">多语言</el-button>
  47. </div>
  48. <div class="correct-answer">
  49. <el-input
  50. v-for="(item, i) in data.answer.answer_list.filter(({ type }) => type === 'any_one')"
  51. :key="item.mark"
  52. v-model="item.value"
  53. @blur="handleTone(item.value, i)"
  54. >
  55. <span slot="prefix">{{ i + 1 }}.</span>
  56. </el-input>
  57. </div>
  58. </div>
  59. <el-divider v-if="isEnable(data.property.view_pinyin)" content-position="left">拼音效果</el-divider>
  60. <template v-if="isEnable(data.property.view_pinyin)">
  61. <div v-for="(item, i) in data.model_essay" :key="i" class="pinyin-text-list">
  62. <template v-for="(li, j) in item">
  63. <PinyinText
  64. v-if="li.type === 'text'"
  65. :key="`${i}-${j}`"
  66. ref="PinyinText"
  67. :paragraph-list="li.paragraph_list"
  68. :pinyin-position="data.property.pinyin_position"
  69. @fillCorrectPinyin="fillCorrectPinyin($event, i, j, 'model_essay')"
  70. />
  71. </template>
  72. </div>
  73. </template>
  74. <MultilingualFill
  75. :visible.sync="multilingualVisible"
  76. :text="data.content"
  77. :translations="data.multilingual"
  78. @SubmitTranslation="handleMultilingualTranslation"
  79. />
  80. </template>
  81. </ModuleBase>
  82. </template>
  83. <script>
  84. import ModuleMixin from '../../common/ModuleMixin';
  85. import SoundRecord from '@/views/book/courseware/create/components/question/fill/components/SoundRecord.vue';
  86. import UploadAudio from '@/views/book/courseware/create/components/question/fill/components/UploadAudio.vue';
  87. import PinyinText from '@/components/PinyinText.vue';
  88. import { getFillData, arrangeTypeList, fillFontList, fillTypeList } from '@/views/book/courseware/data/fill';
  89. import { addTone, handleToneValue } from '@/views/book/courseware/data/common';
  90. import { getRandomNumber } from '@/utils';
  91. import { GetStaticResources } from '@/api/app';
  92. export default {
  93. name: 'FillPage',
  94. components: {
  95. SoundRecord,
  96. UploadAudio,
  97. PinyinText,
  98. },
  99. mixins: [ModuleMixin],
  100. data() {
  101. return {
  102. data: getFillData(),
  103. fillTypeList,
  104. };
  105. },
  106. watch: {
  107. 'data.property.arrange_type': 'handleMindMap',
  108. 'data.property.fill_font': 'handleMindMap',
  109. 'data.vocabulary': {
  110. handler(val) {
  111. if (!val) return;
  112. this.data.word_list = val
  113. .split(/[\n\r]+/)
  114. .map((item) => item.split(/\s+/).filter((s) => s))
  115. .flat()
  116. .map((content) => {
  117. return {
  118. content,
  119. mark: getRandomNumber(),
  120. };
  121. });
  122. },
  123. },
  124. 'data.property': {
  125. handler() {
  126. this.handleViewPinyin();
  127. },
  128. deep: true,
  129. },
  130. },
  131. methods: {
  132. // 识别文本
  133. identifyText() {
  134. this.data.model_essay = [];
  135. this.data.answer.answer_list = [];
  136. this.data.content
  137. .split(/<(p|div)[^>]*>(.*?)<\/(p|div)>/g)
  138. .filter((s) => s && !s.match(/^(p|div)$/))
  139. .forEach((item) => {
  140. if (item.charCodeAt() === 10) return;
  141. let str = item
  142. // 去除所有的 font-size 样式
  143. .replace(/font-size:\s*\d+(\.\d+)?px;/gi, '')
  144. // 匹配 class 名为 rich-fill 的 span 标签和三个以上的_,并将它们组成数组
  145. .replace(/<span class="rich-fill".*?>(.*?)<\/span>|([_]{3,})/gi, '###$1$2###');
  146. this.data.model_essay.push(this.splitRichText(str));
  147. });
  148. this.handleViewPinyin();
  149. },
  150. /**
  151. * 分割富文本
  152. * @param {String} str 富文本字符串
  153. * @returns {Array} 分割后的数组
  154. */
  155. splitRichText(str) {
  156. const parts = String(str).split(/###/g);
  157. const arr = [];
  158. for (let i = 0; i < parts.length; i++) {
  159. let content = parts[i] ?? '';
  160. // 偶数索引为普通文本段
  161. if (i % 2 === 0) {
  162. if (content === '') continue; // 跳过空文本块
  163. // 判断 content 最前面是否是标签
  164. const isStartWithTag = /^<[^>]+>/.test(content);
  165. if (!isStartWithTag) {
  166. content = this.setTag(i, parts, content);
  167. }
  168. arr.push({
  169. content,
  170. type: 'text',
  171. paragraph_list: [],
  172. paragraph_list_parameter: {
  173. text: '',
  174. pinyin_proofread_word_list: [],
  175. },
  176. });
  177. continue;
  178. }
  179. // 奇数索引为输入段(被 ### 包裹的内容)
  180. const isUnderline = /^_{3,}$/.test(content);
  181. const mark = getRandomNumber();
  182. arr.push({
  183. content: isUnderline ? '' : content,
  184. type: 'input',
  185. audio_answer_list: [],
  186. mark,
  187. });
  188. // 同步更新答案列表
  189. this.data.answer.answer_list.push({
  190. value: isUnderline ? '' : content,
  191. mark,
  192. type: isUnderline ? 'any_one' : 'only_one',
  193. });
  194. // 将 content 设置为空,为预览准备
  195. arr[arr.length - 1].content = '';
  196. }
  197. return arr;
  198. },
  199. /**
  200. * 设置前一个标签
  201. * @param {Number} index 当前索引
  202. * @param {Array} parts 分割后的数组
  203. * @param {String} content 当前内容
  204. * @returns {String} 包含前一个标签的内容
  205. */
  206. setTag(index, parts, content) {
  207. let _content = content;
  208. const isEndWithTag = /<\/[^>]+>$/.test(_content);
  209. let startTag = '';
  210. for (let j = index - 1; j >= 0; j--) {
  211. const prev = parts[j] ?? '';
  212. const m = prev.match(/^<[^>]+>/);
  213. if (m) {
  214. startTag = m[0];
  215. }
  216. }
  217. _content = `${startTag}${_content}`;
  218. if (!isEndWithTag) {
  219. let tag = startTag.match(/^<([^>\s]+).*?>/);
  220. tag = tag ? tag[1] : 'span';
  221. _content += `</${tag}>`;
  222. }
  223. return _content;
  224. },
  225. handleTone(value, i) {
  226. if (!/^[a-zA-Z0-9\s]+$/.test(value)) return;
  227. this.data.answer.answer_list[i].value = value
  228. .trim()
  229. .split(/\s+/)
  230. .map((item) => {
  231. return handleToneValue(item);
  232. })
  233. .map((item) =>
  234. item.map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || '')),
  235. )
  236. .filter((item) => item.length > 0)
  237. .join(' ');
  238. },
  239. uploads(file_id) {
  240. this.data.audio_file_id = file_id;
  241. },
  242. deleteFiles() {
  243. this.data.audio_file_id = '';
  244. },
  245. // 自动生成音频
  246. handleMatic() {
  247. GetStaticResources('tool-TextToVoiceFile', {
  248. text: this.data.content.replace(/<[^>]+>/g, ''),
  249. })
  250. .then(({ status, file_id }) => {
  251. if (status === 1) {
  252. this.data.audio_file_id = file_id;
  253. }
  254. })
  255. .catch(() => {});
  256. },
  257. /**
  258. * @description 处理思维导图数据
  259. */
  260. handleMindMap() {
  261. const { fill_font, arrange_type } = this.data.property;
  262. const fontLabel = fillFontList.find((item) => item.value === fill_font)?.label || '';
  263. const arrangeLabel = arrangeTypeList.find((item) => item.value === arrange_type)?.label || '';
  264. this.data.mind_map.node_list = [
  265. {
  266. name: `${arrangeLabel}${fontLabel}填空组件`,
  267. },
  268. ];
  269. },
  270. handleViewPinyin() {
  271. if (!this.isEnable(this.data.property.view_pinyin)) {
  272. this.data.model_essay.forEach((item) => {
  273. item.forEach((option) => {
  274. option.paragraph_list = [];
  275. option.paragraph_list_parameter = {
  276. text: '',
  277. pinyin_proofread_word_list: [],
  278. };
  279. });
  280. });
  281. return;
  282. }
  283. if (
  284. this.data.model_essay.length > 0 &&
  285. this.data.model_essay[0].length > 0 &&
  286. this.data.model_essay[0][0].paragraph_list.length > 0
  287. ) {
  288. return;
  289. }
  290. this.data.model_essay.forEach((item, i) => {
  291. item.forEach((option, j) => {
  292. const text = option.content.replace(/<[^>]+>/g, '');
  293. if (!text) return;
  294. option.paragraph_list_parameter.text = text;
  295. this.createParsedTextInfoPinyin(text, i, j, 'model_essay');
  296. });
  297. });
  298. },
  299. },
  300. };
  301. </script>
  302. <style lang="scss" scoped>
  303. .fill-wrapper {
  304. display: flex;
  305. flex-direction: column;
  306. row-gap: 16px;
  307. align-items: flex-start;
  308. :deep .rich-wrapper {
  309. width: 100%;
  310. }
  311. .tips {
  312. font-size: 12px;
  313. color: #999;
  314. }
  315. .auto-matic,
  316. .upload-audio-play {
  317. :deep .upload-wrapper {
  318. margin-top: 0;
  319. }
  320. .audio-wrapper {
  321. :deep .audio-play {
  322. width: 16px;
  323. height: 16px;
  324. color: #000;
  325. background-color: initial;
  326. }
  327. :deep .audio-play.not-url {
  328. color: #a1a1a1;
  329. }
  330. :deep .voice-play {
  331. width: 16px;
  332. height: 16px;
  333. }
  334. }
  335. }
  336. .auto-matic {
  337. display: flex;
  338. flex-shrink: 0;
  339. column-gap: 12px;
  340. align-items: center;
  341. width: 200px;
  342. padding: 5px 12px;
  343. background-color: $fill-color;
  344. border-radius: 2px;
  345. .auto-btn {
  346. font-size: 16px;
  347. font-weight: 400;
  348. line-height: 22px;
  349. color: #1d2129;
  350. cursor: pointer;
  351. }
  352. }
  353. .correct-answer {
  354. display: flex;
  355. flex-wrap: wrap;
  356. gap: 8px;
  357. .el-input {
  358. width: 180px;
  359. :deep &__prefix {
  360. display: flex;
  361. align-items: center;
  362. color: $text-color;
  363. }
  364. }
  365. }
  366. }
  367. .pinyin-text-list {
  368. display: flex;
  369. flex-wrap: wrap;
  370. align-items: center;
  371. }
  372. </style>