| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950 |
- <!-- eslint-disable vue/no-v-html -->
- <template>
- <ModuleBase ref="base" :type="data.type">
- <template #content>
- <!-- eslint-disable max-len -->
- <div v-loading="loading" class="fill-wrapper">
- <RichText
- v-if="property.isGetContent"
- ref="richText"
- v-model="data.content"
- :is-fill="true"
- toolbar="fontselect fontsizeselect forecolor backcolor | lineheight paragraphSpacing fullwidthspace underline | bold italic strikethrough alignleft aligncenter alignright bullist numlist dotEmphasis"
- :wordlimit-num="false"
- :font-size="data?.unified_attrib?.font_size"
- :font-family="data?.unified_attrib?.font"
- :font-color="data?.unified_attrib?.text_color"
- />
- <div v-if="data.property.fill_type === fillTypeList[1].value" class="select-vocabulary">
- <h5 class="title">选词列表:</h5>
- <el-button size="mini" @click="openAddWord">添加词汇</el-button>
- <ul class="word-list">
- <li v-for="(item, index) in data.word_list" :key="item.mark" class="word-item">
- <span v-html="sanitizeHTML(item.content)"></span>
- <el-button type="text" size="mini" class="delete-word" @click="editWord(index)">
- <SvgIcon icon-class="edit" size="14" />
- </el-button>
- <el-button type="text" size="mini" class="delete-word" style="margin-left: 0" @click="removeWord(index)">
- <SvgIcon icon-class="delete-black" size="12" />
- </el-button>
- </li>
- </ul>
- </div>
- <span class="tips">在需要加空的内容处插入 3 个或以上的下划线“_”。</span>
- <el-divider content-position="left">
- <span>显示效果</span>
- <el-button
- type="text"
- icon="el-icon-refresh"
- title="刷新"
- class="refresh-pinyin-btn"
- @click.native="parsedContentPinyin(true)"
- />
- </el-divider>
- <div class="pinyin-wrapper">
- <div
- v-for="(group, gi) in groupedModelEssay"
- :key="`model-essay-group-${gi}`"
- class="model-essay-group"
- :style="groupStyle(group)"
- >
- <div v-for="(item, i) in group.items" :key="`item-${group.indexes[i]}`" class="pinyin-text-list">
- <PinyinText
- :key="`pinyin-${group.indexes[i]}`"
- ref="PinyinText"
- :is-show-pinyin="isEnable(data.property.view_pinyin)"
- :rich-text-list="item.rich_text_list"
- :pinyin-position="data.property.pinyin_position"
- :body-styles="pinyinBodyStyles"
- @fillCorrectPinyin="fillCorrectPinyin($event, group.indexes[i], -1, 'model_essay')"
- />
- </div>
- </div>
- </div>
- <div v-if="data.audio_file_id">
- <SoundRecord :wav-blob.sync="data.audio_file_id" />
- </div>
- <template v-else>
- <div :class="['upload-audio-play']">
- <UploadAudio
- v-if="data.property.audio_generation_method === 'upload'"
- :file-id="data.audio_file_id"
- :show-upload="!data.audio_file_id"
- @upload="uploads"
- @deleteFile="deleteFiles"
- />
- <div v-else-if="data.property.audio_generation_method === 'auto'" class="auto-matic" @click="handleMatic">
- <SvgIcon icon-class="voiceprint-line" class="record" />
- <span class="auto-btn">{{ data.audio_file_id ? '已生成' : '生成音频' }}</span
- >{{ data.audio_file_id ? '成功' : '' }}
- </div>
- <SoundRecord v-else :wav-blob.sync="data.audio_file_id" />
- </div>
- </template>
- <div>
- <el-button @click="parsedContentPinyin()">识别</el-button>
- <el-button @click="openMultilingual">多语言</el-button>
- <WordProofread button-style="margin-left: 10px" :word-data="wordData" @save="saveWord" />
- </div>
- <div v-if="data.answer.answer_list.length > 0" class="title">答案:</div>
- <div class="correct-answer">
- <el-input
- v-for="(item, i) in data.answer.answer_list.filter(({ type }) => type === 'any_one')"
- :key="item.mark"
- v-model="item.value"
- placeholder="多个答案可用‘/’分割"
- @blur="handleTone(item.value, item)"
- >
- <span slot="prefix">{{ i + 1 }}.</span>
- </el-input>
- </div>
- </div>
- <MultilingualFill
- :visible.sync="multilingualVisible"
- :text="data.content"
- :translations="data.multilingual"
- @SubmitTranslation="handleMultilingualTranslation"
- />
- <AnswerAnalysisList
- v-if="data.answer_list?.length > 0 || data.analysis_list?.length > 0"
- :answer-list="data.answer_list"
- :analysis-list="data.analysis_list"
- :unified-attrib="data.unified_attrib"
- @updateAnswerAnalysisFileList="updateAnswerAnalysisFileList"
- @deleteAnswerAnalysis="deleteAnswerAnalysis"
- />
- <AddWord :visible.sync="visibleWord" :word="editingWord" @add-word="addWord" @edit-word="editWordConfirm" />
- </template>
- </ModuleBase>
- </template>
- <script>
- import ModuleMixin from '../../common/ModuleMixin';
- import SoundRecord from '@/views/book/courseware/create/components/question/fill/components/SoundRecord.vue';
- import UploadAudio from '@/views/book/courseware/create/components/question/fill/components/UploadAudio.vue';
- import PinyinText from '@/components/PinyinText.vue';
- import AddWord from './components/AddWord.vue';
- import WordProofread from '@/views/book/courseware/create/components/common/WordProofread.vue';
- import { buildWordProofreadData } from '@/views/book/courseware/create/components/common/wordProofread';
- import { getFillData, arrangeTypeList, fillTypeList } from '@/views/book/courseware/data/fill';
- import { addTone, handleToneValue, mergePunctuationToWords } from '@/views/book/courseware/data/common';
- import { getRandomNumber } from '@/utils';
- import { TextToAudioFile } from '@/api/app';
- import { sanitizeHTML } from '@/utils/common';
- import { PinyinBuild_OldFormat } from '@/api/book';
- export default {
- name: 'FillPage',
- components: {
- SoundRecord,
- UploadAudio,
- PinyinText,
- AddWord,
- WordProofread,
- },
- mixins: [ModuleMixin],
- data() {
- return {
- data: getFillData(),
- fillTypeList,
- sanitizeHTML,
- visibleWord: false,
- editingIndex: null, // 当前编辑的词汇索引,null 表示新增模式
- wordData: { detail: [] }, // 分词校对数据
- inited: false,
- loading: false,
- };
- },
- computed: {
- editingWord() {
- if (this.editingIndex === null || this.editingIndex === undefined) return null;
- return this.data.word_list[this.editingIndex] || null;
- },
- pinyinBodyStyles() {
- const unifiedAttrib = this.data?.unified_attrib || {};
- return {
- fontFamily: unifiedAttrib.font || undefined,
- fontSize: unifiedAttrib.font_size || undefined,
- color: unifiedAttrib.text_color || undefined,
- };
- },
- /**
- * 按段落对齐方式对 model_essay 分组,便于整体应用 text-align,
- * 让同一段内的文字块与填空连续排列(可换行),而不是每块独占一行。
- * @returns {Array} [{ align, items, indexes }]
- */
- groupedModelEssay() {
- const groups = [];
- (this.data.model_essay || []).forEach((item, index) => {
- const align = item.align || '';
- const last = groups[groups.length - 1];
- if (last && last.align === align) {
- last.items.push(item);
- last.indexes.push(index);
- } else {
- groups.push({ align, items: [item], indexes: [index] });
- }
- });
- return groups;
- },
- },
- watch: {
- 'data.property.arrange_type': 'handleMindMap',
- 'data.property.fill_font': 'handleMindMap',
- 'data.content': {
- handler(newVal) {
- if (!this.inited && newVal) {
- this.ensureWordProofreadData().finally(() => {
- this.inited = true;
- });
- }
- },
- },
- 'data.property.view_pinyin': {
- handler(val) {
- if (this.property.isSilentPropertyWatch) return;
- if (val === 'true') {
- this.parsedContentPinyin();
- }
- },
- deep: true,
- },
- },
- methods: {
- /**
- * 分组容器样式:有对齐时应用块级与对齐,无对齐时保持行内流式。
- * @param {Object} group 分组对象
- * @returns {Object} 样式对象
- */
- groupStyle(group) {
- if (!group || !group.align) return {};
- return {
- display: 'block',
- textAlign: group.align,
- };
- },
- async ensureWordProofreadData() {
- this.wordData = { detail: [{ sentences: [], segList: [] }] };
- this.data.rich_text_list?.forEach((item) => {
- let sentence = (item.word_list || []).reduce((acc, itemss) => `${acc + itemss.text}`, '');
- let seg = (item.word_list || []).map((itemss) => itemss.text);
- this.wordData.detail[0].sentences.push(sentence);
- this.wordData.detail[0].segList.push(seg);
- });
- },
- /**
- * 解析文本内容,生成分词和拼音
- * @param {boolean} isPrompt 是否为提示操作,默认为 false
- */
- async parsedContentPinyin(isPrompt = false, fcList = []) {
- if (isPrompt) {
- try {
- await this.$confirm('刷新将会重置填空和拼音,是否刷新?', '提示', {
- confirmButtonText: '确定',
- cancelButtonText: '取消',
- type: 'warning',
- });
- } catch (e) {
- return;
- }
- }
- this.loading = true;
- PinyinBuild_OldFormat({
- text: this.data.content,
- is_first_sentence_first_hz_pinyin_first_char_upper_case:
- this.data.property.is_first_sentence_first_hz_pinyin_first_char_upper_case,
- is_fill_space: 'true',
- is_rich_text: 'true',
- is_custom_fc: fcList.length > 0,
- fc_paragraph_list: fcList,
- })
- .then(({ rich_text, parsed_text }) => {
- if (!rich_text) return;
- if (!Array.isArray(rich_text.text_list)) return;
- this.data.rich_text_list = rich_text.text_list.map((item) => {
- // 非 word_list 项,原样返回
- if (item.is_style === 'true' || !Array.isArray(item.word_list)) {
- return item;
- }
- return {
- ...item,
- word_list: mergePunctuationToWords(item.word_list),
- };
- });
- this.wordData = buildWordProofreadData(parsed_text?.paragraph_list || []);
- this.data.model_essay = this.parseRichText();
- })
- .finally(() => {
- this.loading = false;
- });
- },
- async saveWord(saveArr) {
- await this.parsedContentPinyin(false, saveArr);
- },
- /**
- * 判断文本是否包含可见内容(去除 HTML 标签后是否仍有非空白字符)
- * @param {String} content 文本内容
- * @returns {Boolean} 是否包含可见内容
- */
- hasVisibleText(content = '') {
- return (
- String(content)
- .replace(/<[^>]*>/g, '')
- .trim().length > 0
- );
- },
- /**
- * 追加文本块;当文本块不含可见内容(仅闭合标签/空白)时,
- * 并入前一个文本块,避免孤立闭合标签破坏 PinyinText 渲染。
- * @param {Array} blocks 目标文本块数组
- * @param {String} content 文本内容
- * @param {Array} richTextList 富文本列表
- */
- appendTextBlock(blocks, content, richTextList = []) {
- if (!this.hasVisibleText(content) && blocks.length > 0) {
- // 填空(input)属于当前段落的一部分:若段落以填空结尾,紧随其后的闭合标签和
- // 段间换行 \n 不能合并到填空前面的文本块中,否则 PinyinText 会把 \n 渲染成
- // <br> 出现在填空之前,导致填空换到下一行显示。此时将换行保留为填空后的独立文本块。
- const lastBlock = blocks[blocks.length - 1];
- if (lastBlock && lastBlock.type === 'input' && String(content).includes('\n')) {
- blocks.push({
- content,
- type: 'text',
- rich_text_list: richTextList,
- });
- return;
- }
- for (let i = blocks.length - 1; i >= 0; i--) {
- if (blocks[i] && blocks[i].type === 'text') {
- blocks[i].content = `${blocks[i].content || ''}${content}`;
- blocks[i].rich_text_list = [...(blocks[i].rich_text_list || []), ...richTextList];
- return;
- }
- }
- }
- blocks.push({
- content,
- type: 'text',
- rich_text_list: richTextList,
- });
- },
- /**
- * 判断最后一个文本块是否为块级(居中对齐)。
- * 居中的块级段落自身已产生换行,其后的段间 \n 是冗余的。
- * @param {Array} blocks 已生成的文本块数组
- * @returns {Boolean}
- */
- isLastTextBlockAligned(blocks) {
- for (let i = blocks.length - 1; i >= 0; i--) {
- const block = blocks[i];
- if (!block || block.type !== 'text') continue;
- return (block.rich_text_list || []).some((item) => {
- const tagText = String(item?.text || '');
- return /^<(p|div|h[1-6]|li|blockquote)\b[^>]*>/i.test(tagText) && /text-align\s*:\s*center/i.test(tagText);
- });
- }
- return false;
- },
- /**
- * 提取每个块的对齐方式并写入 align 字段,同时从块级标签中移除 text-align,
- * 避免 PinyinText 为每个块设置 display:block,导致段内文字被拆成多行。
- * 对齐效果改由上层按 align 分组后整体应用。
- * @param {Array} blocks 文本块数组
- * @returns {Array} 处理后的文本块数组
- */
- finalizeAlign(blocks) {
- return (blocks || []).map((block) => {
- let align = block.align || '';
- const richTextList = (block.rich_text_list || []).map((item) => {
- const tagText = String(item?.text || '');
- if (!/^<(p|div|h[1-6]|li|blockquote)\b[^>]*>/i.test(tagText)) return item;
- const alignMatch = tagText.match(/text-align\s*:\s*([^;"']+)/i);
- if (!alignMatch) return item;
- if (!align) align = alignMatch[1].trim();
- return { ...item, text: tagText.replace(/text-align\s*:\s*[^;"']+;?\s*/i, '') };
- });
- return { ...block, align, rich_text_list: richTextList };
- });
- },
- // 解析富文本,构建 model_essay 结构
- parseRichText() {
- let text_list = this.data.rich_text_list || [];
- const preservedAnyOneAnswers = (this.data.answer.answer_list || [])
- .filter(({ type }) => type === 'any_one')
- .map(({ value }) => value);
- const preservedAnyOneState = { index: 0 };
- this.data.answer.answer_list = [];
- const arr = [];
- let totalText = '';
- let totalRichText = [];
- const openStyleTagStack = [];
- for (let i = 0; i < text_list.length; i++) {
- const textItem = text_list[i];
- const text = textItem.text || '';
- const isStyle = textItem.is_style === 'true' || textItem.is_style === true;
- if (isStyle) {
- const isRichFill = /class=\s*(\\?["'])rich-fill\1/.test(text);
- if (isRichFill) {
- if (totalText.length > 0) {
- this.appendTextBlock(arr, totalText, totalRichText);
- totalText = '';
- totalRichText = [];
- }
- let nextData = text_list[i + 1] || {};
- let nextTag = text_list[i + 2] || {};
- const mark = getRandomNumber();
- arr.push({
- content: text + (nextData.text || '') + (nextTag.text || ''),
- type: 'input',
- input: '',
- audio_answer_list: [],
- mark,
- rich_text_list: [textItem, nextData, nextTag],
- });
- this.data.answer.answer_list.push({
- value: nextData.text || '',
- mark,
- type: 'only_one',
- });
- // 跳过下一个文本和标签数据
- i += 2;
- } else {
- totalText += text;
- totalRichText = totalRichText.concat(textItem);
- this.syncOpenStyleTagStack(openStyleTagStack, textItem);
- }
- } else {
- // 段间空白(如 </p> 之后的 \n):前一段落为块级(带对齐)时冗余,丢弃;
- // 否则保留作为段落换行分隔,避免破坏正常换行结构。
- if (!String(text).trim() && this.isLastTextBlockAligned(arr)) {
- // 先把累积的闭合标签并入前一个文本块,保持标签配对
- if (totalText.length > 0) {
- this.appendTextBlock(arr, totalText, totalRichText);
- totalText = '';
- totalRichText = [];
- }
- continue;
- }
- const splitBlocks = this.splitTextItemByUnderline(textItem, preservedAnyOneAnswers, preservedAnyOneState);
- const currentOpenStyleTags = openStyleTagStack.map((tagItem) => ({ ...tagItem }));
- const firstBlockPrefix =
- totalText.length > 0
- ? this.buildFirstBlockStylePrefix(totalRichText, currentOpenStyleTags)
- : currentOpenStyleTags;
- // 样式标签单独成块会导致 PinyinText 的样式栈断开,需并入紧随其后的文本块。
- if (splitBlocks.length > 0) {
- if (totalText.length > 0) {
- splitBlocks[0].content = `${totalText}${splitBlocks[0].content || ''}`;
- }
- splitBlocks.forEach((block, blockIndex) => {
- const prevRichTextList = block.rich_text_list || [];
- block.rich_text_list = [
- ...(blockIndex === 0 ? firstBlockPrefix : currentOpenStyleTags),
- ...prevRichTextList,
- ];
- });
- totalText = '';
- totalRichText = [];
- } else if (totalText.length > 0) {
- this.appendTextBlock(arr, totalText, firstBlockPrefix);
- totalText = '';
- totalRichText = [];
- }
- splitBlocks.forEach((block) => {
- if (block.type === 'text') {
- this.appendTextBlock(arr, block.content, block.rich_text_list);
- } else {
- arr.push(block);
- }
- });
- }
- }
- if (totalText.length > 0) {
- this.appendTextBlock(arr, totalText, totalRichText);
- }
- return this.finalizeAlign(arr);
- },
- /**
- * 从标签文本中提取标签名称,支持开标签和闭标签
- * @param {String} tagText 标签文本,如 "<b>"、"</b>"、"<br/>"
- * @returns {String} 标签名称,如 "b"、"br",如果不是有效标签则返回空字符串
- */
- getStyleTagName(tagText = '') {
- const trimmedText = String(tagText).trim();
- const closeMatch = trimmedText.match(/^<\/(\w+)>$/);
- if (closeMatch) return closeMatch[1].toLowerCase();
- const openMatch = trimmedText.match(/^<(\w+)([^>]*)>$/);
- if (openMatch) return openMatch[1].toLowerCase();
- return '';
- },
- /**
- * 判断标签项是否为有效的开标签(非自闭合标签且非换行标签)
- * @param {Object} tagItem 标签项对象,包含 text 和 is_style 属性
- * @returns {Boolean} 是否为开标签
- */
- isOpenStyleTag(tagItem = {}) {
- const tagText = String(tagItem?.text || '').trim();
- const isStyleTag = tagItem?.is_style === 'true' || tagItem?.is_style === true;
- if (!isStyleTag) return false;
- if (/^<\//.test(tagText)) return false;
- if (/^<br\s*\/?\s*>$/i.test(tagText)) return false;
- return /^<\w+[^>]*>$/.test(tagText);
- },
- /**
- * 同步更新样式标签栈,遇到开标签入栈,遇到闭标签出栈
- * @param {Array} openStyleTagStack 当前的开标签栈
- * @param {Object} styleTagItem 当前处理的标签项对象
- */
- syncOpenStyleTagStack(openStyleTagStack, styleTagItem) {
- const tagText = String(styleTagItem?.text || '').trim();
- const isStyleTag = styleTagItem?.is_style === 'true' || styleTagItem?.is_style === true;
- if (!isStyleTag) return;
- if (/^<br\s*\/?\s*>$/i.test(tagText)) return;
- const closeMatch = tagText.match(/^<\/(\w+)>$/);
- if (closeMatch) {
- const closeTagName = closeMatch[1].toLowerCase();
- for (let i = openStyleTagStack.length - 1; i >= 0; i--) {
- const tagName = this.getStyleTagName(openStyleTagStack[i]?.text);
- if (tagName === closeTagName) {
- openStyleTagStack.splice(i, 1);
- break;
- }
- }
- return;
- }
- if (this.isOpenStyleTag(styleTagItem)) {
- openStyleTagStack.push(styleTagItem);
- }
- },
- /**
- * 获取开标签的身份标识,用于匹配开闭标签,通常是标签名称加上关键属性(如 class),以区分不同样式的同名标签
- * @param {Object} tagItem 标签项对象,包含 text 和 is_style 属性
- * @return {String} 开标签的身份标识,如 "b"、"span.class1",如果不是有效开标签则返回空字符串
- */
- getOpenStyleTagIdentity(tagItem = {}) {
- return String(tagItem?.text || '')
- .trim()
- .replace(/\s+/g, ' ');
- },
- /**
- * 构建文本块的样式前缀,包含当前仍应处于打开状态的样式标签(外层→内层)
- * 以及过渡标签中的换行等不影响样式栈的特殊项。
- * 开标签已由 currentOpenStyleTags 完整覆盖,闭标签在独立渲染的块中无法
- * 匹配到上一块的开标签,需丢弃,避免误关外层同名标签。
- * @param {Array} transitionStyleTags 过渡样式标签列表,可能包含开标签和闭标签
- * @param {Array} currentOpenStyleTags 当前仍应处于打开状态的样式标签列表
- * @returns {Array} 构建好的样式前缀列表
- */
- buildFirstBlockStylePrefix(transitionStyleTags = [], currentOpenStyleTags = []) {
- // 仅保留换行等不影响样式栈的特殊项(br 不入栈,但需要渲染换行)
- const lineBreakTags = transitionStyleTags.filter((tagItem) =>
- /^<br\s*\/?\s*>$/i.test(String(tagItem?.text || '').trim()),
- );
- return [...currentOpenStyleTags, ...lineBreakTags];
- },
- /**
- * 根据文本中的连续下划线分割文本块,并将下划线部分转换为输入块
- * @param {Object} textItem 富文本中的一个文本项
- * @param {Array} preservedAnyOneAnswers 预先保存的 any_one 类型答案列表
- * @param {Object} preservedAnyOneState any_one 答案的当前索引状态
- */
- splitTextItemByUnderline(textItem, preservedAnyOneAnswers = [], preservedAnyOneState = { index: 0 }) {
- const text = textItem?.text || '';
- const matcher = /_{3,}/g;
- const blocks = [];
- let lastIndex = 0;
- let match = matcher.exec(text);
- while (match) {
- const underlineText = match[0] || '';
- const start = match.index;
- const end = start + underlineText.length;
- if (start > lastIndex) {
- const textContent = text.slice(lastIndex, start);
- blocks.push({
- content: textContent,
- type: 'text',
- rich_text_list: [
- {
- ...textItem,
- text: textContent,
- word_list: this.sliceWordListByTextRange(textItem.word_list, lastIndex, start),
- },
- ],
- });
- }
- const mark = getRandomNumber();
- blocks.push({
- content: underlineText,
- type: 'input',
- input: '',
- audio_answer_list: [],
- mark,
- rich_text_list: [
- {
- ...textItem,
- text: underlineText,
- word_list: this.sliceWordListByTextRange(textItem.word_list, start, end),
- },
- ],
- });
- this.data.answer.answer_list.push({
- value: preservedAnyOneAnswers[preservedAnyOneState.index] || '',
- mark,
- type: 'any_one',
- });
- preservedAnyOneState.index += 1;
- lastIndex = end;
- match = matcher.exec(text);
- }
- if (lastIndex < text.length) {
- const textContent = text.slice(lastIndex);
- blocks.push({
- content: textContent,
- type: 'text',
- rich_text_list: [
- {
- ...textItem,
- text: textContent,
- word_list: this.sliceWordListByTextRange(textItem.word_list, lastIndex, text.length),
- },
- ],
- });
- }
- if (blocks.length === 0) {
- return [
- {
- content: text,
- type: 'text',
- rich_text_list: [textItem],
- },
- ];
- }
- return blocks;
- },
- /**
- * 根据文本范围切割词列表,返回切割后的词列表
- * @param {Array} wordList 富文本项中的词列表,每个词项包含 text 和其他属性
- * @param {Number} rangeStart 切割范围的起始位置(相对于文本开头的字符索引)
- * @param {Number} rangeEnd 切割范围的结束位置(相对于文本开头的字符索引)
- * @returns {Array} 切割后的词列表,包含在切割范围内的词项,且每个词项的 text 已根据切割范围调整
- */
- sliceWordListByTextRange(wordList = [], rangeStart = 0, rangeEnd = 0) {
- if (!Array.isArray(wordList) || rangeEnd <= rangeStart) return [];
- const result = [];
- let cursor = 0;
- wordList.forEach((wordItem) => {
- const wordText = wordItem?.text || '';
- const wordStart = cursor;
- const wordEnd = wordStart + wordText.length;
- cursor = wordEnd;
- const overlapStart = Math.max(rangeStart, wordStart);
- const overlapEnd = Math.min(rangeEnd, wordEnd);
- if (overlapStart >= overlapEnd) return;
- const relativeStart = overlapStart - wordStart;
- const relativeEnd = overlapEnd - wordStart;
- result.push(this.sliceWordItem(wordItem, relativeStart, relativeEnd));
- });
- return result;
- },
- /**
- * 根据文本范围切割单个词项,返回切割后的词项
- * @param {Object} wordItem 词项对象,包含 text 和其他属性
- * @param {Number} start 切割范围的起始位置(相对于词项的字符索引)
- * @param {Number} end 切割范围的结束位置(相对于词项的字符索引)
- * @returns {Object} 切割后的词项,包含在切割范围内的字符,且 text 已根据切割范围调整
- */
- sliceWordItem(wordItem, start, end) {
- const fullText = wordItem?.text || '';
- const slicedText = fullText.slice(start, end);
- const slicedWord = {
- ...wordItem,
- text: slicedText,
- };
- const sourcePinyinList = Array.isArray(wordItem?.pinyin_list) ? wordItem.pinyin_list : [];
- if (sourcePinyinList.length === fullText.length) {
- slicedWord.pinyin_list = sourcePinyinList.slice(start, end);
- slicedWord.pinyin = (slicedWord.pinyin_list || []).join(' ').trim();
- return slicedWord;
- }
- if (start === 0 && end === fullText.length) {
- slicedWord.pinyin_list = [...sourcePinyinList];
- return slicedWord;
- }
- slicedWord.pinyin_list = new Array(slicedText.length).fill('');
- slicedWord.pinyin = '';
- return slicedWord;
- },
- handleTone(value, item) {
- if (!item || !/^[a-zA-Z0-9\s]+$/.test(value)) return;
- item.value = value
- .trim()
- .split(/\s+/)
- .map((valItem) => {
- return handleToneValue(valItem);
- })
- .map((valItem) =>
- valItem.map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || '')),
- )
- .filter((valItem) => valItem.length > 0)
- .join(' ');
- },
- uploads(file_id) {
- this.data.audio_file_id = file_id;
- },
- deleteFiles() {
- this.data.audio_file_id = '';
- },
- // 自动生成音频
- handleMatic() {
- TextToAudioFile({
- text: this.data.content.replace(/<[^>]+>/g, ''),
- voice_type: this.data.property.voice_type,
- emotion: this.data.property.emotion,
- speed_ratio: this.data.property.speed_ratio,
- })
- .then(({ status, file_id }) => {
- if (status === 1) {
- this.data.audio_file_id = file_id;
- }
- })
- .catch(() => {});
- },
- /**
- * @description 处理思维导图数据
- */
- handleMindMap() {
- const { arrange_type } = this.data.property;
- const arrangeLabel = arrangeTypeList.find((item) => item.value === arrange_type)?.label || '';
- this.data.mind_map.node_list = [
- {
- name: `${arrangeLabel}填空组件`,
- },
- ];
- },
- handleViewPinyin() {
- if (!this.isEnable(this.data.property.view_pinyin)) {
- return;
- }
- this.data.model_essay.forEach((item, i) => {
- item.forEach((option, j) => {
- const text = option.content;
- option.paragraph_list_parameter.text = text;
- this.createParsedTextInfoPinyin(text, i, j, 'model_essay');
- });
- });
- },
- openAddWord() {
- this.editingIndex = null;
- this.visibleWord = true;
- },
- /**
- * 编辑词汇
- * @param {number} index 词汇在 word_list 中的索引
- */
- editWord(index) {
- this.editingIndex = index;
- this.visibleWord = true;
- },
- /**
- * 确认编辑词汇
- * @param {string} word 编辑后的词汇内容
- */
- editWordConfirm(word) {
- if (!word) return;
- if (this.editingIndex === null || this.editingIndex === undefined) return;
- this.$set(this.data.word_list, this.editingIndex, {
- ...this.data.word_list[this.editingIndex],
- content: word,
- });
- this.editingIndex = null;
- },
- /**
- * 添加词汇
- * @param {string} word 词汇内容
- */
- addWord(word) {
- if (!word) return;
- this.data.word_list.push({
- content: word,
- mark: getRandomNumber(),
- });
- },
- removeWord(index) {
- this.data.word_list.splice(index, 1);
- },
- },
- };
- </script>
- <style lang="scss" scoped>
- .fill-wrapper {
- display: flex;
- flex-direction: column;
- row-gap: 16px;
- align-items: flex-start;
- :deep .rich-wrapper {
- width: 100%;
- }
- .select-vocabulary {
- .title {
- margin: 0 0 8px;
- }
- .word-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 8px;
- .word-item {
- display: flex;
- gap: 6px;
- align-items: center;
- padding: 4px 6px;
- border: $border;
- border-radius: 4px;
- :deep p {
- margin: 0;
- }
- .delete-word {
- display: flex;
- align-items: center;
- padding: 0;
- line-height: 1;
- }
- }
- }
- }
- .tips {
- font-size: 12px;
- color: #999;
- }
- .auto-matic,
- .upload-audio-play {
- :deep .upload-wrapper {
- margin-top: 0;
- }
- .audio-wrapper {
- :deep .audio-play {
- width: 16px;
- height: 16px;
- color: #000;
- background-color: initial;
- }
- :deep .audio-play.not-url {
- color: #a1a1a1;
- }
- :deep .voice-play {
- width: 16px;
- height: 16px;
- }
- }
- }
- .auto-matic {
- display: flex;
- flex-shrink: 0;
- column-gap: 12px;
- align-items: center;
- width: 200px;
- padding: 5px 12px;
- background-color: $fill-color;
- border-radius: 2px;
- .auto-btn {
- font-size: 16px;
- font-weight: 400;
- line-height: 22px;
- color: #1d2129;
- cursor: pointer;
- }
- }
- .correct-answer {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- .el-input {
- width: 180px;
- :deep &__prefix {
- display: flex;
- align-items: center;
- color: $text-color;
- }
- }
- }
- }
- .pinyin-wrapper {
- .model-essay-group {
- display: inline;
- }
- .pinyin-text-list {
- display: inline;
- :deep .pinyin-area {
- display: inline;
- }
- :deep .pinyin-area .rich-text-container,
- :deep .pinyin-area .pinyin-paragraph,
- :deep .pinyin-area .pinyin-sentence {
- display: inline;
- }
- }
- }
- </style>
|