feat: 薄弱知识

This commit is contained in:
李梦 2026-02-15 18:15:10 +08:00
parent 6824b4fd28
commit c75e009bea
6 changed files with 313 additions and 24 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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;

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB