2026-02-05 12:47:24 +08:00

1575 lines
48 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="question-item">
<!-- 左侧题目区域 -->
<scroll-view :class="['left', { expanded: isLeftExpanded }]" scroll-y :show-scrollbar="false">
<!-- 父题目 -->
<view v-if="data?.pidTitle" class="left-title">
<rich-text :nodes="filterTitleWithoutImages(data.pidTitle)" />
</view>
<!-- 主题目优先用 titleMu null 则用 title -->
<view class="left-title">
<rich-text :nodes="filterTitleWithoutImages(data?.titleMu || data?.title)" />
</view>
<!-- 题目中的图片单独显示可点击预览 -->
<view v-if="titleImages.length" class="title-images">
<image
v-for="(img, imgIdx) in titleImages"
:key="imgIdx"
:src="img"
mode="widthFix"
@click="previewTitleImage(imgIdx)"
/>
</view>
<!-- 音频按钮 -->
<view v-if="data?.voiceUrl" class="left-voice" @click="playAudio">
<image
:src="isAudioPlay
? `${OSS_URL}/icon/horn_active.svg`
: `${OSS_URL}/icon/horn.svg`"
mode="aspectFit"
/>
</view>
<!-- 子题目 -->
<view v-if="data?.children?.length" class="left-children">
<view v-for="(child, cidx) in data.children" :key="child.id" class="child-title">
<rich-text :nodes="filterTitleWithoutImages(child.title, cidx)" />
</view>
</view>
</scroll-view>
<!-- 分隔线 + 折叠按钮 -->
<view :class="['divider-wrapper', { 'at-right': isLeftExpanded }]">
<view class="divider"></view>
<view class="fold-btn" @click="toggleLeftExpand">
<text>{{ isLeftExpanded ? '收起' : '展开' }}</text>
<view :class="['fold-arrow', { rotated: !isLeftExpanded }]"></view>
</view>
</view>
<!-- 右侧答题区域 -->
<scroll-view :class="['right', { collapsed: isLeftExpanded }]" scroll-y :show-scrollbar="false">
<!-- 批改结果 -->
<view v-if="reportFlag" class="result-box">
<view :class="['result', resultClass.class]">
<text>我的答案</text>
<view class="emoji-box">
<image :src="`${OSS_URL}/icon/${resultClass.icon}.svg`" mode="aspectFit" />
<text>{{ resultClass.text }}</text>
</view>
</view>
</view>
<!-- 答题区域 -->
<view class="answer-area">
<!-- 单选题 -->
<template v-if="questionType === 'single'">
<view class="choice-list">
<view
v-for="(option, oIdx) in optionList"
:key="oIdx"
:class="['choice-item', { active: isSelected(option.key), correct: showCorrect(option.key), wrong: showWrong(option.key) }]"
@click="selectOption(option.key)"
>
<view class="choice-key">{{ option.key }}</view>
<view class="choice-content">
<rich-text :nodes="option.value" />
</view>
</view>
</view>
</template>
<!-- 多选题 -->
<template v-else-if="questionType === 'multiple'">
<view class="choice-list">
<view
v-for="(option, oIdx) in optionList"
:key="oIdx"
:class="['choice-item multiple', { active: isMultiSelected(option.key) }]"
@click="selectMultiOption(option.key)"
>
<view class="choice-key">{{ option.key }}</view>
<view class="choice-content">
<rich-text :nodes="option.value" />
</view>
</view>
</view>
</template>
<!-- 判断题 -->
<template v-else-if="questionType === 'judgment'">
<view class="judgment-list">
<view
:class="['judgment-item', { active: submitAnswer === '对' || submitAnswer === 'A' }]"
@click="selectJudgment('对')"
>
<image :src="`${OSS_URL}/icon/icon_correct.svg`" mode="aspectFit" />
<text>正确</text>
</view>
<view
:class="['judgment-item', { active: submitAnswer === '错' || submitAnswer === 'B' }]"
@click="selectJudgment('错')"
>
<image :src="`${OSS_URL}/icon/icon_wrong.svg`" mode="aspectFit" />
<text>错误</text>
</view>
</view>
</template>
<!-- 填空题 -->
<template v-else-if="questionType === 'input'">
<view class="input-area">
<view v-for="idx in fillBlankNum" :key="idx" class="input-item">
<text class="input-label">{{ idx }}</text>
<input
class="input-field"
:value="getInputValue(idx - 1)"
:disabled="!!reportFlag"
placeholder="请输入答案"
@input="onInputChange($event, idx - 1)"
/>
</view>
</view>
</template>
<!-- 其他题型上传图片 - 支持多图 -->
<template v-else>
<view class="upload-area">
<!-- 提示文字 -->
<view v-if="!reportFlag" class="upload-tip">
<text class="tip-icon"></text>
<text>请将答案写在纸上点击下方拍照上传答案按钮拍照上传最多上传3张图片</text>
</view>
<!-- 已上传的图片列表 -->
<view v-if="picList.length" class="pic-list">
<view
v-for="(pic, pIdx) in picList"
:key="pIdx"
class="pic-item"
>
<image :src="pic" mode="aspectFill" @click="previewPicList(pIdx)" />
<view v-if="!reportFlag" class="delete-btn" @click="deletePic(pic)">
<text>×</text>
</view>
</view>
<!-- 添加更多按钮最多10张 -->
<view v-if="!reportFlag && picList.length < 3" class="add-btn" @click="chooseImage">
<text>+</text>
</view>
</view>
<!-- 未上传时显示上传按钮 -->
<view v-else-if="!reportFlag" class="upload-btn" @click="chooseImage">
<!-- 有些自定义 button 组件不会冒泡 click这里把事件直接绑在按钮上避免点了没反应 -->
<mj-button
class="upload-btn-button"
type="primary"
@click.stop="chooseImage"
@tap.stop="chooseImage"
>
拍照上传答案
</mj-button>
</view>
<!-- 报告模式无图片 -->
<view v-else class="empty-tip">
<text>未作答</text>
</view>
</view>
</template>
</view>
<!-- 答案与解析查看报告时显示 -->
<template v-if="reportFlag">
<!-- 参考答案 -->
<view v-if="data?.answerMu || data?.answer" class="answer-box">
<view class="answer-box-title">参考答案</view>
<view class="answer-box-main">
<rich-text :nodes="filterTitle(data?.answerMu || data?.answer)" />
</view>
</view>
<!-- 解析 -->
<view v-if="(data?.titleAnalyzeMu || data?.titleAnalyze) || data?.titleAnalyzePicture" class="answer-box">
<view class="answer-box-title">解析</view>
<view v-if="data?.titleAnalyzeMu || data?.titleAnalyze" class="answer-box-main analyze">
<rich-text :nodes="filterTitle(data?.titleAnalyzeMu || data?.titleAnalyze)" />
</view>
<view v-if="data.titleAnalyzePicture" class="answer-box-main analyze-pic">
<image
v-for="(pic, pIdx) in data.titleAnalyzePicture.split(',')"
:key="pIdx"
:src="pic"
mode="widthFix"
@click="previewAnalyzeImage(pic)"
/>
</view>
</view>
<!-- AI答案接口返回 data.aiAnswer 不为空时显示 -->
<view v-if="data?.aiAnswer != null " class="answer-box">
<view class="answer-box-title">AI答案</view>
<view class="answer-box-main analyze">
<rich-text :nodes="filterTitle(typeof data.aiAnswer === 'string' ? data.aiAnswer : String(data.aiAnswer))" />
</view>
</view>
</template>
</scroll-view>
<!-- 图片裁剪组件 -->
<qy-image-cropper
:visible="showCropper"
:imageSrc="cropperImageSrc"
:outputWidth="750"
:outputHeight="750"
@update:visible="showCropper = $event"
@confirm="handleCropConfirm"
@cancel="handleCropCancel"
/>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue';
import { uploadFile } from '../../../api/upload';
const props = defineProps<{
data?: any;
idx: number;
total: number;
resourceType?: string;
reportFlag: 0 | 1;
subjectId?: string;
}>();
const emit = defineEmits(['submit']);
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
// 音频状态
const isAudioPlay = ref(false);
let audioContext: any = null;
// 左侧展开状态(展开时隐藏右侧答题区域)
const isLeftExpanded = ref(false);
// 切换左侧展开
const toggleLeftExpand = () => {
isLeftExpanded.value = !isLeftExpanded.value;
};
// 从标题中提取图片 URL仅内容图排除 formula-img支持 src 在任意属性位置)
const titleImages = computed(() => {
// 优先使用 titleMu为 null 则用 title
const title = props.data?.titleMu || props.data?.title || '';
const pidTitle = props.data?.pidTitle || '';
const allTitle = pidTitle + title;
const imgRegex = /<img[^>]*\s*src=["']([^"']+)["'][^>]*\/?>/gi;
const images: string[] = [];
let match;
while ((match = imgRegex.exec(allTitle)) !== null) {
if (/formula-img/i.test(match[0])) continue;
const url = (match[1] || '').trim().replace(/http:\/\//gi, 'https://');
if (url) images.push(url);
}
return images;
});
// 过滤:只移除题目内容图,保留 formula-img后端预渲染的公式图并处理 u 标签
const filterTitleWithoutImages = (title?: string, cidx?: number | string) => {
if (!title) return '';
// 只移除非公式的 img保留 class="formula-img" 的公式图(后端已返回带公式图的 HTML
let t = title.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
// 处理空的 u 标签,添加 class 以便显示下划线
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
t = t.replace(/http:\/\//gi, 'https://');
// 先修复数学符号(< > 转义、AUB→避免被 HTML 解析截断
t = fixMathSymbolsInHtml(t);
// 处理公式
t = processFormula(t);
if (cidx !== undefined) {
t = `(${Number(cidx) + 1})` + t;
}
return t;
};
// 预览标题图片
const previewTitleImage = (index: number) => {
uni.previewImage({
urls: titleImages.value,
current: titleImages.value[index],
});
};
// 处理 rich-text 点击(小程序中 rich-text 内的图片无法直接点击)
const handleTitleClick = (e: any, title: string) => {
// 此处无法直接获取点击的图片,图片预览通过单独的 image 组件实现
};
const handleRichTextClick = (e: any) => {
// rich-text 点击事件处理
};
// 本地提交答案
const submitAnswer = ref('');
const submitAnswerPic = ref('');
// 监听题目变化,初始化答案
watch(
() => props.data?.id,
() => {
submitAnswer.value = props.data?.submitAnswer || '';
submitAnswerPic.value = props.data?.submitAnswerPic || '';
// 停止音频
if (audioContext) {
audioContext.stop();
isAudioPlay.value = false;
}
},
{ immediate: true },
);
// 修复题目中的数学符号:避免 < > 被当作 HTML 解析导致内容丢失,并将 AUB 等显示为正确符号
const fixMathSymbolsInHtml = (html: string): string => {
if (html == null || typeof html !== 'string') return '';
let t = html;
// 小号连字符/减号 ﹣ (U+FE63) 显示过小,统一替换为正常大小的减号 (U+2212)
t = t.replace(/\uFE63/g, '\u2212');
// 将非标签的 < 转义,避免 " -2 < x < 2" 等被当作标签截断
t = t.replace(/<(?![a-zA-Z/!])/g, '&lt;');
// 将数学比较中的 > 转义(仅空格包围的情况,避免破坏标签)
t = t.replace(/ > /g, ' &gt; ');
// 集合并集AUB -> AB题目中常写作 AUB需显示为并集符号
t = t.replace(/AUB/g, 'AB');
// 集合写法 [x| 有时被截断,确保 ≤ ≥ 不被误解析(已是单字符,一般无问题)
return t;
};
// LaTeX 渲染服务 URL使用 CodeCogs 在线渲染)
const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?';
// 将 LaTeX 公式转为图片 URL
const latexToImageUrl = (latex: string): string => {
if (!latex) return '';
// 预处理:将非标准 LaTeX 写法转换为标准格式
let formula = latex.trim();
// 中文标点符号映射表(完整覆盖所有可能的中文标点)
const punctuationMap: Record<string, string> = {
'': ',', // 逗号
'。': '.', // 句号
'': ':', // 冒号
'': ';', // 分号
'': '!', // 感叹号
'': '?', // 问号
'': '(', // 左括号
'': ')', // 右括号
'【': '[', // 左方括号
'】': ']', // 右方括号
'': '{', // 左花括号
'': '}', // 右花括号
'《': '<', // 左书名号
'》': '>', // 右书名号
"\u201c": '"', // 左双引号 "
"\u201d": '"', // 右双引号 "
"\u2018": "'", // 左单引号 '
"\u2019": "'", // 右单引号 '
'': '+', // 加号
'': '-', // 全角减号
'\u2212': '-', // Unicode 数学减号 MINUS SIGN
'×': '*', // 乘号转为星号LaTeX 中用 \times
'÷': '/', // 除号(转为斜杠)
'': '=', // 等号
'': '<', // 小于号
'': '>', // 大于号
'': '%', // 百分号
'': '&', // 和号
'': '*', // 星号
'': '@', // at符号
'': '#', // 井号
'': '$', // 美元符号
'': '^', // 脱字符
'': '~', // 波浪号
'': '|', // 竖线
'': '\\', // 反斜杠
'': '/', // 斜杠
' ': ' ', // 全角空格
};
// 批量替换中文标点
for (const [cn, en] of Object.entries(punctuationMap)) {
formula = formula.split(cn).join(en);
}
// Unicode 数学符号到 LaTeX 的转换映射
const mathSymbolMap: Record<string, string> = {
// 集合符号
'∈': ' \\in ', // 属于
'∉': ' \\notin ', // 不属于
'⊂': ' \\subset ', // 真子集
'⊃': ' \\supset ', // 真超集
'⊆': ' \\subseteq ', // 子集
'⊇': ' \\supseteq ', // 超集
'': ' \\cup ', // 并集
'∩': ' \\cap ', // 交集
'∅': ' \\emptyset ', // 空集
// 比较符号
'≤': ' \\leq ', // 小于等于
'≥': ' \\geq ', // 大于等于
'≠': ' \\neq ', // 不等于
'≈': ' \\approx ', // 约等于
'≡': ' \\equiv ', // 恒等于
'∝': ' \\propto ', // 正比于
// 运算符号
'±': ' \\pm ', // 正负
'∓': ' \\mp ', // 负正
'×': ' \\times ', // 乘号
'÷': ' \\div ', // 除号
'·': ' \\cdot ', // 点乘
'∘': ' \\circ ', // 复合
// 希腊字母
'α': '\\alpha ',
'β': '\\beta ',
'γ': '\\gamma ',
'δ': '\\delta ',
'ε': '\\varepsilon ',
'θ': '\\theta ',
'λ': '\\lambda ',
'μ': '\\mu ',
'σ': '\\sigma ',
'φ': '\\varphi ',
'ω': '\\omega ',
'Δ': '\\Delta ',
'Σ': '\\Sigma ',
'Ω': '\\Omega ',
// 其他常用符号
'∞': '\\infty ', // 无穷
'∂': '\\partial ', // 偏导
'∇': '\\nabla ', // 梯度
'∫': '\\int ', // 积分
'∑': '\\sum ', // 求和
'∏': '\\prod ', // 连乘
'√': '\\sqrt ', // 根号
'∠': '\\angle ', // 角
'⊥': '\\perp ', // 垂直
'∥': '\\parallel ', // 平行
'→': '\\to ', // 箭头
'⇒': '\\Rightarrow ', // 双线箭头
'⇔': '\\Leftrightarrow ', // 双向箭头
'∀': '\\forall ', // 任意
'∃': '\\exists ', // 存在
'¬': '\\neg ', // 非
'∧': '\\land ', // 与
'': '\\lor ', // 或
};
// 批量替换数学符号
for (const [symbol, latex] of Object.entries(mathSymbolMap)) {
formula = formula.split(symbol).join(latex);
}
// 处理 R+ 等集合表示R+ → R^+
formula = formula.replace(/\b([RNZQC])\+/g, '$1^+');
formula = formula.replace(/\b([RNZQC])\*/g, '$1^*');
formula = formula
// \overset{\rightarrow}{x} -> \vec{x}(简化向量表示)
.replace(/\\overset\s*\{\s*\\rightarrow\s*\}\s*\{([^}]+)\}/g, '\\vec{$1}')
// \text{π} -> \pi
.replace(/\\text\s*\{\s*π\s*\}/g, '\\pi')
// \text{sin/cos/tan/log} -> \sin/\cos/\tan/\log标准 LaTeX 函数)
.replace(/\\text\s*\{\s*sin\s*\}/gi, '\\sin')
.replace(/\\text\s*\{\s*cos\s*\}/gi, '\\cos')
.replace(/\\text\s*\{\s*tan\s*\}/gi, '\\tan')
.replace(/\\text\s*\{\s*log\s*\}/gi, '\\log')
// 直接的 Unicode π 转为 \pi
.replace(/π/g, '\\pi');
// 对公式进行 URL 编码
const encoded = encodeURIComponent(formula);
return `${LATEX_RENDER_URL}\\inline&space;\\dpi{200}&space;${encoded}`;
};
// 将一段公式内的简单 LaTeX 转为可读文本(三角函数、希腊字母、括号等),供整段或 \frac 分子分母使用
const applySimpleLatex = (s: string): string => {
if (!s) return s;
let t = s.trim();
// \text{sin/cos/tan/log} -> sin/cos/tan/log
t = t.replace(/\\text\s*\{\s*sin\s*\}/gi, 'sin');
t = t.replace(/\\text\s*\{\s*cos\s*\}/gi, 'cos');
t = t.replace(/\\text\s*\{\s*tan\s*\}/gi, 'tan');
t = t.replace(/\\text\s*\{\s*log\s*\}/gi, 'log');
t = t.replace(/\\text\s*\{\s*π\s*\}/g, 'π');
t = t.replace(/\\pi\b/g, 'π');
// 希腊字母
t = t.replace(/\\alpha\b/g, 'α');
t = t.replace(/\\beta\b/g, 'β');
t = t.replace(/\\gamma\b/g, 'γ');
t = t.replace(/\\theta\b/g, 'θ');
t = t.replace(/\\Delta\b/g, 'Δ');
// \left( \right) 等
t = t.replace(/\\left\s*\(/g, '(');
t = t.replace(/\\right\s*\)/g, ')');
t = t.replace(/\\left\s*\[/g, '[');
t = t.replace(/\\right\s*\]/g, ']');
t = t.replace(/\\left\s*\|/g, '|');
t = t.replace(/\\right\s*\|/g, '|');
// 去掉剩余反斜杠(如 \text 中未匹配到的)
t = t.replace(/\\/g, '');
return t;
};
// 判断公式是否为简单公式(可以直接用文本显示,不需要图片渲染)
const isSimpleFormula = (formula: string): boolean => {
// 需要图片渲染的复杂 LaTeX 命令
const complexPatterns = [
/\\frac\b/, // 分数
/\\sqrt\b/, // 根号
/\\sum\b/, // 求和
/\\int\b/, // 积分
/\\prod\b/, // 连乘
/\\lim\b/, // 极限
/\\vec\b/, // 向量
/\\overline\b/, // 上划线
/\\underline\b/, // 下划线
/\\overset\b/, // 上标符号
/\\underset\b/, // 下标符号
/\\binom\b/, // 二项式
/\\matrix\b/, // 矩阵
/\\begin\b/, // 环境
/\^{[^}]+}/, // 复杂上标(如 x^{2n}
/_{[^}]+}/, // 复杂下标(如 a_{n+1}
];
return !complexPatterns.some(pattern => pattern.test(formula));
};
// 将简单公式转换为可显示的 HTML 文本
const simpleFormulaToHtml = (formula: string): string => {
let text = formula;
// 处理 \text{} 命令
text = text.replace(/\\text\s*\{([^}]*)\}/g, '$1');
// 希腊字母转换
const greekMap: Record<string, string> = {
'\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\delta': 'δ',
'\\epsilon': 'ε', '\\varepsilon': 'ε', '\\theta': 'θ', '\\lambda': 'λ',
'\\mu': 'μ', '\\pi': 'π', '\\sigma': 'σ', '\\phi': 'φ', '\\varphi': 'φ',
'\\omega': 'ω', '\\Delta': 'Δ', '\\Sigma': 'Σ', '\\Omega': 'Ω',
};
for (const [latex, symbol] of Object.entries(greekMap)) {
text = text.split(latex).join(symbol);
}
// 数学函数
text = text.replace(/\\sin\b/g, 'sin');
text = text.replace(/\\cos\b/g, 'cos');
text = text.replace(/\\tan\b/g, 'tan');
text = text.replace(/\\log\b/g, 'log');
text = text.replace(/\\ln\b/g, 'ln');
// 符号转换
text = text.replace(/\\infty\b/g, '∞');
text = text.replace(/\\pm\b/g, '±');
text = text.replace(/\\times\b/g, '×');
text = text.replace(/\\div\b/g, '÷');
text = text.replace(/\\leq\b/g, '≤');
text = text.replace(/\\geq\b/g, '≥');
text = text.replace(/\\neq\b/g, '≠');
text = text.replace(/\\in\b/g, '∈');
text = text.replace(/\\subset\b/g, '⊂');
text = text.replace(/\\cup\b/g, '');
text = text.replace(/\\cap\b/g, '∩');
text = text.replace(/\\emptyset\b/g, '∅');
text = text.replace(/\\cdot\b/g, '·');
text = text.replace(/\\to\b/g, '→');
// 处理简单上下标x^2 -> x², a_n -> aₙ
const superscriptMap: Record<string, string> = {
'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',
'5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹',
'+': '⁺', '-': '⁻', 'n': 'ⁿ',
};
const subscriptMap: Record<string, string> = {
'0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄',
'5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉',
'+': '₊', '-': '₋', 'n': 'ₙ', 'i': 'ᵢ', 'k': 'ₖ',
};
// 简单上标 x^2
text = text.replace(/\^([0-9n+\-])/g, (_, char) => superscriptMap[char] || `^${char}`);
// 简单下标 a_n
text = text.replace(/_([0-9nik+\-])/g, (_, char) => subscriptMap[char] || `_${char}`);
// 移除 \left \right
text = text.replace(/\\left\s*/g, '');
text = text.replace(/\\right\s*/g, '');
// 移除剩余的反斜杠
text = text.replace(/\\/g, '');
return `<span class="formula-text" style="font-style:italic;">${text}</span>`;
};
// 处理数学公式:使用在线 LaTeX 渲染服务
const processFormula = (html: string): string => {
if (html == null || typeof html !== 'string') return '';
if (!html) return '';
// 匹配 <span ... data-value="..." ... data-w-e-type="formula" ...></span> 格式
// 使用能正确处理属性值中包含 > 符号的正则(如 "a> 0"
const formulaRegex = /<span\s+((?:[^<>"']+|"[^"]*"|'[^']*')*)data-w-e-type=["']formula["']((?:[^<>"']+|"[^"]*"|'[^']*')*)>[\s\S]*?<\/span>/gi;
return html.replace(formulaRegex, (match) => {
const valueMatch = match.match(/data-value=["']([^"']+)["']/);
if (!valueMatch) return match;
let formula = valueMatch[1]
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"')
.replace(/\\'/g, "'");
// 如果公式为空或只有空格,返回空
if (!formula.trim()) return '';
// 判断是否为简单公式
if (isSimpleFormula(formula)) {
// 简单公式直接转为文本显示
return simpleFormulaToHtml(formula);
}
// 复杂公式使用 LaTeX 图片渲染
const imageUrl = latexToImageUrl(formula);
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" />`;
});
};
// 选项列表 - 单选题/多选题:仅用 optionsMu 或 options不用 optionList
// options/optionsMu 可能是 JSON 字符串,需先解析为数组
const optionList = computed(() => {
const data = props.data;
let raw =
data?.optionsMu != null
? data.optionsMu
: data?.options != null
? data.options
: null;
// options/optionsMu 为 JSON 字符串时解析为数组
if (typeof raw === 'string') {
try {
raw = JSON.parse(raw);
} catch (e) {
console.warn('optionList: options/optionsMu JSON 解析失败', e);
raw = null;
}
}
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
if (list.length === 0) return [];
return list.map((item: any, idx: number) => {
let value = '';
if (typeof item === 'string') {
value = String(item || '');
} else {
const v = item?.value ?? item?.optionValue ?? item;
value = v != null && v !== '' ? String(v) : '';
}
value = fixMathSymbolsInHtml(value);
value = processFormula(value);
value = typeof value === 'string' ? value.replace(/http:\/\//gi, 'https://') : '';
// 选项内容为空时显示占位,避免只看到选项字母没有内容
// if (!value || (typeof value === 'string' && !value.trim())) {
// value = '';
// }
return {
key:
typeof item === 'string'
? String.fromCharCode(65 + idx)
: (item.key || item.optionKey || item.label || String.fromCharCode(65 + idx)),
value: value,
};
});
});
// 填空数量
const fillBlankNum = computed(() => {
return props.data?.fillBlankNum || 0;
});
// 题目类型
const questionType = computed(() => {
const data = props.data;
if (!data) return 'other';
// 判断题
if (['判断', '判断题'].includes(data.titleChannelTypeName)) {
return 'judgment';
}
// 多选题
if (data.titleChannelTypeName === '多选题') {
return 'multiple';
}
// 填空题
if (data.fillBlankNum) {
return 'input';
}
// 单选题
if (optionList.value.length > 0) {
return 'single';
}
return 'other';
});
// 批改结果
const resultClass = computed(() => {
const data = props.data;
if (data?.correctResult || data?.markFlag) {
return {
class: 'success',
text: '答对啦',
icon: 'emoji_success',
};
}
// 同时检查文字答案和图片答案
const hasAnswer = data?.submitAnswer || data?.submitAnswerPic;
return {
class: 'error',
text: hasAnswer ? '答错了' : '未作答',
icon: 'emoji_fail',
};
});
// 过滤标题内容(解析等场景)
const filterTitle = (title?: string, cidx?: number | string) => {
if (!title) return '';
let t = title.replace(/http:\/\//gi, 'https://');
t = fixMathSymbolsInHtml(t);
t = processFormula(t);
if (cidx !== undefined) {
t = `(${Number(cidx) + 1})` + t;
}
return t;
};
// 单选题相关
const isSelected = (key: string) => {
return submitAnswer.value === key;
};
const showCorrect = (key: string) => {
if (!props.reportFlag) return false;
return props.data?.answer?.includes(key);
};
const showWrong = (key: string) => {
if (!props.reportFlag) return false;
return submitAnswer.value === key && !props.data?.answer?.includes(key);
};
const selectOption = (key: string) => {
if (props.reportFlag) return;
submitAnswer.value = key;
emit('submit', { submitAnswer: key, toNext: true });
};
// 多选题相关
const isMultiSelected = (key: string) => {
return submitAnswer.value.includes(key);
};
const selectMultiOption = (key: string) => {
if (props.reportFlag) return;
let answer = submitAnswer.value;
if (answer.includes(key)) {
answer = answer.replace(key, '');
} else {
answer += key;
}
// 排序
answer = answer.split('').sort().join('');
submitAnswer.value = answer;
emit('submit', { submitAnswer: answer });
};
// 判断题
const selectJudgment = (value: string) => {
if (props.reportFlag) return;
submitAnswer.value = value;
emit('submit', { submitAnswer: value, toNext: true });
};
// 填空题
const getInputValue = (idx: number) => {
const answers = submitAnswer.value.split(',');
return answers[idx] || '';
};
const onInputChange = (e: any, idx: number) => {
const value = e.detail.value;
const answers = submitAnswer.value.split(',');
while (answers.length <= idx) {
answers.push('');
}
answers[idx] = value;
submitAnswer.value = answers.join(',');
emit('submit', { submitAnswer: submitAnswer.value });
};
// 图片列表(支持多图,逗号分隔)
const picList = computed(() => {
if (!submitAnswerPic.value) return [];
return submitAnswerPic.value
.split(',')
.map((url: string) => url.replace(/http:\/\//gi, 'https://').trim())
.filter(Boolean);
});
// 裁剪组件相关
const showCropper = ref(false);
const cropperImageSrc = ref('');
// 图片上传
const chooseImage = () => {
const remainCount = 3 - picList.value.length;
if (remainCount <= 0) {
uni.showToast({ title: '最多上传3张图片', icon: 'none' });
return;
}
uni.chooseImage({
count: 1, // 每次选择一张,选择后立即裁剪
sizeType: ['original'], // 使用原图,裁剪后再压缩
sourceType: ['camera', 'album'],
success: async (res) => {
const filePath = res.tempFilePaths[0];
// 显示裁剪界面
cropperImageSrc.value = filePath;
showCropper.value = true;
},
fail: (err) => {
console.error('选择图片失败', err);
uni.showToast({ title: '选择图片失败', icon: 'none' });
},
});
};
// 裁剪确认
const handleCropConfirm = (filePath: string) => {
uploadImage(filePath);
};
// 裁剪取消
const handleCropCancel = () => {
showCropper.value = false;
cropperImageSrc.value = '';
};
const uploadImage = async (filePath: string) => {
uni.showLoading({ title: '上传中...' });
try {
const uploadRes: any = await uploadFile(filePath);
// 追加新图片到列表
const newUrl = uploadRes.url || uploadRes.filePath;
const currentList = picList.value;
const newList = [...currentList, newUrl];
submitAnswerPic.value = newList.join(',');
emit('submit', { submitAnswerPic: submitAnswerPic.value });
uni.hideLoading();
} catch (error: any) {
uni.hideLoading();
uni.showToast({ title: error.message || '上传失败', icon: 'none' });
}
};
// 删除单张图片
const deletePic = (url: string) => {
const newList = picList.value.filter((pic: string) => pic !== url);
submitAnswerPic.value = newList.join(',');
emit('submit', { submitAnswerPic: submitAnswerPic.value });
};
// 预览图片列表
const previewPicList = (index: number) => {
uni.previewImage({
urls: picList.value,
current: index,
});
};
const previewAnalyzeImage = (url: string) => {
uni.previewImage({
urls: props.data.titleAnalyzePicture.split(','),
current: url,
});
};
// 音频播放
const playAudio = () => {
if (!props.data?.voiceUrl) return;
if (!audioContext) {
audioContext = uni.createInnerAudioContext();
audioContext.onPlay(() => {
isAudioPlay.value = true;
});
audioContext.onPause(() => {
isAudioPlay.value = false;
});
audioContext.onEnded(() => {
isAudioPlay.value = false;
});
audioContext.onError(() => {
isAudioPlay.value = false;
});
}
if (isAudioPlay.value) {
audioContext.pause();
} else {
audioContext.src = props.data.voiceUrl;
audioContext.play();
}
};
// 组件卸载时清理
onUnmounted(() => {
if (audioContext) {
audioContext.destroy();
audioContext = null;
}
});
</script>
<style lang="scss" scoped>
.question-item {
width: 100%;
height: 100%;
display: flex;
position: relative;
background: rgba(255, 255, 255, 0.95);
border-radius: 24rpx;
overflow: hidden;
}
// 分隔线容器
.divider-wrapper {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 50rpx;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
transition: left 0.3s ease;
&.at-right {
left: calc(100% - 30rpx);
}
}
.divider {
flex: 1;
width: 4rpx;
margin-top: 32rpx;
background: repeating-linear-gradient(
to bottom,
#cdd5ff,
#cdd5ff 16rpx,
transparent 16rpx,
transparent 32rpx
);
border-radius: 4rpx;
}
// 折叠按钮
.fold-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8rpx 4rpx;
background: linear-gradient(135deg, #e8f4fd 0%, #d6ebfa 100%);
border-radius: 8rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
text {
font-size: 10rpx;
color: #4F8EF7;
writing-mode: vertical-lr;
letter-spacing: 2rpx;
}
.fold-arrow {
width: 12rpx;
height: 12rpx;
margin-top: 4rpx;
position: relative;
transition: transform 0.3s ease;
&::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 8rpx;
height: 8rpx;
border-left: 2rpx solid #4F8EF7;
border-bottom: 2rpx solid #4F8EF7;
transform: translate(-50%, -70%) rotate(50deg);
}
&.rotated {
transform: rotate(180deg);
}
}
}
.left {
width: 50%;
height: 100%;
padding: 10rpx;
padding-right: 40rpx;
box-sizing: border-box;
transition: width 0.3s ease;
&.expanded {
width: calc(100% - 60rpx);
padding-right: 10rpx;
}
.left-title {
padding-top: 5rpx;
padding-left: 10rpx;
font-family: Arial;
font-size: 12rpx;
color: $font-color;
line-height: 1.6;
:deep(image) {
max-width: 100%;
height: auto;
}
// 填空题的下划线样式
:deep(.fill-blank) {
display: inline-block;
min-width: 40rpx;
height: 1em;
border-bottom: 2rpx solid $font-color;
margin: 0 4rpx;
vertical-align: baseline;
}
// 公式文本样式:使用数学字体风格
:deep(.formula-text) {
font-size: 15rpx;
font-family: 'Times New Roman', 'STIXGeneral', Georgia, serif;
font-style: italic;
color: #1a1a1a;
letter-spacing: 0.5rpx;
vertical-align: baseline;
}
// 上下标样式:用 span 控制,避免 Unicode 字符过小
:deep(.sup) {
font-size: 12rpx;
line-height: 1;
vertical-align: super;
}
:deep(.sub) {
font-size: 12rpx;
line-height: 1;
vertical-align: sub;
}
// 分数样式:模拟图片那种上下结构,字体大小与公式文本一致
:deep(.fraction) {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14rpx;
line-height: 1.1;
vertical-align: middle;
}
:deep(.fraction .top) {
padding: 0 4rpx;
border-bottom: 2rpx solid currentColor;
}
:deep(.fraction .bottom) {
padding: 0 4rpx;
}
}
// 题目图片(可点击预览)
.title-images {
padding: 10rpx;
display: flex;
flex-wrap: wrap;
gap: 16rpx;
image {
max-width: 100%;
border-radius: 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
}
.left-voice {
padding-left: 32rpx;
margin-top: 24rpx;
image {
width: 80rpx;
height: 80rpx;
}
}
.left-children {
padding-left: 32rpx;
margin-top: 24rpx;
.child-title {
font-size: 28rpx;
color: $font-color;
margin-bottom: 16rpx;
}
}
}
.right {
width: 50%;
height: 100%;
padding: 10rpx;
padding-left: 40rpx;
box-sizing: border-box;
transition: width 0.3s ease, opacity 0.2s ease;
&.collapsed {
width: 0;
padding: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
}
}
.result-box {
margin-bottom: 10rpx;
.result {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12rpx;
height: 40rpx;
border-radius: 8rpx;
position: relative;
font-family: $font-special;
font-size: 12rpx;
color: $font-color;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 6rpx;
height: 100%;
background: #669cfe;
border-radius: 0 16rpx 16rpx 0;
}
&.error {
background: linear-gradient(90deg, rgba(255, 191, 191, 0) 0%, #ffbfbf 100%);
}
&.success {
background: linear-gradient(90deg, rgba(221, 255, 140, 0) 0%, #ddff8c 100%);
}
.emoji-box {
display: flex;
align-items: center;
image {
width: 20rpx;
height: 20rpx;
margin-right: 8rpx;
}
}
}
}
.choice-list {
.choice-item {
display: flex;
align-items: flex-start;
padding: 10rpx;
margin-bottom: 15rpx;
background: #f5f7fa;
border-radius: 16rpx;
border: 4rpx solid transparent;
transition: all 0.2s ease;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
&.active {
background: #e6f0ff;
border-color: #7eacfe;
}
&.correct {
background: #e6ffe6;
border-color: #4cd964;
}
&.wrong {
background: #ffe6e6;
border-color: #ff4d4f;
}
.choice-key {
width: 25rpx;
height: 25rpx;
background: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: $font-special;
font-size: 15rpx;
color: $font-color;
margin-right: 20rpx;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.choice-content {
flex: 1;
font-size: 12rpx;
color: $font-color;
line-height: 1.6;
:deep(image) {
max-width: 100%;
height: auto;
}
// 公式文本样式:使用数学字体风格
:deep(.formula-text) {
font-family: 'Times New Roman', 'STIXGeneral', Georgia, serif;
font-style: italic;
font-size: 15rpx;
color: #1a1a1a;
letter-spacing: 0.5rpx;
}
// 上下标样式
:deep(.sup) {
font-size: 12rpx;
line-height: 1;
vertical-align: super;
}
:deep(.sub) {
font-size: 12rpx;
line-height: 1;
vertical-align: sub;
}
// 选项里的分数样式:字体大小与公式文本一致
:deep(.fraction) {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14rpx;
line-height: 1.1;
vertical-align: middle;
}
:deep(.fraction .top) {
padding: 0 4rpx;
border-bottom: 2rpx solid currentColor;
}
:deep(.fraction .bottom) {
padding: 0 4rpx;
}
}
}
}
.judgment-list {
display: flex;
justify-content: center;
gap: 30rpx;
.judgment-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
background: #f5f7fa;
border-radius: 24rpx;
border: 4rpx solid transparent;
transition: all 0.2s ease;
&.active {
background: #e6f0ff;
border-color: #7eacfe;
}
image {
width: 20rpx;
height: 20rpx;
margin-bottom: 10rpx;
}
text {
font-family: $font-special;
font-size: 28rpx;
color: $font-color;
}
}
}
.input-area {
.input-item {
display: flex;
align-items: center;
margin-bottom: 24rpx;
.input-label {
font-size: 28rpx;
color: $font-color;
margin-right: 16rpx;
flex-shrink: 0;
}
.input-field {
flex: 1;
height: 72rpx;
padding: 0 24rpx;
background: #f5f7fa;
border-radius: 12rpx;
font-size: 28rpx;
color: $font-color;
}
}
}
.upload-area {
// 提示文字
.upload-tip {
display: flex;
align-items: center;
justify-content: center;
padding: 16rpx 20rpx;
margin-bottom: 20rpx;
background: linear-gradient(135deg, #fff9e6 0%, #fff3cc 100%);
border-radius: 12rpx;
border: 2rpx solid #ffe58f;
.tip-icon {
font-size: 20rpx;
margin-right: 10rpx;
}
text {
font-size: 13rpx;
color: #d48806;
font-family: $font-special;
}
}
// 多图列表
.pic-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.pic-item {
position: relative;
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
overflow: hidden;
background: #f5f7fa;
image {
width: 100%;
height: 100%;
}
.delete-btn {
position: absolute;
top: 0;
right: 0;
width: 32rpx;
height: 32rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 0 0 0 12rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 24rpx;
color: #fff;
line-height: 1;
}
}
}
.add-btn {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
background: #f5f7fa;
border: 2rpx dashed #ccc;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 48rpx;
color: #999;
}
}
}
.empty-tip {
padding: 32rpx;
text-align: center;
text {
font-size: 24rpx;
color: #999;
}
}
.upload-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx;
background: #f5f7fa;
border-radius: 16rpx;
border: 2rpx dashed #ccc;
.upload-btn-button {
padding: 10rpx 20rpx;
font-size: 14rpx;
background: #7eacfe;
color: #fff;
border-radius: 20rpx;
box-shadow: 0 3rpx 8rpx rgba(0, 0, 0, 0.15);
font-family: AlimamaShuHeiTi;
}
}
}
.answer-box {
margin-top: 20rpx;
&-title {
padding: 0 32rpx;
position: relative;
font-family: $font-special;
font-size: 12rpx;
color: $font-color;
margin-bottom: 14rpx;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 12rpx;
height: 100%;
background: #669cfe;
border-radius: 0 16rpx 16rpx 0;
}
}
&-main {
font-family: 'OPPO Sans';
font-size: 12rpx;
color: $font-color;
padding-left: 16rpx;
line-height: 1.6;
// 解析中的公式文本样式:使用数学字体风格
:deep(.formula-text) {
font-size: 15rpx;
font-family: 'Times New Roman', 'STIXGeneral', Georgia, serif;
font-style: italic;
color: #1a1a1a;
letter-spacing: 0.5rpx;
vertical-align: baseline;
}
// 上下标样式
:deep(.sup) {
font-size: 12rpx;
line-height: 1;
vertical-align: super;
}
:deep(.sub) {
font-size: 12rpx;
line-height: 1;
vertical-align: sub;
}
// 解析中的分数样式
:deep(.fraction) {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14rpx;
line-height: 1.1;
vertical-align: middle;
}
&.analyze {
:deep(image) {
max-width: 100%;
height: auto;
}
}
&.analyze-pic {
image {
max-width: 100%;
margin-bottom: 16rpx;
border-radius: 8rpx;
}
}
}
}
</style>