| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630 |
- <!-- eslint-disable vue/no-v-html -->
- <template>
- <div class="select-preview" :style="[getAreaStyle(), getComponentStyle()]">
- <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
- <div class="main" :style="getMainStyle()">
- <AudioFill
- v-if="data.audio_file_id.length > 0"
- :color="data.unified_attrib?.topic_color"
- :file-id="data.audio_file_id"
- />
- <div class="fill-wrapper">
- <p>
- <template v-for="(li, i) in modelEssay">
- <template v-if="li.type === 'text'">
- <PinyinText
- v-if="isEnable(data.property.view_pinyin)"
- :key="`text-${i}`"
- class="content"
- :paragraph-list="li.paragraph_list"
- :rich-text-list="li.rich_text_list"
- :pinyin-position="data.property.pinyin_position"
- :is-preview="true"
- />
- <span v-else :key="`text-${i}`" class="html-content" v-html="renderTextBlockContent(li)"></span>
- </template>
- <template v-if="li.type === 'input'">
- <!-- 输入填空 -->
- <template v-if="data.property.fill_type === fillTypeList[0].value">
- <el-input
- :key="`input-${i}`"
- :ref="`input-${li.mark}`"
- v-model="li.input"
- :disabled="disabled"
- :class="[...computedAnswerClass(li.mark)]"
- :style="[
- {
- fontFamily: data.property.fill_font,
- width: (inputWidthMap[li.mark] || data.property.input_default_width) + 'px',
- },
- ]"
- @input="handleInput(li.input, li.mark)"
- />
- </template>
- <!-- 选词填空 -->
- <template v-else-if="data.property.fill_type === fillTypeList[1].value">
- <el-popover :key="`popover-${i}`" placement="top" trigger="click">
- <div class="word-list">
- <span
- v-for="{ content, mark } in data.word_list"
- :key="mark"
- class="word-item"
- @click="handleSelectWord(content, mark, li)"
- v-html="sanitizeHTML(content)"
- >
- </span>
- </div>
- <span
- slot="reference"
- class="select-content"
- :style="[{ minWidth: data.property.input_default_width + 'px' }]"
- v-html="sanitizeHTML(li.input)"
- ></span>
- </el-popover>
- </template>
- <!-- 手写填空 -->
- <template v-else-if="data.property.fill_type === fillTypeList[2].value">
- <span :key="`write-${i}`" class="write-click" @click="handleWriteClick(li.mark)">
- <img
- v-show="li.write_base64"
- style="background-color: #f4f4f4"
- :src="li.write_base64"
- alt="write-show"
- />
- </span>
- </template>
- <!-- 语音填空 -->
- <template v-else-if="data.property.fill_type === fillTypeList[3].value">
- <SoundRecordBox
- ref="record"
- :key="`record-${i}`"
- type="mini"
- :many-times="false"
- class="record-box"
- :attrib="data.unified_attrib"
- :answer-record-list="data.audio_answer_list"
- :task-model="isJudgingRightWrong ? 'ANSWER' : ''"
- @handleWav="handleMiniWav($event, li.mark)"
- />
- </template>
- </template>
- </template>
- </p>
- </div>
- <SoundRecord
- v-if="isEnable(data.property.is_enable_voice_answer)"
- ref="record"
- type="normal"
- class="record-box"
- :attrib="data.unified_attrib"
- :answer-record-list="data.record_list"
- :task-model="isJudgingRightWrong ? 'ANSWER' : ''"
- @handleWav="handleWav"
- />
- <div v-if="showLang" class="lang">
- {{ data.multilingual.find((item) => item.type === getLang())?.translation }}
- </div>
- </div>
- <WriteDialog :visible.sync="writeVisible" @confirm="handleWriteConfirm" />
- <PreviewOperation @showAnswerAnalysis="showAnswerAnalysis" @judgeCorrect="judgeCorrect" @retry="retry" />
- <AnswerCorrect
- :visible.sync="visibleAnswerCorrect"
- :is-check-correct="isCheckCorrect"
- @closeAnswerCorrect="closeAnswerCorrect"
- />
- <AnswerAnalysis
- :visible.sync="visibleAnswerAnalysis"
- :answer-list="data.answer_list"
- :analysis-list="data.analysis_list"
- @closeAnswerAnalysis="closeAnswerAnalysis"
- >
- <div slot="right-answer" class="fill-wrapper">
- <p>
- <template v-for="(li, i) in modelEssay">
- <template v-if="li.type === 'text'">
- <PinyinText
- v-if="isEnable(data.property.view_pinyin)"
- :key="`answer-text-${i}`"
- class="content"
- :paragraph-list="li.paragraph_list"
- :rich-text-list="li.rich_text_list"
- :pinyin-position="data.property.pinyin_position"
- :is-preview="true"
- />
- <span v-else :key="`answer-text-${i}`" class="html-content" v-html="renderTextBlockContent(li)"></span>
- </template>
- <template v-if="li.type === 'input'">
- <span v-show="computedAnswerText(li.mark).length > 0" :key="`answer-${i}`" class="right-answer">
- {{ computedAnswerText(li.mark) }}
- </span>
- </template>
- </template>
- </p>
- </div>
- </AnswerAnalysis>
- </div>
- </template>
- <script>
- import PreviewMixin from '../common/PreviewMixin';
- import AudioFill from './components/AudioFillPlay.vue';
- import SoundRecord from '../../common/SoundRecord.vue';
- import SoundRecordBox from '@/views/book/courseware/preview/components/record_input/SoundRecord.vue';
- import WriteDialog from './components/WriteDialog.vue';
- import { getFillData, fillTypeList, arrangeTypeList, audioPositionList } from '@/views/book/courseware/data/fill';
- export default {
- name: 'FillPreview',
- components: {
- AudioFill,
- SoundRecord,
- SoundRecordBox,
- WriteDialog,
- },
- mixins: [PreviewMixin],
- data() {
- return {
- data: getFillData(),
- fillTypeList,
- modelEssay: [],
- inputWidthMap: {},
- selectedWordList: [], // 用于存储选中的词汇
- writeVisible: false,
- writeMark: '',
- };
- },
- watch: {
- 'data.model_essay': {
- handler(list) {
- if (!list || !Array.isArray(list)) return;
- this.modelEssay = JSON.parse(JSON.stringify(list));
- this.syncAnswerList(list);
- },
- deep: true,
- immediate: true,
- },
- modelEssay: {
- handler(list) {
- if (!list || !Array.isArray(list)) return;
- this.syncAnswerList(list);
- },
- deep: true,
- immediate: true,
- },
- isJudgingRightWrong(val) {
- if (!val) return;
- this.answer.answer_list.forEach(({ mark, value }) => {
- const li = this.modelEssay.find((item) => item.mark === mark);
- if (li) {
- li.input = value;
- }
- });
- this.handleWav(this.answer.record_list);
- },
- 'data.record_list'(val) {
- this.answer.record_list = val;
- },
- },
- methods: {
- handleWav(data) {
- this.data.record_list = data;
- },
- renderContent(content) {
- return this.convertText(this.sanitizeHTML(content));
- },
- /**
- * 渲染文本块内容
- * @param {Object} textBlock 文本块对象,包含纯文本内容和富文本内容
- */
- renderTextBlockContent(textBlock) {
- const richTextList = textBlock?.rich_text_list;
- if (Array.isArray(richTextList) && richTextList.length > 0) {
- const richHtml = richTextList.map((item) => item?.text || '').join('');
- return this.renderContent(richHtml);
- }
- return this.renderContent(textBlock?.content || '');
- },
- syncAnswerList(list) {
- this.answer.answer_list = list
- .map(({ type, input, audio_answer_list, mark }) => {
- if (type === 'input') {
- return {
- value: input,
- mark,
- audio_answer_list,
- write_base64: '',
- };
- }
- return null;
- })
- .filter((item) => item);
- },
- /**
- * 处理小音频录音
- * @param {Object} data 音频数据
- * @param {String} mark 选项标识
- */
- handleMiniWav(data, mark) {
- if (!data || !mark) return;
- const li = this.modelEssay.find((item) => item?.mark === mark);
- if (li) {
- this.$set(li, 'audio_answer_list', data);
- }
- },
- /**
- * 处理选中词汇
- * @param {String} content 选中的词汇内容
- * @param {String} mark 选项标识
- * @param {Object} li 当前输入框对象
- */
- handleSelectWord(content, mark, li) {
- if (!content || !mark || !li) return;
- li.input = content;
- this.selectedWordList.push(mark);
- },
- /**
- * 处理书写区确认
- * @param {String} data 书写区数据
- */
- handleWriteConfirm(data) {
- if (!data) return;
- const li = this.modelEssay.find((item) => item?.mark === this.writeMark);
- if (li) {
- this.$set(li, 'write_base64', data);
- }
- },
- handleWriteClick(mark) {
- this.writeVisible = true;
- this.writeMark = mark;
- },
- getMainStyle() {
- const isRow = this.data.property.arrange_type === arrangeTypeList[0].value;
- const isFront = this.data.property.audio_position === audioPositionList[0].value;
- const isEnableVoice = this.data.property.is_enable_voice_answer === 'true';
- const isHasAudio = this.data.audio_file_id.length > 0;
- let areaList = [
- { name: 'audio', value: '24px' },
- { name: 'fill', value: '1fr' },
- ];
- if (!isHasAudio) {
- areaList.shift();
- }
- if (!isFront && isHasAudio) {
- areaList = areaList.reverse();
- }
- let gridArea = '';
- let gridTemplateRows = '';
- let gridTemplateColumns = '';
- if (isRow) {
- const rowAreas = areaList.map(({ name }) => name);
- const rowCols = areaList.map(({ value }) => value);
- const templateRows = ['auto'];
- if (isEnableVoice) {
- rowAreas.push('record');
- rowCols.push('160px');
- }
- const areaRows = [`"${rowAreas.join(' ')}"`];
- if (this.showLang) {
- areaRows.push(`"${Array(rowAreas.length).fill('lang').join(' ')}"`);
- templateRows.push('auto');
- }
- gridArea = areaRows.join(' ');
- gridTemplateRows = templateRows.join(' ');
- gridTemplateColumns = rowCols.join(' ');
- } else {
- const areaRows = areaList.map(({ name }) => `"${name}"`);
- const templateRows = areaList.map(({ value }) => value);
- if (isEnableVoice) {
- areaRows.push('"record"');
- templateRows.push('32px');
- }
- if (this.showLang) {
- areaRows.push('"lang"');
- templateRows.push('auto');
- }
- gridArea = areaRows.join(' ');
- gridTemplateRows = templateRows.join(' ');
- gridTemplateColumns = '1fr';
- }
- return {
- 'grid-auto-flow': isRow ? 'column' : 'row',
- 'column-gap': isRow && isHasAudio ? '16px' : undefined,
- 'row-gap': isRow || !isHasAudio ? undefined : '8px',
- 'grid-template-areas': gridArea,
- 'grid-template-rows': gridTemplateRows,
- 'grid-template-columns': gridTemplateColumns,
- };
- },
- /**
- * 计算答题对错选项字体颜色
- * @param {string} mark 选项标识
- */
- computedAnswerClass(mark) {
- if (!this.isJudgingRightWrong && !this.isShowRightAnswer) {
- return '';
- }
- let selectOption = this.answer.answer_list.find((item) => item.mark === mark);
- let answerOption = this.data.answer.answer_list.find((item) => item.mark === mark);
- if (!selectOption || !answerOption) return '';
- let selectValue = selectOption.value;
- let answerValue = answerOption.value;
- let answerType = answerOption.type;
- let classList = [];
- let isRight =
- answerType === 'only_one' ? selectValue === answerValue : answerValue.split('/').includes(selectValue);
- if (this.isJudgingRightWrong) {
- isRight ? classList.push('right') : classList.push('wrong');
- }
- if (this.isShowRightAnswer && !isRight) {
- classList.push('show-right-answer');
- }
- return classList;
- },
- /**
- * 计算正确答案文本
- * @param {string} mark 选项标识
- */
- computedAnswerText(mark) {
- let answerOption = this.data.answer.answer_list.find((item) => item.mark === mark);
- if (!answerOption) return '';
- let answerValue = answerOption.value;
- return `${answerValue}`;
- },
- // 重做
- retry() {
- this.modelEssay.forEach((li) => {
- if (li.type === 'input') {
- li.input = '';
- li.write_base64 = '';
- }
- });
- this.selectedWordList = [];
- this.isJudgingRightWrong = false;
- this.isShowRightAnswer = false;
- this.handleWav([]);
- },
- /**
- * 获取无文本内容的数据结构,用于保存为个人模板时的样式模板
- */
- getNoTextContentData() {
- let noTextContentData = JSON.parse(JSON.stringify(this.data));
- const resetFieldMap = {
- model_essay: [],
- content: '',
- audio_file_id: '',
- file_list: [],
- multilingual: [],
- analysis_list: [],
- answer_list: [],
- record_list: [],
- };
- Object.assign(noTextContentData, resetFieldMap);
- if (noTextContentData.answer) {
- noTextContentData.answer.answer_list = [];
- noTextContentData.answer.reference_answer = '';
- }
- return noTextContentData;
- },
- /**
- * 处理输入框内容变化
- * @description 根据输入框值计算所需长度,动态调整输入框宽度,输入框宽度不小于默认宽度,且不超过组件最大宽度 1000px,需要考虑输入框前面的宽度,保证输入框不会超出组件范围
- * @param {String} value 输入框当前值
- * @param {String} mark 选项标识
- */
- handleInput(value, mark) {
- if (!mark) return;
- const text = `${value || ''}`;
- const defaultWidth = Number(this.data?.property?.input_default_width) || 120;
- const fontSize = 16;
- const fontFamily = this.data?.property?.fill_font || 'arial';
- const canvas = document.createElement('canvas');
- const context = canvas.getContext('2d');
- let textWidth = 0;
- if (context) {
- context.font = `${fontSize}pt ${fontFamily}`;
- textWidth = context.measureText(text || ' ').width;
- }
- // 额外留出输入框内边距和边框余量,避免刚好卡边。
- const contentWidth = Math.ceil(textWidth + 24);
- const inputRef = this.$refs[`input-${mark}`];
- const inputVm = Array.isArray(inputRef) ? inputRef[0] : inputRef;
- const inputEl = inputVm?.$el;
- const containerEl = this.$el;
- let availableWidth = 1000;
- if (inputEl && containerEl) {
- const inputRect = inputEl.getBoundingClientRect();
- const containerRect = containerEl.getBoundingClientRect();
- const rightLimit = containerRect.left + Math.min(containerRect.width, 1000);
- availableWidth = Math.floor(rightLimit - inputRect.left - 12);
- }
- const maxWidth = Math.max(40, availableWidth);
- const nextWidth = Math.min(Math.max(contentWidth, defaultWidth), maxWidth);
- this.$set(this.inputWidthMap, mark, nextWidth);
- },
- },
- };
- </script>
- <style lang="scss" scoped>
- @use '@/styles/mixin.scss' as *;
- .select-preview {
- @include preview-base;
- .main {
- display: grid;
- gap: 10px;
- align-items: center;
- }
- .fill-wrapper {
- grid-area: fill;
- font-size: 16pt;
- :deep .pinyin-area .rich-text-container,
- :deep .pinyin-area .pinyin-paragraph {
- display: inline;
- }
- p {
- margin: 0;
- }
- .content {
- display: inline;
- }
- .html-content {
- :deep p {
- display: inline;
- }
- }
- .record-box {
- display: inline-flex;
- align-items: center;
- background-color: #fff;
- border-bottom: 1px solid $font-color;
- }
- .write-click {
- display: inline-block;
- width: 104px;
- height: 32px;
- padding: 0 4px;
- vertical-align: bottom;
- cursor: pointer;
- border-bottom: 1px solid $font-color;
- img {
- width: 100%;
- height: 100%;
- }
- }
- .select-content {
- display: inline-block;
- height: 32px;
- margin: 0 10px;
- vertical-align: bottom;
- cursor: pointer;
- border-bottom: 1px solid $font-color;
- :deep p {
- margin: 0;
- }
- }
- .el-input {
- display: inline-flex;
- align-items: center;
- width: 120px;
- margin: 0 10px;
- vertical-align: bottom;
- &.pinyin :deep input.el-input__inner {
- font-family: 'PINYIN-B', sans-serif;
- }
- &.chinese :deep input.el-input__inner {
- font-family: 'arial', sans-serif;
- }
- &.english :deep input.el-input__inner {
- font-family: 'arial', sans-serif;
- }
- &.right {
- :deep input.el-input__inner {
- color: $right-color;
- }
- }
- &.wrong {
- :deep input.el-input__inner {
- color: $error-color;
- }
- }
- :deep .el-input__inner {
- padding: 0;
- font-size: 16pt;
- color: $font-color;
- text-align: center;
- background-color: #fff;
- border-width: 0;
- border-bottom: 1px solid $font-color;
- border-radius: 0;
- }
- }
- .right-answer {
- position: relative;
- display: inline-block;
- height: 32px;
- margin: 0 4px;
- line-height: 32px;
- vertical-align: bottom;
- border-bottom: 1px solid $font-color;
- }
- }
- .record-box {
- padding: 6px 12px;
- background-color: $fill-color;
- :deep .record-time {
- width: 100px;
- }
- }
- }
- </style>
- <style lang="scss" scoped>
- .word-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
- .word-item {
- cursor: pointer;
- }
- }
- </style>
|