feat: 薄弱知识
This commit is contained in:
parent
6824b4fd28
commit
c75e009bea
@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<view class="qy-loading">
|
||||
<view class="qy-loading-inner">
|
||||
<!-- ========== 加载中模式 ========== -->
|
||||
<template v-if="mode === 'loading'">
|
||||
<image
|
||||
v-if="!loadError"
|
||||
class="qy-loading-img"
|
||||
@ -13,6 +15,31 @@
|
||||
<view class="qy-loading-placeholder"></view>
|
||||
</view>
|
||||
<text v-if="text" class="qy-loading-text">{{ text }}</text>
|
||||
</template>
|
||||
|
||||
<!-- ========== 空数据模式 ========== -->
|
||||
<template v-else-if="mode === 'empty'">
|
||||
<view class="qy-empty-illustration">
|
||||
<!-- 外圈光晕 -->
|
||||
<view class="qy-empty-glow"></view>
|
||||
<!-- 主图:空文档 -->
|
||||
<view class="qy-empty-doc">
|
||||
<view class="qy-empty-doc-fold"></view>
|
||||
<view class="qy-empty-doc-body">
|
||||
<view class="qy-empty-doc-line long"></view>
|
||||
<view class="qy-empty-doc-line medium"></view>
|
||||
<view class="qy-empty-doc-line short"></view>
|
||||
</view>
|
||||
<!-- 放大镜 -->
|
||||
<view class="qy-empty-magnifier">
|
||||
<view class="qy-empty-magnifier-glass"></view>
|
||||
<view class="qy-empty-magnifier-handle"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="qy-empty-text">{{ text || '暂无数据' }}</text>
|
||||
<text v-if="subText" class="qy-empty-subtext">{{ subText }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@ -20,19 +47,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
// 直接引用 static 下的 loading.webp,由构建输出正确路径
|
||||
import defaultLoadingImg from '@/static/loading.webp';
|
||||
import defaultLoadingImg from '@/static/loading.gif';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 提示文字,不传则不显示 */
|
||||
/** 模式:loading 加载中(默认)| empty 空数据 */
|
||||
mode?: 'loading' | 'empty';
|
||||
/** 提示文字;loading 模式不传则不显示,empty 模式默认"暂无数据" */
|
||||
text?: string;
|
||||
/** 尺寸,单位 rpx,默认 120 */
|
||||
/** 副文本提示(仅 empty 模式),如"请稍后再试" */
|
||||
subText?: string;
|
||||
/** 尺寸,单位 rpx,默认 120(仅 loading 模式下的动画尺寸) */
|
||||
size?: number;
|
||||
/** 自定义图片路径,不传则使用 static/loading.webp */
|
||||
/** 自定义图片路径(仅 loading 模式),不传则使用 static/loading.gif */
|
||||
src?: string;
|
||||
}>(),
|
||||
{
|
||||
mode: 'loading',
|
||||
text: '',
|
||||
subText: '',
|
||||
size: 120,
|
||||
src: '',
|
||||
},
|
||||
@ -56,6 +89,7 @@ function onImageError() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ========== 通用容器 ========== */
|
||||
.qy-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -72,6 +106,7 @@ function onImageError() {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ========== Loading 模式 ========== */
|
||||
.qy-loading-img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -97,4 +132,132 @@ function onImageError() {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ========== Empty 模式 ========== */
|
||||
.qy-empty-illustration {
|
||||
position: relative;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: emptyFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 背景光晕 */
|
||||
.qy-empty-glow {
|
||||
position: absolute;
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(143, 157, 247, 0.15) 0%, rgba(143, 157, 247, 0) 70%);
|
||||
}
|
||||
|
||||
/* 文档主体 */
|
||||
.qy-empty-doc {
|
||||
position: relative;
|
||||
width: 55rpx;
|
||||
height: 65rpx;
|
||||
background: #fff;
|
||||
border-radius: 6rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(100, 116, 200, 0.15), 0 1rpx 4rpx rgba(100, 116, 200, 0.08);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 文档折角 */
|
||||
.qy-empty-doc-fold {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
background: linear-gradient(135deg, transparent 50%, #e8ecf4 50%);
|
||||
border-radius: 0 6rpx 0 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
background: linear-gradient(135deg, transparent 50%, #dce1ed 50%);
|
||||
border-bottom-right-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 文档内容行 */
|
||||
.qy-empty-doc-body {
|
||||
padding: 18rpx 7rpx 7rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.qy-empty-doc-line {
|
||||
height: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
background: #e8ecf4;
|
||||
|
||||
&.long {
|
||||
width: 100%;
|
||||
}
|
||||
&.medium {
|
||||
width: 70%;
|
||||
}
|
||||
&.short {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 放大镜 */
|
||||
.qy-empty-magnifier {
|
||||
position: absolute;
|
||||
right: -9rpx;
|
||||
bottom: -7rpx;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.qy-empty-magnifier-glass {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid #8f9df7;
|
||||
background: rgba(143, 157, 247, 0.08);
|
||||
}
|
||||
|
||||
.qy-empty-magnifier-handle {
|
||||
width: 3rpx;
|
||||
height: 10rpx;
|
||||
background: linear-gradient(to bottom, #8f9df7, #b5bfff);
|
||||
border-radius: 0 0 2rpx 2rpx;
|
||||
margin-left: 9rpx;
|
||||
margin-top: -1rpx;
|
||||
}
|
||||
|
||||
/* 文字 */
|
||||
.qy-empty-text {
|
||||
margin-top: 12rpx;
|
||||
font-size: 16rpx;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.qy-empty-subtext {
|
||||
margin-top: 4rpx;
|
||||
font-size: 20rpx;
|
||||
color: #b5bfcf;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 悬浮动画 */
|
||||
@keyframes emptyFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -290,8 +290,8 @@ const filterTitleWithoutImages = (title?: string, cidx?: number | string) => {
|
||||
t = t.replace(/http:\/\//gi, 'https://');
|
||||
// 先修复数学符号(< > 转义、AUB→∪),避免被 HTML 解析截断
|
||||
t = fixMathSymbolsInHtml(t);
|
||||
// 处理公式
|
||||
t = processFormula(t);
|
||||
// 处理公式(传入失败 URL 集合,触发响应式依赖以便失败后自动重渲染)
|
||||
t = processFormula(t, failedFormulaUrls.value);
|
||||
|
||||
if (cidx !== undefined) {
|
||||
t = `(${Number(cidx) + 1})` + t;
|
||||
@ -320,6 +320,10 @@ const handleRichTextClick = (e: any) => {
|
||||
const submitAnswer = ref('');
|
||||
const submitAnswerPic = ref('');
|
||||
|
||||
// ========== 公式图片预加载 + 静默重试 ==========
|
||||
// 加载永久失败的公式图片 URL 集合(重试耗尽后加入,触发模板重新渲染用文本兜底)
|
||||
const failedFormulaUrls = ref<Set<string>>(new Set());
|
||||
|
||||
// 监听题目变化,初始化答案
|
||||
watch(
|
||||
() => props.data?.id,
|
||||
@ -620,7 +624,8 @@ const simpleFormulaToHtml = (formula: string): string => {
|
||||
};
|
||||
|
||||
// 处理数学公式:使用在线 LaTeX 渲染服务
|
||||
const processFormula = (html: string): string => {
|
||||
// failedUrls: 可选,加载永久失败的 URL 集合;命中时用文本兜底而非 <img>
|
||||
const processFormula = (html: string, failedUrls?: Set<string>): string => {
|
||||
if (html == null || typeof html !== 'string') return '';
|
||||
if (!html) return '';
|
||||
|
||||
@ -648,10 +653,130 @@ const processFormula = (html: string): string => {
|
||||
|
||||
// 复杂公式使用 LaTeX 图片渲染
|
||||
const imageUrl = latexToImageUrl(formula);
|
||||
|
||||
// 如果该图片经过多次重试仍失败,使用 LaTeX 文本兜底
|
||||
if (failedUrls?.has(imageUrl)) {
|
||||
const display = formula.length > 60 ? formula.substring(0, 60) + '…' : formula;
|
||||
return `<span class="formula-text">[${display}]</span>`;
|
||||
}
|
||||
|
||||
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" />`;
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- 公式图片预加载工具函数 ----------
|
||||
|
||||
// 从原始 HTML 中提取所有复杂公式对应的图片 URL(逻辑与 processFormula 一致)
|
||||
const extractFormulaImageUrls = (html: string): string[] => {
|
||||
if (html == null || typeof html !== 'string') return [];
|
||||
const formulaRegex = /<span\s+((?:[^<>"']+|"[^"]*"|'[^']*')*)data-w-e-type=["']formula["']((?:[^<>"']+|"[^"]*"|'[^']*')*)>[\s\S]*?<\/span>/gi;
|
||||
const urls: string[] = [];
|
||||
let match;
|
||||
while ((match = formulaRegex.exec(html)) !== null) {
|
||||
const valueMatch = match[0].match(/data-value=["']([^"']+)["']/);
|
||||
if (!valueMatch) continue;
|
||||
let formula = valueMatch[1]
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'");
|
||||
if (!formula.trim()) continue;
|
||||
if (isSimpleFormula(formula)) continue;
|
||||
urls.push(latexToImageUrl(formula));
|
||||
}
|
||||
return urls;
|
||||
};
|
||||
|
||||
// 单张图片预加载,失败时自动重试(递增延迟 1s → 2s → 3s)
|
||||
const preloadImageWithRetry = (url: string, maxRetries = 3): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
let attempt = 0;
|
||||
const tryLoad = () => {
|
||||
uni.getImageInfo({
|
||||
src: url,
|
||||
success: () => resolve(true),
|
||||
fail: () => {
|
||||
attempt++;
|
||||
if (attempt < maxRetries) {
|
||||
setTimeout(tryLoad, attempt * 1000);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
tryLoad();
|
||||
});
|
||||
};
|
||||
|
||||
// 收集题目中所有可能含公式的原始 HTML(题干、选项、答案、解析等)
|
||||
const collectAllRawHtml = (data: any): string[] => {
|
||||
if (!data) return [];
|
||||
const htmls: string[] = [];
|
||||
if (data.pidTitle) htmls.push(data.pidTitle);
|
||||
if (data.titleMu || data.title) htmls.push(data.titleMu || data.title);
|
||||
// 子题目
|
||||
if (data.children?.length) {
|
||||
data.children.forEach((c: any) => { if (c.title) htmls.push(c.title); });
|
||||
}
|
||||
// 选项
|
||||
try {
|
||||
let opts = data.optionsMu ?? data.options;
|
||||
if (typeof opts === 'string') opts = JSON.parse(opts);
|
||||
if (Array.isArray(opts)) {
|
||||
opts.forEach((item: any) => {
|
||||
const v = typeof item === 'string' ? item : (item?.value ?? item?.optionValue ?? '');
|
||||
if (v) htmls.push(String(v));
|
||||
});
|
||||
}
|
||||
} catch { /* ignore parse error */ }
|
||||
// 答案、解析、AI 等
|
||||
if (data.answerMu || data.answer) htmls.push(data.answerMu || data.answer);
|
||||
if (data.titleAnalyzeMu || data.titleAnalyze) htmls.push(data.titleAnalyzeMu || data.titleAnalyze);
|
||||
if (data.aiAnswer != null) htmls.push(String(data.aiAnswer));
|
||||
if (data.aiAnalyze != null) htmls.push(String(data.aiAnalyze));
|
||||
return htmls;
|
||||
};
|
||||
|
||||
// 监听题目数据变化,后台预加载所有公式图片,失败自动重试,彻底失败后切换文本兜底
|
||||
let preloadGeneration = 0;
|
||||
watch(
|
||||
() => props.data,
|
||||
async (newData) => {
|
||||
const generation = ++preloadGeneration;
|
||||
// 新题目先清空失败集合(乐观渲染,先展示图片)
|
||||
failedFormulaUrls.value = new Set();
|
||||
|
||||
if (!newData) return;
|
||||
|
||||
// 收集题目所有原始 HTML,提取其中的公式图片 URL
|
||||
const allUrls = new Set<string>();
|
||||
for (const html of collectAllRawHtml(newData)) {
|
||||
const processed = fixMathSymbolsInHtml(html);
|
||||
extractFormulaImageUrls(processed).forEach((url) => allUrls.add(url));
|
||||
}
|
||||
|
||||
if (allUrls.size === 0) return;
|
||||
|
||||
// 并发预加载每张公式图片(每张最多重试 3 次,间隔递增)
|
||||
const results = await Promise.all(
|
||||
Array.from(allUrls).map(async (url) => ({
|
||||
url,
|
||||
ok: await preloadImageWithRetry(url, 3),
|
||||
})),
|
||||
);
|
||||
|
||||
// 如果用户已切到下一题(generation 过期),丢弃本次结果
|
||||
if (generation !== preloadGeneration) return;
|
||||
|
||||
const failed = results.filter((r) => !r.ok).map((r) => r.url);
|
||||
if (failed.length > 0) {
|
||||
console.warn(`[Question] ${failed.length} 张公式图片加载失败,已切换为文本兜底`, failed);
|
||||
// 设置新 Set 触发响应式更新 → filterTitleWithoutImages / optionList / filterTitle 重新求值 → 失败图片用文本替代
|
||||
failedFormulaUrls.value = new Set(failed);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 选项列表 - 单选题/多选题:仅用 optionsMu 或 options,不用 optionList
|
||||
// options/optionsMu 可能是 JSON 字符串,需先解析为数组
|
||||
@ -688,7 +813,7 @@ const optionList = computed(() => {
|
||||
}
|
||||
|
||||
value = fixMathSymbolsInHtml(value);
|
||||
value = processFormula(value);
|
||||
value = processFormula(value, failedFormulaUrls.value);
|
||||
value = typeof value === 'string' ? value.replace(/http:\/\//gi, 'https://') : '';
|
||||
// 选项内容为空时显示占位,避免只看到选项字母没有内容
|
||||
// if (!value || (typeof value === 'string' && !value.trim())) {
|
||||
@ -762,7 +887,7 @@ const filterTitle = (title?: string, cidx?: number | string) => {
|
||||
if (!title) return '';
|
||||
let t = title.replace(/http:\/\//gi, 'https://');
|
||||
t = fixMathSymbolsInHtml(t);
|
||||
t = processFormula(t);
|
||||
t = processFormula(t, failedFormulaUrls.value);
|
||||
if (cidx !== undefined) {
|
||||
t = `(${Number(cidx) + 1})` + t;
|
||||
}
|
||||
|
||||
@ -584,6 +584,7 @@ $teacher-bg: 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/main_bg.s
|
||||
flex-wrap: wrap;
|
||||
gap: 6rpx;
|
||||
justify-content: center;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
@ -596,7 +597,7 @@ $teacher-bg: 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/main_bg.s
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 8rpx;
|
||||
border: 1rpx solid rgba(196, 181, 255, 0.2);
|
||||
margin-top: 8rpx;
|
||||
// margin-top: 8rpx;
|
||||
width: 100%;
|
||||
.action-icon {
|
||||
font-size: 18rpx;
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<!-- 主体 -->
|
||||
<view class="main-body">
|
||||
<view v-if="loading" class="status-box">
|
||||
<qy-Loading text="加载中..." />
|
||||
<qy-Loading />
|
||||
</view>
|
||||
<view v-else-if="errorMsg" class="status-box error">
|
||||
<text class="status-text">{{ errorMsg }}</text>
|
||||
@ -23,7 +23,7 @@
|
||||
<text class="class-desc">薄弱知识点 Top10</text>
|
||||
</view>
|
||||
<view v-if="!listData.topWeakKnowledgeList || listData.topWeakKnowledgeList.length === 0" class="empty-hint">
|
||||
<text>暂无薄弱知识点数据</text>
|
||||
<qy-Loading mode="empty" text="暂无薄弱知识点数据" />
|
||||
</view>
|
||||
<view v-else class="knowledge-list">
|
||||
<view
|
||||
|
||||
BIN
src/static/loading.gif
Normal file
BIN
src/static/loading.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
Loading…
x
Reference in New Issue
Block a user