Fill.vue 13 KB

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