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