feat: 对接完成

This commit is contained in:
阿梦 2026-05-30 17:07:53 +08:00
parent 339c871d65
commit b90a932b9a
20 changed files with 1371 additions and 1162 deletions

View File

@ -210,6 +210,91 @@ export const getWrongQuestionSummary = (params: { userId: string | number }) =>
params, params,
}); });
export interface QuestionTitle {
actualScore?: number;
aiAnalyze?: string;
aiAnswer?: string;
answer?: string;
answerHtml?: string;
answerSqs?: string;
correctResult?: number;
courseId?: number;
difficulty?: number;
difficultyCode?: number;
difficultyLevel?: string;
explanation?: string;
id?: string;
itemId?: number;
optionHtml?: string;
optionSqs?: string;
score?: number;
stem?: string;
stemHtml?: string;
submitAnswer?: string;
submitAnswerPic?: string;
typeId?: string;
typeName?: string;
[key: string]: any;
}
export interface WrongQuestionItem {
id: number;
questionId: string;
userId?: number;
subjectId?: number;
textbookId?: number;
chapterId?: number;
courseId?: number;
errorCount: number;
errorType?: number;
masteryLevel?: number;
status?: number;
firstErrorTime: string;
lastErrorTime: string;
lastCorrectTime?: string;
questionTitle?: QuestionTitle;
[key: string]: any;
}
export interface WrongQuestionFilterPageParams {
userId?: string | number;
subjectId?: string | number;
current?: number;
size?: number;
startTime?: string;
endTime?: string;
}
export interface FilterSubject {
id: number;
name: string;
}
/** 学科网 - 错题筛选分页列表 */
export const getWrongQuestionFilterPage = (params: WrongQuestionFilterPageParams) => {
const query: Record<string, string | number> = {
current: params.current ?? 1,
size: params.size ?? 10,
};
if (params.userId) query.userId = params.userId;
if (params.subjectId) query.subjectId = Number(params.subjectId);
if (params.startTime) query.startTime = params.startTime;
if (params.endTime) query.endTime = params.endTime;
return request<PageResult<WrongQuestionItem>>({
url: '/xkw/wrong/question/filter/page',
method: 'GET',
params: query,
});
};
/** 学科网 - 错题筛选学科列表 */
export const getWrongQuestionFilterSubjects = (params?: { userId?: string | number }) =>
request<FilterSubject[]>({
url: '/xkw/wrong/question/filter/subjects',
method: 'GET',
params: params?.userId ? { userId: params.userId } : undefined,
});
/** 家长端 - 错题知识点占比 */ /** 家长端 - 错题知识点占比 */
export const getWrongKpointRatio = (params: { subjectId: string | number; userId: string | number }) => export const getWrongKpointRatio = (params: { subjectId: string | number; userId: string | number }) =>
request<WrongKpointRatioResult>({ request<WrongKpointRatioResult>({

View File

@ -117,6 +117,52 @@ const getTouchPoint = (e: any) => {
return { x, y }; return { x, y };
}; };
const syncViewportSize = (reloadWhenVisible = false) => {
uni.getSystemInfo({
success: (res) => {
safeAreaTop.value = res.safeArea?.top || 0;
safeAreaBottom.value =
(res.screenHeight && res.safeArea ? res.screenHeight - res.safeArea.bottom : 0) || 0;
const bottomReserve = controlsReservePx + safeAreaBottom.value;
const fallbackWidth = res.windowWidth;
const fallbackHeight = res.windowHeight;
const applySize = (width: number, height: number) => {
const nextWidth = Math.max(1, Math.round(width));
const nextHeight = Math.max(1, Math.round(height - bottomReserve));
const sizeChanged = nextWidth !== canvasWidth.value || nextHeight !== canvasHeight.value;
canvasWidth.value = nextWidth;
canvasHeight.value = nextHeight;
if (!ctx.value) {
ctx.value = uni.createCanvasContext('imageCropperCanvas', componentProxy);
}
if (props.visible && props.imageSrc && (reloadWhenVisible || sizeChanged)) {
tryInitAndLoad();
}
};
if (props.visible) {
nextTick(() => {
uni.createSelectorQuery()
.in(componentProxy)
.select('.cropper-wrapper')
.boundingClientRect((rect: any) => {
if (rect?.width && rect?.height) {
applySize(rect.width, rect.height);
return;
}
applySize(fallbackWidth, fallbackHeight);
})
.exec();
});
return;
}
applySize(fallbackWidth, fallbackHeight);
},
});
};
const tryInitAndLoad = () => { const tryInitAndLoad = () => {
if (!props.visible) return; if (!props.visible) return;
if (!props.imageSrc) return; if (!props.imageSrc) return;
@ -133,21 +179,8 @@ const tryInitAndLoad = () => {
}; };
onMounted(() => { onMounted(() => {
// canvas // getSystemInfo
uni.getSystemInfo({ syncViewportSize(false);
success: (res) => {
canvasWidth.value = res.windowWidth;
safeAreaTop.value = res.safeArea?.top || 0;
safeAreaBottom.value =
(res.screenHeight && res.safeArea ? res.screenHeight - res.safeArea.bottom : 0) || 0;
// +
const bottomReserve = controlsReservePx + safeAreaBottom.value;
canvasHeight.value = Math.max(1, res.windowHeight - bottomReserve);
// canvas /
ctx.value = uni.createCanvasContext('imageCropperCanvas', componentProxy);
tryInitAndLoad();
},
});
}); });
watch( watch(
@ -157,6 +190,7 @@ watch(
imgInfo.value = null; imgInfo.value = null;
return; return;
} }
syncViewportSize(true);
tryInitAndLoad(); tryInitAndLoad();
}, },
{ immediate: true } { immediate: true }

View File

@ -14,7 +14,7 @@
"islogin": false "islogin": false
}, },
"style": { "style": {
"navigationBarTitleText": "小乐AI", "navigationBarTitleText": "灵犀学AI",
"enablePullDownRefresh": false, "enablePullDownRefresh": false,
"mp-weixin": { "mp-weixin": {
"pageOrientation": "portrait" "pageOrientation": "portrait"
@ -34,6 +34,32 @@
} }
} }
}, },
{
"path": "pages/agreement/user/index",
"name": "agreement-user",
"meta": {
"islogin": false
},
"style": {
"navigationBarTitleText": "用户协议",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "pages/agreement/privacy/index",
"name": "agreement-privacy",
"meta": {
"islogin": false
},
"style": {
"navigationBarTitleText": "隐私政策",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{ {
"path": "pages/homework/index", "path": "pages/homework/index",
"name": "homework", "name": "homework",
@ -86,6 +112,19 @@
} }
} }
}, },
{
"path": "pages/wrong/list",
"name": "wrongList",
"meta": {
"islogin": true
},
"style": {
"navigationBarTitleText": "错题列表",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{ {
"path": "pages/doWork/index", "path": "pages/doWork/index",
"name": "doWork", "name": "doWork",
@ -276,7 +315,7 @@
], ],
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"navigationBarTitleText": "小乐AI", "navigationBarTitleText": "灵犀学AI",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F5F7FF", "backgroundColor": "#F5F7FF",
"pageOrientation": "portrait" "pageOrientation": "portrait"

View File

@ -0,0 +1,60 @@
<template>
<scroll-view class="agreement-page" scroll-y :show-scrollbar="false">
<view class="container">
<view class="title">灵犀学隐私政策</view>
<view class="date">更新日期2026-05-30</view>
<view class="content">
<text>
我们重视您的个人信息与隐私保护为向您提供学习服务我们会在最小必要范围内收集和使用信息
</text>
<text>1. 信息收集可能包括账号信息学习记录设备信息等</text>
<text>2. 信息用途用于登录鉴权学习功能实现学习效果分析与服务优化</text>
<text>3. 信息保护我们采取合理安全措施保护您的信息防止泄露篡改或丢失</text>
<text>4. 信息共享除法律法规要求外未经授权不会向第三方提供个人信息</text>
<text>5. 权利保障您可通过平台或管理员渠道申请查询更正删除相关信息</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.agreement-page {
height: 100vh;
background: #f8fafc;
}
.container {
padding: 32rpx;
}
.title {
font-size: 40rpx;
font-weight: 800;
color: #0f172a;
}
.date {
margin-top: 12rpx;
font-size: 24rpx;
color: #64748b;
}
.content {
margin-top: 28rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 28rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
text {
font-size: 28rpx;
color: #334155;
line-height: 1.7;
}
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<scroll-view class="agreement-page" scroll-y :show-scrollbar="false">
<view class="container">
<view class="title">灵犀学用户协议</view>
<view class="date">更新日期2026-05-30</view>
<view class="content">
<text>
欢迎使用灵犀学为保障您的合法权益请在使用前仔细阅读本协议您在注册登录或使用服务时
即视为已阅读并同意本协议全部内容
</text>
<text>1. 服务内容我们提供学习任务作业练习学情分析等功能</text>
<text>2. 账号安全请妥善保管账号信息因保管不善造成的风险由用户自行承担</text>
<text>3. 合规使用不得利用本服务从事违法违规活动不得侵犯他人合法权益</text>
<text>4. 服务变更我们可能根据业务需要调整服务内容并通过合理方式通知</text>
<text>5. 联系方式如有问题请联系平台运营方或学校管理员</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.agreement-page {
height: 100vh;
background: #f8fafc;
}
.container {
padding: 32rpx;
}
.title {
font-size: 40rpx;
font-weight: 800;
color: #0f172a;
}
.date {
margin-top: 12rpx;
font-size: 24rpx;
color: #64748b;
}
.content {
margin-top: 28rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 28rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
text {
font-size: 28rpx;
color: #334155;
line-height: 1.7;
}
}
</style>

View File

@ -367,6 +367,7 @@
import { ref, computed, watch, onUnmounted } from 'vue'; import { ref, computed, watch, onUnmounted } from 'vue';
import { uploadFile } from '@/api/upload'; import { uploadFile } from '@/api/upload';
import ImageCropper from '@/components/ImageCropper/index.vue'; import ImageCropper from '@/components/ImageCropper/index.vue';
import { formatQuestionHtml } from '@/utils/questionHtml';
const props = defineProps<{ const props = defineProps<{
data?: any; data?: any;
@ -377,7 +378,7 @@ const props = defineProps<{
subjectId?: string; subjectId?: string;
}>(); }>();
const emit = defineEmits(['submit']); const emit = defineEmits(['submit', 'cropper-visible-change']);
/* ============== 音频 ============== */ /* ============== 音频 ============== */
const isAudioPlay = ref(false); const isAudioPlay = ref(false);
@ -412,21 +413,18 @@ const previewTitleImage = (index: number) => {
}); });
}; };
/* ============== 公式失败兜底集合 ============== */
const failedFormulaUrls = ref<Set<string>>(new Set());
/* ============== 题目过滤 ============== */ /* ============== 题目过滤 ============== */
const filterTitleWithoutImages = (title?: string, cidx?: number | string) => { const renderQuestionHtml = (title?: string, cidx?: number | string, options?: { stripImages?: boolean }) => {
if (!title) return ''; if (!title) return '';
let t = title.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, ''); let html = formatQuestionHtml(title, options);
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
t = t.replace(/http:\/\//gi, 'https://');
t = fixMathSymbolsInHtml(t);
t = processFormula(t, failedFormulaUrls.value);
if (cidx !== undefined) { if (cidx !== undefined) {
t = `(${Number(cidx) + 1})` + t; html = `(${Number(cidx) + 1})${html}`;
} }
return t; return html;
};
const filterTitleWithoutImages = (title?: string, cidx?: number | string) => {
return renderQuestionHtml(title, cidx, { stripImages: true });
}; };
const parseJsonArray = (raw: any) => { const parseJsonArray = (raw: any) => {
@ -466,283 +464,6 @@ watch(
{ immediate: true }, { immediate: true },
); );
/* ============== 数学符号修复 ============== */
const fixMathSymbolsInHtml = (html: string): string => {
if (html == null || typeof html !== 'string') return '';
let t = html;
t = t.replace(/\uFE63/g, '\u2212');
t = t.replace(/<(?![a-zA-Z/!])/g, '&lt;');
t = t.replace(/ > /g, ' &gt; ');
t = t.replace(/AUB/g, 'AB');
return t;
};
/* ============== LaTeX 渲染 ============== */
const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?';
const latexToImageUrl = (latex: string): string => {
if (!latex) return '';
let formula = latex.trim();
const punctuationMap: Record<string, string> = {
'': ',', '。': '.', '': ':', '': ';', '': '!', '': '?',
'': '(', '': ')', '【': '[', '】': ']', '': '{', '': '}',
'《': '<', '》': '>',
"\u201c": '"', "\u201d": '"', "\u2018": "'", "\u2019": "'",
'': '+', '': '-', '\u2212': '-', '×': '*', '÷': '/',
'': '=', '': '<', '': '>', '': '%', '': '&',
'': '*', '': '@', '': '#', '': '$', '': '^',
'': '~', '': '|', '': '\\', '': '/', ' ': ' ',
};
for (const [cn, en] of Object.entries(punctuationMap)) {
formula = formula.split(cn).join(en);
}
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);
}
formula = formula.replace(/\b([RNZQC])\+/g, '$1^+');
formula = formula.replace(/\b([RNZQC])\*/g, '$1^*');
formula = formula
.replace(/\\overset\s*\{\s*\\rightarrow\s*\}\s*\{([^}]+)\}/g, '\\vec{$1}')
.replace(/\\text\s*\{\s*π\s*\}/g, '\\pi')
.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')
.replace(/π/g, '\\pi');
const encoded = encodeURIComponent(formula);
return `${LATEX_RENDER_URL}\\inline&space;\\dpi{200}&space;${encoded}`;
};
const isSimpleFormula = (formula: string): boolean => {
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/,
/\^{[^}]+}/, /_{[^}]+}/,
];
return !complexPatterns.some((pattern) => pattern.test(formula));
};
const simpleFormulaToHtml = (formula: string): string => {
let text = formula;
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, '→');
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': 'ₖ',
};
text = text.replace(/\^([0-9n+\-])/g, (_, char) => superscriptMap[char] || `^${char}`);
text = text.replace(/_([0-9nik+\-])/g, (_, char) => subscriptMap[char] || `_${char}`);
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>`;
};
const processFormula = (html: string, failedUrls?: Set<string>): string => {
if (html == null || typeof html !== 'string') return '';
if (!html) return '';
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);
}
const imageUrl = latexToImageUrl(formula);
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;" />`;
});
};
/* ============== 公式预加载 + 重试 ============== */
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;
};
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();
});
};
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) => {
if (typeof item === 'string') {
if (item) htmls.push(item);
} else if (item?.stem || item?.options) {
if (item.stem) htmls.push(String(item.stem));
if (Array.isArray(item.options)) {
item.options.forEach((sub: any) => {
const sv = typeof sub === 'string' ? sub : (sub?.value ?? sub?.optionValue ?? '');
if (sv) htmls.push(String(sv));
});
}
} else {
const v = item?.value ?? item?.optionValue ?? '';
if (v) htmls.push(String(v));
}
});
}
} catch { /* ignore */ }
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 preloadGen = 0;
watch(
() => props.data,
async (newData) => {
const generation = ++preloadGen;
failedFormulaUrls.value = new Set();
if (!newData) return;
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;
const results = await Promise.all(
Array.from(allUrls).map(async (url) => ({
url,
ok: await preloadImageWithRetry(url, 3),
})),
);
if (generation !== preloadGen) return;
const failed = results.filter((r) => !r.ok).map((r) => r.url);
if (failed.length > 0) {
console.warn(`[Question] ${failed.length} 张公式图片加载失败,已切换为文本兜底`, failed);
failedFormulaUrls.value = new Set(failed);
}
},
{ immediate: true },
);
/* ============== 选项 ============== */ /* ============== 选项 ============== */
const optionList = computed(() => { const optionList = computed(() => {
const data = props.data; const data = props.data;
@ -772,9 +493,7 @@ const optionList = computed(() => {
const v = item?.html ?? item?.value ?? item?.optionValue ?? item?.content ?? ''; const v = item?.html ?? item?.value ?? item?.optionValue ?? item?.content ?? '';
value = v != null && v !== '' ? String(v) : ''; value = v != null && v !== '' ? String(v) : '';
} }
value = fixMathSymbolsInHtml(value); value = formatQuestionHtml(value);
value = processFormula(value, failedFormulaUrls.value);
value = typeof value === 'string' ? value.replace(/http:\/\//gi, 'https://') : '';
return { return {
key: key:
typeof item === 'string' typeof item === 'string'
@ -869,9 +588,7 @@ const yueDuList = computed(() => {
const subOpts = rawSubOpts.map((opt: any, oIdx: number) => { const subOpts = rawSubOpts.map((opt: any, oIdx: number) => {
let v = typeof opt === 'string' ? opt : (opt?.html ?? opt?.value ?? opt?.optionValue ?? opt?.content ?? ''); let v = typeof opt === 'string' ? opt : (opt?.html ?? opt?.value ?? opt?.optionValue ?? opt?.content ?? '');
v = v != null ? String(v) : ''; v = v != null ? String(v) : '';
v = fixMathSymbolsInHtml(v); v = formatQuestionHtml(v);
v = processFormula(v, failedFormulaUrls.value);
v = v.replace(/http:\/\//gi, 'https://');
return { return {
label: label:
typeof opt === 'string' typeof opt === 'string'
@ -883,9 +600,7 @@ const yueDuList = computed(() => {
let stem = ''; let stem = '';
if (item.stem) { if (item.stem) {
stem = String(item.stem); stem = String(item.stem);
stem = fixMathSymbolsInHtml(stem); stem = formatQuestionHtml(stem);
stem = processFormula(stem, failedFormulaUrls.value);
stem = stem.replace(/http:\/\//gi, 'https://');
} }
let horizontal = false; let horizontal = false;
if (subOpts.length > 0) { if (subOpts.length > 0) {
@ -922,14 +637,7 @@ const resultClass = computed(() => {
}); });
const filterTitle = (title?: string, cidx?: number | string) => { const filterTitle = (title?: string, cidx?: number | string) => {
if (!title) return ''; return renderQuestionHtml(title, cidx);
let t = title.replace(/http:\/\//gi, 'https://');
t = fixMathSymbolsInHtml(t);
t = processFormula(t, failedFormulaUrls.value);
if (cidx !== undefined) {
t = `(${Number(cidx) + 1})` + t;
}
return t;
}; };
/* ============== 单选 ============== */ /* ============== 单选 ============== */
@ -1048,6 +756,10 @@ const picList = computed(() => {
const showCropper = ref(false); const showCropper = ref(false);
const cropperImageSrc = ref(''); const cropperImageSrc = ref('');
watch(showCropper, (visible) => {
emit('cropper-visible-change', visible);
});
const chooseImage = () => { const chooseImage = () => {
const remain = 3 - picList.value.length; const remain = 3 - picList.value.length;
if (remain <= 0) { if (remain <= 0) {

View File

@ -9,11 +9,11 @@
<view class="top-back" @click="handleBack"> <view class="top-back" @click="handleBack">
<view class="back-arrow"></view> <view class="back-arrow"></view>
</view> </view>
<view class="top-title"> <view v-if="!isCropperActive" class="top-title">
<text class="ellipsis">{{ title }}</text> <text class="ellipsis">{{ title }}</text>
</view> </view>
<view <view
v-if="!pageLoading" v-if="!pageLoading && !isCropperActive"
class="top-meta" class="top-meta"
:style="{ paddingRight: menuButtonRightRpx + 'rpx' }" :style="{ paddingRight: menuButtonRightRpx + 'rpx' }"
> >
@ -48,6 +48,7 @@
:reportFlag="reportFlag" :reportFlag="reportFlag"
:subjectId="paperInfo.subjectId" :subjectId="paperInfo.subjectId"
@submit="submitQuestion" @submit="submitQuestion"
@cropper-visible-change="onCropperVisibleChange"
/> />
</view> </view>
@ -119,6 +120,7 @@ import Question from './components/Question.vue';
const pageLoading = ref(true); const pageLoading = ref(true);
const submitLoading = ref(false); const submitLoading = ref(false);
const menuButtonRightRpx = ref(0); const menuButtonRightRpx = ref(0);
const isCropperActive = ref(false);
const paperList = ref<any[]>([]); const paperList = ref<any[]>([]);
const paperInfo = ref<any>({}); const paperInfo = ref<any>({});
@ -322,6 +324,10 @@ const submitQuestion = (answer: any) => {
} }
}; };
const onCropperVisibleChange = (visible: boolean) => {
isCropperActive.value = !!visible;
};
const submitTrain = async () => { const submitTrain = async () => {
clearTimer(); clearTimer();
submitLoading.value = true; submitLoading.value = true;
@ -446,6 +452,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
clearTimer(); clearTimer();
isCropperActive.value = false;
paperInfo.value = {}; paperInfo.value = {};
paperList.value = []; paperList.value = [];
activeIdx.value = 0; activeIdx.value = 0;
@ -495,6 +502,7 @@ onUnmounted(() => {
z-index: 10; z-index: 10;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: 3rpx 12rpx 2rpx; padding: 3rpx 12rpx 2rpx;
gap: 8rpx; gap: 8rpx;
} }

View File

@ -301,7 +301,7 @@ onShow(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.hist-page { .hist-page {
min-height: 100vh; height: 100vh;
background: #f5f7ff; background: #f5f7ff;
position: relative; position: relative;
overflow: hidden; overflow: hidden;

View File

@ -274,7 +274,7 @@ onShow(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.hw-page { .hw-page {
min-height: 100vh; height: 100vh;
background: #f5f7ff; background: #f5f7ff;
position: relative; position: relative;
overflow: hidden; overflow: hidden;

View File

@ -9,7 +9,7 @@
<view class="hero"> <view class="hero">
<view class="hero-greet"> <view class="hero-greet">
<view class="hero-hello">{{ greeting }} 👋</view> <view class="hero-hello">{{ greeting }} 👋</view>
<view class="hero-title">小乐<text class="hero-title-ai">AI</text></view> <view class="hero-title">灵犀<text class="hero-title-ai">AI</text></view>
<view class="hero-sub">让学习像玩游戏一样有趣</view> <view class="hero-sub">让学习像玩游戏一样有趣</view>
</view> </view>
<view class="hero-mascot"> <view class="hero-mascot">
@ -57,10 +57,7 @@
<view v-else class="user-card login-card" @click="goLogin"> <view v-else class="user-card login-card" @click="goLogin">
<view class="login-illu"> <view class="login-illu">
<svg viewBox="0 0 24 24" fill="none"> <text class="icon-emoji">👤</text>
<circle cx="12" cy="8" r="4" stroke="#4F46E5" stroke-width="1.8" />
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8" stroke="#4F46E5" stroke-width="1.8" stroke-linecap="round" />
</svg>
</view> </view>
<view class="login-text"> <view class="login-text">
<view class="login-title">点击登录账号</view> <view class="login-title">点击登录账号</view>
@ -79,11 +76,7 @@
<view class="grid-card grid-card-primary" @click="navigate('/pages/homework/index')"> <view class="grid-card grid-card-primary" @click="navigate('/pages/homework/index')">
<view class="grid-card-decor"></view> <view class="grid-card-decor"></view>
<view class="grid-card-icon"> <view class="grid-card-icon">
<svg viewBox="0 0 24 24" fill="none"> <text class="icon-emoji">📝</text>
<path d="M4 4h12l4 4v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
stroke="#fff" stroke-width="1.8" stroke-linejoin="round" />
<path d="M16 4v4h4M7 13h8M7 17h6" stroke="#fff" stroke-width="1.8" stroke-linecap="round" />
</svg>
</view> </view>
<view class="grid-card-info"> <view class="grid-card-info">
<view class="grid-card-title">我的作业</view> <view class="grid-card-title">我的作业</view>
@ -98,10 +91,7 @@
<view class="grid-card grid-card-cta" @click="navigate('/pages/homework/history/index')"> <view class="grid-card grid-card-cta" @click="navigate('/pages/homework/history/index')">
<view class="grid-card-decor"></view> <view class="grid-card-decor"></view>
<view class="grid-card-icon"> <view class="grid-card-icon">
<svg viewBox="0 0 24 24" fill="none"> <text class="icon-emoji">📚</text>
<circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="1.8" />
<path d="M12 7v5l3 2" stroke="#fff" stroke-width="1.8" stroke-linecap="round" />
</svg>
</view> </view>
<view class="grid-card-info"> <view class="grid-card-info">
<view class="grid-card-title">作业记录</view> <view class="grid-card-title">作业记录</view>
@ -117,11 +107,12 @@
</view> </view>
<view class="tools"> <view class="tools">
<view class="tool-item" v-for="t in tools" :key="t.id" @click="onToolClick(t)"> <view class="tool-item" :class="{ disabled: !t.enabled }" v-for="t in tools" :key="t.id" @click="onToolClick(t)">
<view class="tool-icon" :style="{ background: t.color }"> <view class="tool-icon" :style="{ background: t.enabled ? t.color : '#e2e8f0' }">
<text class="tool-icon-emoji" v-if="t.emoji">{{ t.emoji }}</text> <text class="tool-icon-emoji" v-if="t.emoji">{{ t.emoji }}</text>
</view> </view>
<text class="tool-name">{{ t.name }}</text> <text class="tool-name">{{ t.name }}</text>
<text v-if="!t.enabled" class="tool-badge">开发中</text>
</view> </view>
</view> </view>
@ -163,13 +154,14 @@ const greeting = computed(() => {
}); });
const tools = [ const tools = [
{ id: 'speak', name: '口算练习', emoji: '🧮', color: 'linear-gradient(135deg,#fde68a 0%,#f59e0b 100%)' }, { id: 'speak', name: '口算练习', emoji: '🧮', color: 'linear-gradient(135deg,#fde68a 0%,#f59e0b 100%)', enabled: false },
{ id: 'word', name: '英语单词', emoji: '🅰️', color: 'linear-gradient(135deg,#bfdbfe 0%,#3b82f6 100%)' }, { id: 'word', name: '英语单词', emoji: '🅰️', color: 'linear-gradient(135deg,#bfdbfe 0%,#3b82f6 100%)', enabled: false },
{ id: 'wrong', name: '错题本', emoji: '📕', color: 'linear-gradient(135deg,#fecaca 0%,#ef4444 100%)' }, { id: 'wrong', name: '错题本', emoji: '📕', color: 'linear-gradient(135deg,#fecaca 0%,#ef4444 100%)', enabled: true },
{ id: 'rank', name: '排行榜', emoji: '🏆', color: 'linear-gradient(135deg,#bbf7d0 0%,#22c55e 100%)' }, { id: 'rank', name: '排行榜', emoji: '🏆', color: 'linear-gradient(135deg,#bbf7d0 0%,#22c55e 100%)', enabled: false },
]; ];
const onToolClick = (t: any) => { const onToolClick = (t: any) => {
if (!t?.enabled) return;
if (t.id === 'wrong') { if (t.id === 'wrong') {
navigate('/pages/wrong/index'); navigate('/pages/wrong/index');
return; return;
@ -500,9 +492,8 @@ onShow(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
svg { .icon-emoji {
width: 56rpx; font-size: 46rpx;
height: 56rpx;
} }
} }
@ -608,9 +599,8 @@ onShow(() => {
justify-content: center; justify-content: center;
backdrop-filter: blur(8rpx); backdrop-filter: blur(8rpx);
svg { .icon-emoji {
width: 44rpx; font-size: 42rpx;
height: 44rpx;
} }
} }
@ -664,6 +654,7 @@ onShow(() => {
} }
.tool-item { .tool-item {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -674,6 +665,10 @@ onShow(() => {
&:active { &:active {
transform: scale(0.92); transform: scale(0.92);
} }
&.disabled {
opacity: 0.72;
}
} }
.tool-icon { .tool-icon {
@ -698,6 +693,12 @@ onShow(() => {
font-weight: 500; font-weight: 500;
} }
.tool-badge {
margin-top: 4rpx;
font-size: 18rpx;
color: #94a3b8;
}
/* 退出 */ /* 退出 */
.logout { .logout {
position: relative; position: relative;

View File

@ -13,8 +13,8 @@
<view class="brand-logo-glow"></view> <view class="brand-logo-glow"></view>
<image :src="logoUrl" mode="aspectFit" class="brand-logo-img" /> <image :src="logoUrl" mode="aspectFit" class="brand-logo-img" />
</view> </view>
<view class="brand-name">小乐<text class="brand-name-ai">AI</text></view> <view class="brand-name">灵犀<text class="brand-name-ai">AI</text></view>
<view class="brand-slogan">智能学习助手 · 让学习更轻松1</view> <view class="brand-slogan">智能学习助手 · 让学习更轻松</view>
</view> </view>
<!-- 角色切换 --> <!-- 角色切换 -->
@ -139,12 +139,12 @@
</block> </block>
</view> </view>
<view class="agreement"> <!-- <view class="agreement">
<text>登录即表示同意</text> <text>登录即表示同意</text>
<text class="link">用户协议</text> <text class="link" @click="openAgreement('user')">用户协议</text>
<text></text> <text></text>
<text class="link">隐私政策</text> <text class="link" @click="openAgreement('privacy')">隐私政策</text>
</view> </view> -->
<!-- 登录按钮 --> <!-- 登录按钮 -->
<block v-if="isParentLogin && parentStage === 'authorize'"> <block v-if="isParentLogin && parentStage === 'authorize'">
@ -187,7 +187,7 @@ import { setCache, getCache } from '@/utils/cache';
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome'; import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com'; const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
const logoUrl = `${OSS_URL}/urm/logo.png`; const logoUrl = `https://lxx-web-1313840333.cos.ap-chengdu.myqcloud.com/urm/mode.png`;
const PHONE_REG = /^1[3-9]\d{9}$/; const PHONE_REG = /^1[3-9]\d{9}$/;
@ -294,6 +294,13 @@ const onLogoTap = () => {
} }
}; };
const openAgreement = (type: 'user' | 'privacy') => {
const url = type === 'user'
? '/pages/agreement/user/index'
: '/pages/agreement/privacy/index';
uni.navigateTo({ url });
};
const getLoginTargetUrl = () => { const getLoginTargetUrl = () => {
const teacherHome = '/pages/teacher/index/index'; const teacherHome = '/pages/teacher/index/index';
const parentHome = '/pages/parent/home/index'; const parentHome = '/pages/parent/home/index';
@ -760,6 +767,7 @@ const onLogin = async () => {
.link { .link {
color: #4f46e5; color: #4f46e5;
padding: 0 6rpx;
} }
} }

View File

@ -128,8 +128,8 @@ const onBindSuccess = async () => {
const showAbout = () => { const showAbout = () => {
uni.showModal({ uni.showModal({
title: '关于小乐AI', title: '关于灵犀学AI',
content: '小乐AI为学生、老师和家长提供学习过程分析与成长陪伴。', content: '灵犀学AI为学生、老师和家长提供学习过程分析与成长陪伴。',
showCancel: false, showCancel: false,
}); });
}; };

View File

@ -9,7 +9,7 @@
<image class="avatar" :src="avatarUrl" mode="aspectFill" /> <image class="avatar" :src="avatarUrl" mode="aspectFill" />
<view class="profile-main"> <view class="profile-main">
<text class="hello">您好{{ teacherName }}</text> <text class="hello">您好{{ teacherName }}</text>
<text class="school ellipsis">{{ teacherInfo.schoolName || '欢迎使用小乐AI教师端' }}</text> <text class="school ellipsis">{{ teacherInfo.schoolName || '欢迎使用灵犀学AI教师端' }}</text>
</view> </view>
<view class="logout-btn" @click="handleLogout">退出</view> <view class="logout-btn" @click="handleLogout">退出</view>
</view> </view>

View File

@ -16,14 +16,13 @@
</view> </view>
</view> </view>
<view v-if="!pageLoading" class="content"> <view v-if="!pageLoading && !loadError" class="content">
<view class="subject-grid"> <view class="subject-grid">
<view <view
v-for="subject in subjectStats" v-for="subject in subjectStats"
:key="subject.name" :key="subject.name"
class="subject-card" class="subject-card"
:class="{ active: selectedSubject === subject.name }" @click="openSubject(subject)"
@click="selectSubject(subject.name)"
> >
<view class="subject-icon" :class="subject.tone"> <view class="subject-icon" :class="subject.tone">
<text>{{ subject.shortName }}</text> <text>{{ subject.shortName }}</text>
@ -48,136 +47,18 @@
</view> </view>
</view> </view>
</view> </view>
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
<text>{{ tab.label }}</text>
<text class="tab-count">{{ getTabCount(tab.key) }}</text>
</view>
</view>
<view class="filter-row">
<picker class="filter-picker" :range="gradeOptions" :value="gradeIndex" @change="onGradeChange">
<view class="filter-pill">
<text>{{ gradeOptions[gradeIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="typeOptions" :value="typeIndex" @change="onTypeChange">
<view class="filter-pill">
<text>{{ typeOptions[typeIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="examOptions" :value="examIndex" @change="onExamChange">
<view class="filter-pill">
<text>{{ examOptions[examIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
</view>
<view v-if="filteredQuestions.length" class="question-list">
<view v-for="item in filteredQuestions" :key="item.id" class="question-card">
<view class="question-head">
<view class="question-index">
<text>{{ item.number }}</text>
<text>满分{{ item.score }}</text>
</view>
<view class="status-tag" :class="item.status">{{ getStatusText(item.status) }}</view>
</view>
<view class="exam-name">{{ item.examName }}</view>
<view class="question-title">
<rich-text :nodes="item.title" />
</view>
<view v-if="item.options.length" class="option-list">
<view v-for="option in item.options" :key="option.key" class="option-item">
<text class="option-key">{{ option.key }}</text>
<view class="option-value">
<rich-text :nodes="option.value" />
</view>
</view>
</view>
<view class="answer-tabs">
<view
class="answer-tab"
:class="{ active: item.openPanel === 'mine' }"
@click="item.openPanel = 'mine'"
>
我的作答
</view>
<view
class="answer-tab"
:class="{ active: item.openPanel === 'analysis' }"
@click="item.openPanel = 'analysis'"
>
答案&解析
</view>
<view class="answer-tab action" @click="toggleReview(item)">
{{ item.inReviewBook ? '移出复习本' : '加入复习本' }}
</view>
</view>
<view v-if="item.openPanel === 'mine'" class="info-box mine">
<view class="info-title">我的作答</view>
<view class="info-content">{{ item.myAnswer || '未作答' }}</view>
</view>
<template v-else>
<view class="info-box correct">
<view class="info-title">正确答案</view>
<view class="info-content">{{ item.answer || '暂无答案' }}</view>
</view>
<view v-if="item.analysis" class="info-box analysis">
<view class="info-title">试题解析</view>
<view class="info-content">
<rich-text :nodes="item.analysis" />
</view>
</view>
<view v-if="item.knowledge" class="info-box knowledge">
<view class="info-title">知识点</view>
<view class="info-content">{{ item.knowledge }}</view>
</view>
</template>
<view class="card-actions">
<view class="mark-btn" @click="markMastered(item)">
{{ item.status === 'mastered' ? '标为未掌握' : '标为已掌握' }}
</view>
<view class="practice-btn" @click="practiceAgain(item)">再练一次</view>
</view>
</view>
</view>
<view v-else class="empty-wrap">
<xy-Empty type="learn" content="当前筛选下暂无错题" />
</view>
</view> </view>
<view v-else class="loading-wrap"> <view v-else class="loading-wrap">
<xy-Empty type="loading" content="正在整理错题..." /> <xy-Empty type="loading" content="正在整理错题..." />
</view> </view>
<view v-if="!pageLoading && loadError" class="error-wrap">
<xy-Empty type="network" :content="loadError" />
<view class="retry-btn" @click="loadSummary">点击重试</view>
</view>
<view class="safe-bottom"></view> <view class="safe-bottom"></view>
</scroll-view> </scroll-view>
<!-- <view class="home-float" @click="goHome">
<view class="home-roof"></view>
<view class="home-body"></view>
</view> -->
<view v-if="!pageLoading && filteredQuestions.length" class="bottom-bar">
<view class="download-btn" @click="downloadWrongQuestions">一键下载错题</view>
</view>
</view> </view>
</template> </template>
@ -185,340 +66,79 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { getWrongQuestionSummary, type WrongQuestionSummaryResult } from '@/api/parent'; import type { WrongQuestionSummaryResult } from '@/api/parent';
import { getListCompleteRecord, getPaperRecordDetail } from '../../api/homework';
import { resource_type_homework } from '@/constants/doWork';
import { useParentStore } from '@/state/modules/parent'; import { useParentStore } from '@/state/modules/parent';
import { useUserStore } from '@/state/modules/user'; import { useUserStore } from '@/state/modules/user';
import { buildSubjectStatRow, fetchWrongSummary } from './shared';
type WrongStatus = 'new' | 'review' | 'mastered';
type TabKey = 'all' | 'new' | 'mastered';
interface WrongOption {
key: string;
value: string;
}
interface WrongQuestion {
id: string;
recordId: string;
subject: string;
grade: string;
type: string;
examName: string;
number: number;
score: number;
title: string;
options: WrongOption[];
myAnswer: string;
answer: string;
analysis: string;
knowledge: string;
status: WrongStatus;
inReviewBook: boolean;
openPanel: 'mine' | 'analysis';
}
const userStore = useUserStore(); const userStore = useUserStore();
const parentStore = useParentStore(); const parentStore = useParentStore();
const { userInfo } = storeToRefs(userStore); const { userInfo } = storeToRefs(userStore);
const { activeChild } = storeToRefs(parentStore);
const pageLoading = ref(true); const pageLoading = ref(true);
const summaryData = ref<WrongQuestionSummaryResult | null>(null); const summaryData = ref<WrongQuestionSummaryResult | null>(null);
const wrongQuestions = ref<WrongQuestion[]>([]); const loadError = ref('');
const selectedSubject = ref('全部'); const skipFirstOnShow = ref(true);
const activeTab = ref<TabKey>('all');
const gradeIndex = ref(0);
const typeIndex = ref(0);
const examIndex = ref(0);
const tabs: { key: TabKey; label: string }[] = [
{ key: 'all', label: '全部错题' },
{ key: 'new', label: '未掌握' },
{ key: 'mastered', label: '已掌握' },
];
const subjectTones = ['blue', 'green', 'orange', 'rose', 'violet'];
const totalWrong = computed(() => { const totalWrong = computed(() => {
const subjects = summaryData.value?.subjects || []; const subjects = summaryData.value?.subjects || [];
if (subjects.length) { if (subjects.length) {
return subjects.reduce((sum, item) => sum + (item.totalCount || 0), 0); return subjects.reduce((sum, item) => sum + (item.totalCount || 0), 0);
} }
return wrongQuestions.value.length; return 0;
});
const buildSubjectStatRow = (
name: string,
stats: { total: number; newCount: number; reviewCount: number; masteredCount: number },
index: number,
) => ({
name,
shortName: name === '全部' ? '全' : name.slice(0, 1),
tone: subjectTones[index % subjectTones.length],
total: stats.total,
newCount: stats.newCount,
reviewCount: stats.reviewCount,
masteredCount: stats.masteredCount,
}); });
const subjectStats = computed(() => { const subjectStats = computed(() => {
const apiSubjects = summaryData.value?.subjects || []; const apiSubjects = summaryData.value?.subjects || [];
if (apiSubjects.length) { return apiSubjects.map((item, index) => ({
const allStats = apiSubjects.reduce<{ total: number; newCount: number; reviewCount: number; masteredCount: number }>( ...buildSubjectStatRow(
(acc, item) => ({ item.subjectName || '其他',
total: acc.total + (item.totalCount || 0), {
newCount: acc.newCount + (item.newCount || 0), total: item.totalCount || 0,
reviewCount: acc.reviewCount + (item.reviewCount || 0), newCount: item.newCount || 0,
masteredCount: acc.masteredCount + (item.masteredCount || 0), reviewCount: item.reviewCount || 0,
}), masteredCount: item.masteredCount || 0,
{ total: 0, newCount: 0, reviewCount: 0, masteredCount: 0 }, },
); index,
return [ ),
buildSubjectStatRow('全部', allStats, 0), subjectId: item.subjectId,
...apiSubjects.map((item, index) => buildSubjectStatRow( }));
item.subjectName || '其他', });
{
total: item.totalCount || 0,
newCount: item.newCount || 0,
reviewCount: item.reviewCount || 0,
masteredCount: item.masteredCount || 0,
},
index + 1,
)),
];
}
const subjects = ['全部', ...Array.from(new Set(wrongQuestions.value.map((item) => item.subject).filter(Boolean)))]; const openSubject = (subject: { name: string; subjectId?: number }) => {
return subjects.map((name, index) => { const query = [`subject=${encodeURIComponent(subject.name)}`];
const list = name === '全部' ? wrongQuestions.value : wrongQuestions.value.filter((item) => item.subject === name); if (subject.subjectId != null) {
return buildSubjectStatRow(name, { query.push(`subjectId=${subject.subjectId}`);
total: list.length, }
newCount: list.filter((item) => item.status === 'new').length, const userId = parentStore.activeChild.id || parentStore.activeChildId;
reviewCount: list.filter((item) => item.inReviewBook).length, if (userId) {
masteredCount: list.filter((item) => item.status === 'mastered').length, query.push(`userId=${userId}`);
}, index); }
uni.navigateTo({
url: `/pages/wrong/list?${query.join('&')}`,
}); });
});
const scopedQuestions = computed(() => {
let list = wrongQuestions.value;
if (selectedSubject.value !== '全部') {
list = list.filter((item) => item.subject === selectedSubject.value);
}
return list;
});
const gradeOptions = computed(() => ['不限年级', ...Array.from(new Set(scopedQuestions.value.map((item) => item.grade).filter(Boolean)))]);
const typeOptions = computed(() => ['不限类型', ...Array.from(new Set(scopedQuestions.value.map((item) => item.type).filter(Boolean)))]);
const examOptions = computed(() => ['选择考试', ...Array.from(new Set(scopedQuestions.value.map((item) => item.examName).filter(Boolean)))]);
const filteredQuestions = computed(() => {
const grade = gradeOptions.value[gradeIndex.value];
const type = typeOptions.value[typeIndex.value];
const exam = examOptions.value[examIndex.value];
return scopedQuestions.value.filter((item) => {
const tabMatched = activeTab.value === 'all'
|| (activeTab.value === 'new' && item.status !== 'mastered')
|| (activeTab.value === 'mastered' && item.status === 'mastered');
const gradeMatched = !grade || grade === '不限年级' || item.grade === grade;
const typeMatched = !type || type === '不限类型' || item.type === type;
const examMatched = !exam || exam === '选择考试' || item.examName === exam;
return tabMatched && gradeMatched && typeMatched && examMatched;
});
});
const getTabCount = (key: TabKey) => {
if (key === 'all') return scopedQuestions.value.length;
if (key === 'new') return scopedQuestions.value.filter((item) => item.status !== 'mastered').length;
return scopedQuestions.value.filter((item) => item.status === 'mastered').length;
};
const selectSubject = (name: string) => {
selectedSubject.value = name;
gradeIndex.value = 0;
typeIndex.value = 0;
examIndex.value = 0;
};
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); };
const onTypeChange = (e: any) => { typeIndex.value = Number(e.detail.value); };
const onExamChange = (e: any) => { examIndex.value = Number(e.detail.value); };
const normalizeHtml = (html?: string) => {
if (!html) return '';
return String(html)
.replace(/http:\/\//gi, 'https://')
.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
};
const normalizeAnswer = (value: any) => {
if (Array.isArray(value)) return value.join('、');
if (value == null) return '';
return String(value).replace(/^\[|\]$/g, '').replace(/"/g, '').replace(/,/g, '、');
};
const parseOptions = (raw: any): WrongOption[] => {
let list = raw;
if (typeof list === 'string') {
try {
list = JSON.parse(list);
} catch {
list = [];
}
}
if (!Array.isArray(list)) return [];
return list.map((item, index) => ({
key: typeof item === 'string' ? String.fromCharCode(65 + index) : (item?.key || item?.optionKey || item?.label || String.fromCharCode(65 + index)),
value: normalizeHtml(typeof item === 'string' ? item : (item?.value || item?.optionValue || '')),
})).filter((item) => item.value);
};
const getGradeText = (record: any, item: any) => {
return item.gradeName || item.grade || record.gradeName || record.grade || '未分年级';
};
const isWrongQuestion = (item: any) => {
const hasAnswer = Boolean(item.submitAnswer || item.submitAnswerPic);
const correct = item.correctResult === 1 || item.correctResult === true || item.markFlag === 1 || item.markFlag === true;
return hasAnswer && !correct;
};
const mapQuestion = (record: any, item: any, index: number): WrongQuestion => ({
id: String(item.itemId || item.id || `${record.id}-${index}`),
recordId: String(record.id || record.recordId || ''),
subject: item.subjectName || record.subjectName || '其他',
grade: getGradeText(record, item),
type: item.titleChannelTypeName || item.chanelTypeName || '综合题',
examName: record.paperName || record.jobName || item.paperName || '未命名考试',
number: Number(item.titleSort || item.sort || index + 1),
score: Number(item.titleScore || item.score || 0),
title: normalizeHtml(item.titleMu || item.title || item.pidTitle || '暂无题干'),
options: parseOptions(item.optionsMu ?? item.options),
myAnswer: normalizeAnswer(item.submitAnswer || item.submitAnswerPic),
answer: normalizeAnswer(item.answerMu || item.answer),
analysis: normalizeHtml(item.titleAnalyzeMu || item.titleAnalyze || item.aiAnalyze),
knowledge: item.knowledgeName || item.knowledge || item.knowledgePointName || '待归类',
status: 'new',
inReviewBook: false,
openPanel: 'analysis',
});
const resolveUserId = async () => {
try {
if (!userInfo.value.id) {
await userStore.fetchParentUserInfo();
}
if (userInfo.value.id) {
await parentStore.fetchBindChildren(userInfo.value.id);
}
} catch {
/* ignore */
}
if (activeChild.value.id) {
return activeChild.value.id;
}
try {
if (!userInfo.value.id) {
await userStore.fetchUserInfo();
}
} catch {
/* ignore */
}
return userInfo.value.id || '';
}; };
const loadSummary = async () => { const loadSummary = async () => {
const userId = await resolveUserId(); if (pageLoading.value) return;
if (!userId) {
summaryData.value = null;
return;
}
try {
const res = await getWrongQuestionSummary({ userId });
summaryData.value = res.data || null;
} catch (err) {
console.error('[wrong] summary err', err);
summaryData.value = null;
}
};
const loadWrongQuestions = async () => {
pageLoading.value = true; pageLoading.value = true;
loadError.value = '';
try { try {
await loadSummary(); summaryData.value = await fetchWrongSummary();
const recordsRes: any = await getListCompleteRecord();
const records = Array.isArray(recordsRes?.data) ? recordsRes.data : Array.isArray(recordsRes) ? recordsRes : [];
const detailRecords = await Promise.all(
records.slice(0, 20).map(async (record: any) => {
try {
const detailRes: any = await getPaperRecordDetail({
recordId: record.id || record.recordId,
reportFlag: 1,
});
return {
record,
questions: detailRes?.data?.titleVoList || detailRes?.data?.subjectTitleVoList || [],
};
} catch (err) {
console.warn('[wrong] detail err', err);
return { record, questions: [] };
}
}),
);
wrongQuestions.value = detailRecords.reduce<WrongQuestion[]>((list, { record, questions }) => {
const current = questions
.filter(isWrongQuestion)
.map((item: any, index: number) => mapQuestion(record, item, index));
return list.concat(current);
}, []);
} catch (err) { } catch (err) {
console.error('[wrong] load err', err); console.error('[wrong/index] summary load failed', err);
uni.showToast({ title: '错题加载失败', icon: 'none' }); summaryData.value = null;
wrongQuestions.value = []; loadError.value = '错题数据加载失败,请检查网络后重试';
} finally { } finally {
pageLoading.value = false; pageLoading.value = false;
} }
}; };
const getStatusText = (status: WrongStatus) => {
if (status === 'mastered') return '已掌握';
if (status === 'review') return '复习中';
return '未掌握';
};
const toggleReview = (item: WrongQuestion) => {
item.inReviewBook = !item.inReviewBook;
if (item.inReviewBook && item.status === 'new') item.status = 'review';
if (!item.inReviewBook && item.status === 'review') item.status = 'new';
uni.showToast({ title: item.inReviewBook ? '已加入复习本' : '已移出复习本', icon: 'none' });
};
const markMastered = (item: WrongQuestion) => {
item.status = item.status === 'mastered' ? 'new' : 'mastered';
if (item.status === 'mastered') item.inReviewBook = false;
};
const practiceAgain = (item: WrongQuestion) => {
uni.navigateTo({
url: `/pages/doWork/index?id=${item.recordId}&recordId=${item.recordId}&reportFlag=1&resourceType=${resource_type_homework}&idx=${Math.max(item.number - 1, 0)}`,
});
};
const downloadWrongQuestions = () => {
uni.showToast({ title: '下载功能对接中', icon: 'none' });
};
const goHome = () => {
uni.reLaunch({ url: '/pages/index/index' });
};
onShow(async () => { onShow(async () => {
if (skipFirstOnShow.value) {
skipFirstOnShow.value = false;
return;
}
try { try {
if (!userInfo.value.id) { if (!userInfo.value.id) {
await userStore.fetchParentUserInfo(); await userStore.fetchParentUserInfo();
@ -533,7 +153,8 @@ onShow(async () => {
}); });
onLoad(() => { onLoad(() => {
loadWrongQuestions(); pageLoading.value = false;
loadSummary();
}); });
</script> </script>
@ -654,18 +275,13 @@ onLoad(() => {
border-radius: 32rpx; border-radius: 32rpx;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14rpx 28rpx rgba(8, 145, 178, 0.08); box-shadow: 0 14rpx 28rpx rgba(8, 145, 178, 0.08);
transition: transform 160ms ease, border-color 160ms ease; transition: transform 160ms ease;
} }
.subject-card:active { .subject-card:active {
transform: scale(0.98); transform: scale(0.98);
} }
.subject-card.active {
border-color: #22d3ee;
box-shadow: 0 18rpx 34rpx rgba(8, 145, 178, 0.14);
}
.subject-icon { .subject-icon {
grid-row: span 2; grid-row: span 2;
width: 96rpx; width: 96rpx;
@ -730,354 +346,35 @@ onLoad(() => {
font-size: 20rpx; font-size: 20rpx;
} }
.tabs {
display: flex;
gap: 14rpx;
margin-top: 28rpx;
padding: 8rpx;
border-radius: 999rpx;
background: rgba(226, 232, 240, 0.55);
}
.tab {
flex: 1;
height: 66rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #64748b;
font-size: 24rpx;
font-weight: 800;
}
.tab.active {
color: #ffffff;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 10rpx 18rpx rgba(8, 145, 178, 0.18);
}
.tab-count {
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
background: rgba(255, 255, 255, 0.24);
font-size: 20rpx;
}
.filter-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14rpx;
margin-top: 22rpx;
}
.filter-picker {
min-width: 0;
}
.filter-pill {
height: 70rpx;
padding: 0 16rpx;
border: 2rpx solid #e2e8f0;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #334155;
background: rgba(255, 255, 255, 0.95);
font-size: 22rpx;
font-weight: 700;
}
.filter-pill text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-arrow {
width: 0;
height: 0;
border-left: 7rpx solid transparent;
border-right: 7rpx solid transparent;
border-top: 9rpx solid #0891b2;
flex-shrink: 0;
}
.question-list {
display: flex;
flex-direction: column;
gap: 24rpx;
margin-top: 26rpx;
}
.question-card {
padding: 28rpx;
border-radius: 34rpx;
background: #ffffff;
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.08);
}
.question-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.question-index {
display: flex;
align-items: baseline;
gap: 10rpx;
}
.question-index text:first-child {
color: #f97316;
font-size: 28rpx;
font-weight: 900;
}
.question-index text:last-child {
color: #94a3b8;
font-size: 22rpx;
}
.status-tag {
padding: 8rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 800;
}
.status-tag.new {
color: #b45309;
background: #fef3c7;
}
.status-tag.review {
color: #0369a1;
background: #e0f2fe;
}
.status-tag.mastered {
color: #15803d;
background: #dcfce7;
}
.exam-name {
margin-top: 18rpx;
color: #164e63;
font-size: 26rpx;
font-weight: 900;
line-height: 1.45;
}
.question-title {
margin-top: 20rpx;
color: #0f172a;
font-size: 28rpx;
font-weight: 700;
line-height: 1.65;
}
.question-title :deep(image),
.option-value :deep(image),
.info-content :deep(image) {
max-width: 100%;
height: auto;
}
.option-list {
margin-top: 16rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.option-item {
display: flex;
align-items: flex-start;
gap: 12rpx;
color: #334155;
font-size: 25rpx;
line-height: 1.55;
}
.option-key {
color: #0f172a;
font-weight: 900;
}
.option-value {
flex: 1;
min-width: 0;
}
.answer-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-top: 24rpx;
border-top: 2rpx solid #f1f5f9;
}
.answer-tab {
min-height: 76rpx;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 24rpx;
font-weight: 800;
}
.answer-tab.active {
color: #0891b2;
}
.answer-tab.action {
color: #475569;
}
.info-box {
margin-top: 12rpx;
padding: 20rpx;
border: 2rpx solid transparent;
border-radius: 22rpx;
}
.info-box.mine {
border-color: #fde68a;
background: #fffbeb;
}
.info-box.correct {
border-color: #bbf7d0;
background: #f0fdf4;
}
.info-box.analysis {
border-color: #bae6fd;
background: #f0f9ff;
}
.info-box.knowledge {
border-color: #fde68a;
background: #fffbeb;
}
.info-title {
margin-bottom: 10rpx;
color: #164e63;
font-size: 23rpx;
font-weight: 900;
}
.info-content {
color: #334155;
font-size: 25rpx;
line-height: 1.6;
}
.card-actions {
display: flex;
gap: 16rpx;
margin-top: 22rpx;
}
.mark-btn,
.practice-btn {
flex: 1;
height: 72rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 900;
}
.mark-btn {
color: #0891b2;
background: #ecfeff;
}
.practice-btn {
color: #ffffff;
background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%);
box-shadow: 0 10rpx 18rpx rgba(22, 163, 74, 0.18);
}
.empty-wrap,
.loading-wrap { .loading-wrap {
height: 560rpx; height: 560rpx;
} }
.home-float { .error-wrap {
position: fixed; height: 560rpx;
right: 32rpx;
bottom: 144rpx;
z-index: 10;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(180deg, #38bdf8, #0284c7); gap: 20rpx;
box-shadow: 0 12rpx 24rpx rgba(2, 132, 199, 0.28);
} }
.home-roof { .retry-btn {
position: absolute; min-width: 220rpx;
top: 27rpx; height: 72rpx;
width: 32rpx; padding: 0 32rpx;
height: 32rpx; border-radius: 999rpx;
border-left: 5rpx solid #ffffff;
border-top: 5rpx solid #ffffff;
transform: rotate(45deg);
}
.home-body {
width: 34rpx;
height: 28rpx;
margin-top: 20rpx;
border: 5rpx solid #ffffff;
border-top: 0;
border-radius: 0 0 6rpx 6rpx;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9;
padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 -10rpx 24rpx rgba(15, 23, 42, 0.08);
}
.download-btn {
height: 84rpx;
border-radius: 22rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #ffffff; color: #ffffff;
background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%); font-size: 24rpx;
font-size: 28rpx; font-weight: 700;
font-weight: 900; background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 10rpx 18rpx rgba(8, 145, 178, 0.2);
} }
.safe-bottom { .safe-bottom {
height: 150rpx; height: 48rpx;
} }
</style> </style>

482
src/pages/wrong/list.vue Normal file
View File

@ -0,0 +1,482 @@
<template>
<view class="wrong-list-page">
<scroll-view class="page-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
<view class="content">
<view class="filter-row">
<picker
class="filter-picker"
:range="timeRangeLabels"
:value="timeRangeIndex"
@change="onTimeRangeChange"
>
<view class="filter-pill">
<text>{{ timeRangeLabels[timeRangeIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
</view>
<view v-if="listLoading" class="loading-wrap">
<xy-Empty type="loading" content="正在整理错题..." />
</view>
<view v-else-if="wrongQuestions.length" class="question-list">
<view v-for="item in wrongQuestions" :key="item.id" class="question-card">
<view class="question-main" @click="toggleExpand(item)">
<view class="question-preview rich-content">
<rich-text :nodes="item.previewHtml" />
</view>
<view class="meta-row">
<view class="meta-item">
<text class="meta-label">错误次数</text>
<text class="meta-value error-count">{{ item.errorCount ?? 0 }}</text>
</view>
<view class="meta-item">
<text class="meta-label">首次错误</text>
<text class="meta-value">{{ item.firstErrorTimeText }}</text>
</view>
<view class="meta-item">
<text class="meta-label">最近错误</text>
<text class="meta-value">{{ item.lastErrorTimeText }}</text>
</view>
</view>
<view class="card-head-actions">
<view class="expand-arrow" :class="{ expanded: item.expanded }"></view>
</view>
</view>
<view v-if="item.expanded && item.questionTitle" class="expand-section">
<view v-if="item.questionTitle.typeName" class="expand-item">
<text class="expand-label">题型</text>
<text class="expand-value">{{ item.questionTitle.typeName }}</text>
</view>
<view v-if="item.questionTitle.difficultyLevel" class="expand-item">
<text class="expand-label">难度</text>
<text class="expand-value">{{ item.questionTitle.difficultyLevel }}</text>
</view>
<view v-if="item.questionTitle.score" class="expand-item">
<text class="expand-label">分值</text>
<text class="expand-value">{{ item.questionTitle.score }}</text>
</view>
<view v-if="item.questionTitle.submitAnswer" class="expand-item full">
<text class="expand-label">提交答案</text>
<text class="expand-value submit-answer">{{ item.questionTitle.submitAnswer }}</text>
</view>
<view v-if="item.expandContent?.stemHtml" class="expand-item full">
<text class="expand-label">题干</text>
<view class="expand-value rich-content">
<rich-text :nodes="item.expandContent.stemHtml" />
</view>
</view>
<view v-if="item.expandContent?.parsedOptions?.length" class="expand-item full">
<text class="expand-label">选项</text>
<view class="expand-value rich-content">
<view
v-for="opt in item.expandContent.parsedOptions"
:key="opt.index"
class="option-item"
>
<text class="option-index">{{ opt.index }}.</text>
<view class="option-html">
<rich-text :nodes="opt.html" />
</view>
</view>
</view>
</view>
<view
v-else-if="item.expandContent?.optionHtmlFallback"
class="expand-item full"
>
<text class="expand-label">选项</text>
<view class="expand-value rich-content">
<rich-text :nodes="item.expandContent.optionHtmlFallback" />
</view>
</view>
<view v-if="item.expandContent?.answerHtml" class="expand-item full">
<text class="expand-label">正确答案</text>
<view class="expand-value answer-text rich-content">
<rich-text :nodes="item.expandContent.answerHtml" />
</view>
</view>
<view v-if="item.expandContent?.explanationHtml" class="expand-item full">
<text class="expand-label">解析</text>
<view class="expand-value rich-content">
<rich-text :nodes="item.expandContent.explanationHtml" />
</view>
</view>
</view>
</view>
</view>
<view v-else class="empty-wrap">
<xy-Empty type="learn" :content="emptyText" />
</view>
<view v-if="loadingMore" class="load-more">加载中...</view>
</view>
<view class="safe-bottom"></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { useParentStore } from '@/state/modules/parent';
import {
TIME_RANGE_OPTIONS,
ensureExpandContent,
fetchWrongQuestionPage,
getTimeRange,
resolveWrongUserId,
type WrongQuestion,
} from './shared';
const parentStore = useParentStore();
const listLoading = ref(false);
const loadingMore = ref(false);
const hasMore = ref(true);
const currentPage = ref(1);
const wrongQuestions = ref<WrongQuestion[]>([]);
const selectedSubject = ref('');
const selectedSubjectId = ref('');
const cachedUserId = ref('');
const timeRange = ref('');
const emptyText = ref('暂无错题');
const timeRangeLabels = TIME_RANGE_OPTIONS.map((item) => item.label);
const timeRangeIndex = computed(() => {
const idx = TIME_RANGE_OPTIONS.findIndex((item) => item.value === timeRange.value);
return idx >= 0 ? idx : 0;
});
const onTimeRangeChange = (e: any) => {
const index = Number(e.detail.value);
timeRange.value = TIME_RANGE_OPTIONS[index]?.value ?? '';
initList();
};
const toggleExpand = (item: WrongQuestion) => {
if (!item.expanded) {
ensureExpandContent(item);
}
item.expanded = !item.expanded;
};
const getUserId = async () => {
if (cachedUserId.value) return cachedUserId.value;
const userId = parentStore.activeChild.id
|| parentStore.activeChildId
|| await resolveWrongUserId();
cachedUserId.value = userId;
return userId;
};
const fetchQuestions = async (append = false) => {
const userId = await getUserId();
if (!userId) {
wrongQuestions.value = [];
hasMore.value = false;
emptyText.value = '未找到孩子信息,请先绑定孩子';
return;
}
const page = append ? currentPage.value : 1;
const { startTime, endTime } = getTimeRange(timeRange.value);
const { rows, totalRows } = await fetchWrongQuestionPage({
userId,
subjectId: selectedSubjectId.value || undefined,
current: page,
startTime,
endTime,
});
if (append) {
const exists = new Set(wrongQuestions.value.map((item) => item.id));
wrongQuestions.value = wrongQuestions.value.concat(
rows.filter((item: WrongQuestion) => !exists.has(item.id)),
);
} else {
wrongQuestions.value = rows;
}
hasMore.value = wrongQuestions.value.length < totalRows;
};
const initList = async () => {
if (listLoading.value) return;
listLoading.value = true;
currentPage.value = 1;
hasMore.value = true;
try {
await fetchQuestions(false);
emptyText.value = wrongQuestions.value.length ? '' : '暂无错题';
} catch (err) {
console.error('[wrong/list] load failed', err);
wrongQuestions.value = [];
emptyText.value = '网络出问题了啦~';
hasMore.value = false;
} finally {
listLoading.value = false;
}
};
const loadMore = async () => {
if (listLoading.value || loadingMore.value || !hasMore.value) return;
loadingMore.value = true;
currentPage.value += 1;
try {
await fetchQuestions(true);
} catch {
currentPage.value -= 1;
} finally {
loadingMore.value = false;
}
};
onLoad((options) => {
const subject = options?.subject ? decodeURIComponent(options.subject) : '';
selectedSubject.value = subject;
selectedSubjectId.value = options?.subjectId ? String(options.subjectId) : '';
cachedUserId.value = options?.userId ? String(options.userId) : '';
const title = subject ? `${subject}错题` : '错题列表';
uni.setNavigationBarTitle({ title });
timeRange.value = '';
initList();
});
</script>
<style lang="scss" scoped>
.wrong-list-page {
min-height: 100vh;
background: #f8fafc;
}
.page-scroll {
height: 100vh;
}
.content {
padding: 24rpx 28rpx 0;
}
.filter-row {
display: flex;
}
.filter-picker {
min-width: 0;
flex: 1;
}
.filter-pill {
height: 70rpx;
padding: 0 16rpx;
border: 2rpx solid #e2e8f0;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #334155;
background: rgba(255, 255, 255, 0.95);
font-size: 22rpx;
font-weight: 700;
}
.filter-pill text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-arrow {
width: 0;
height: 0;
border-left: 7rpx solid transparent;
border-right: 7rpx solid transparent;
border-top: 9rpx solid #0891b2;
flex-shrink: 0;
}
.question-list {
display: flex;
flex-direction: column;
gap: 24rpx;
margin-top: 26rpx;
}
.question-card {
border-radius: 34rpx;
background: #ffffff;
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.question-main {
padding: 28rpx;
}
.question-preview {
color: #0f172a;
font-size: 28rpx;
font-weight: 700;
line-height: 1.65;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.question-preview :deep(image),
.rich-content :deep(image),
.option-html :deep(image) {
max-width: 100%;
height: auto;
}
.rich-content :deep(.formula-text) {
font-family: 'Times New Roman', 'STIXGeneral', Georgia, serif;
font-style: italic;
}
.rich-content :deep(.formula-img) {
max-width: 100%;
vertical-align: middle;
}
.meta-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-top: 22rpx;
}
.meta-item {
padding: 16rpx 12rpx;
border-radius: 18rpx;
background: #f8fafc;
text-align: center;
}
.meta-label {
display: block;
color: #94a3b8;
font-size: 20rpx;
}
.meta-value {
display: block;
margin-top: 6rpx;
color: #334155;
font-size: 22rpx;
font-weight: 700;
}
.error-count {
color: #e64340;
font-size: 28rpx;
font-weight: 900;
}
.card-head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 22rpx;
}
.expand-arrow {
width: 0;
height: 0;
border-left: 10rpx solid transparent;
border-right: 10rpx solid transparent;
border-top: 12rpx solid #94a3b8;
transition: transform 0.25s ease;
}
.expand-arrow.expanded {
transform: rotate(180deg);
}
.expand-section {
padding: 0 28rpx 28rpx;
border-top: 2rpx solid #f1f5f9;
}
.expand-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-top: 20rpx;
}
.expand-item.full {
flex-direction: column;
gap: 10rpx;
}
.expand-label {
flex-shrink: 0;
width: 120rpx;
color: #94a3b8;
font-size: 22rpx;
font-weight: 700;
}
.expand-item.full .expand-label {
width: auto;
}
.expand-value {
flex: 1;
min-width: 0;
color: #334155;
font-size: 24rpx;
line-height: 1.6;
}
.submit-answer {
color: #e64340;
font-weight: 700;
}
.answer-text {
color: #15803d;
font-weight: 700;
}
.option-item {
display: flex;
align-items: flex-start;
gap: 10rpx;
margin-bottom: 10rpx;
}
.option-index {
flex-shrink: 0;
color: #0891b2;
font-weight: 900;
}
.option-html {
flex: 1;
min-width: 0;
}
.empty-wrap,
.loading-wrap {
height: 560rpx;
}
.load-more {
padding: 24rpx 0 12rpx;
text-align: center;
color: #94a3b8;
font-size: 24rpx;
}
.safe-bottom {
height: 48rpx;
}
</style>

250
src/pages/wrong/shared.ts Normal file
View File

@ -0,0 +1,250 @@
import {
getWrongQuestionFilterPage,
getWrongQuestionSummary,
type PageResult,
type QuestionTitle,
type WrongQuestionItem,
type WrongQuestionSummaryResult,
} from '@/api/parent';
import { formatWrongQuestionHtml } from '@/utils/questionHtml';
import { useParentStore } from '@/state/modules/parent';
import { useUserStore } from '@/state/modules/user';
export type { QuestionTitle, WrongQuestionItem };
export interface WrongOption {
index: string;
html: string;
}
export interface WrongQuestion extends WrongQuestionItem {
expanded: boolean;
previewHtml: string;
firstErrorTimeText: string;
lastErrorTimeText: string;
expandContent?: WrongExpandContent;
}
export interface WrongExpandContent {
parsedOptions: WrongOption[] | null;
stemHtml: string;
optionHtmlFallback: string;
answerHtml: string;
explanationHtml: string;
}
export const SUBJECT_TONES = ['blue', 'green', 'orange', 'rose', 'violet'];
export const TIME_RANGE_OPTIONS = [
{ value: '', label: '全部时间' },
{ value: 'today', label: '今天' },
{ value: '7days', label: '近7天' },
{ value: '30days', label: '近30天' },
];
export const normalizeHtml = (html?: string, options?: { stripImages?: boolean }) =>
formatWrongQuestionHtml(html, options);
export const getPreviewHtml = (item: WrongQuestionItem): string => {
if (!item.questionTitle) return '--';
const html = item.questionTitle.stemHtml || item.questionTitle.stem || '';
if (!html) return '--';
return formatWrongQuestionHtml(html, { stripImages: true }) || '--';
};
export const parseOptions = (optionHtml?: string): WrongOption[] | null => {
if (!optionHtml) return null;
try {
const parsed = typeof optionHtml === 'string' ? JSON.parse(optionHtml) : optionHtml;
if (Array.isArray(parsed)) {
return parsed.map((item: any) => ({
index: item.index || '',
html: formatWrongQuestionHtml(item.html || ''),
}));
}
} catch {
/* not JSON, fallback to HTML string */
}
return null;
};
export const getTimeRange = (rangeKey: string): { startTime?: string; endTime?: string } => {
if (!rangeKey) return {};
const now = new Date();
const endTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 23:59:59`;
let start: Date;
if (rangeKey === 'today') {
start = now;
} else if (rangeKey === '7days') {
start = new Date(now.getTime() - 6 * 86400000);
} else {
start = new Date(now.getTime() - 29 * 86400000);
}
const startTime = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')} 00:00:00`;
return { startTime, endTime };
};
export const formatWrongTime = (time?: string) => {
if (!time) return '--';
return time.replace('T', ' ').slice(0, 16);
};
export const getPreviewText = (item: WrongQuestionItem): string => {
const html = item.questionTitle?.stemHtml || item.questionTitle?.stem || '';
if (!html) return '--';
return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 120) || '--';
};
export const createWrongQuestionShell = (item: WrongQuestionItem): WrongQuestion => ({
...item,
expanded: false,
previewHtml: getPreviewText(item),
firstErrorTimeText: formatWrongTime(item.firstErrorTime),
lastErrorTimeText: formatWrongTime(item.lastErrorTime),
});
export const hydrateWrongQuestionPreview = (item: WrongQuestion) => {
try {
item.previewHtml = getPreviewHtml(item);
} catch (err) {
console.error('[wrong] preview hydrate failed', err);
item.previewHtml = getPreviewText(item);
}
};
export const mapWrongQuestionItem = (item: WrongQuestionItem): WrongQuestion => ({
...createWrongQuestionShell(item),
previewHtml: getPreviewHtml(item),
});
export const ensureExpandContent = (item: WrongQuestion) => {
if (item.expandContent || !item.questionTitle) return;
const qt = item.questionTitle;
try {
item.expandContent = {
parsedOptions: parseOptions(qt.optionHtml),
stemHtml: normalizeHtml(qt.stemHtml || qt.stem),
optionHtmlFallback: normalizeHtml(qt.optionHtml),
answerHtml: normalizeHtml(qt.answerHtml || qt.answer),
explanationHtml: normalizeHtml(qt.explanation),
};
} catch (err) {
console.error('[wrong] expand content failed', err);
item.expandContent = {
parsedOptions: null,
stemHtml: getPreviewText(item),
optionHtmlFallback: '',
answerHtml: String(qt.answer || qt.answerHtml || ''),
explanationHtml: String(qt.explanation || ''),
};
}
};
export const normalizeWrongQuestionPage = (
data: WrongQuestionItem[] | PageResult<WrongQuestionItem> | null | undefined,
page = 1,
) => {
if (!data) {
return { rows: [] as WrongQuestionItem[], totalRows: 0, current: page };
}
if (Array.isArray(data)) {
return { rows: data, totalRows: data.length, current: page };
}
const rows = data.rows || data.records || [];
return {
rows,
totalRows: data.totalRows ?? rows.length,
current: data.current ?? page,
};
};
export const WRONG_LIST_PAGE_SIZE = 10;
export const resolveWrongUserId = async () => {
const userStore = useUserStore();
const parentStore = useParentStore();
if (parentStore.activeChild.id) {
return parentStore.activeChild.id;
}
if (parentStore.activeChildId) {
return parentStore.activeChildId;
}
try {
if (!userStore.userInfo.id) {
await userStore.fetchParentUserInfo();
}
if (userStore.userInfo.id) {
await parentStore.fetchBindChildren(userStore.userInfo.id);
}
} catch {
/* ignore */
}
if (parentStore.activeChild.id) {
return parentStore.activeChild.id;
}
try {
if (!userStore.userInfo.id) {
await userStore.fetchUserInfo();
}
} catch {
/* ignore */
}
return userStore.userInfo.id || '';
};
export const fetchWrongSummary = async (): Promise<WrongQuestionSummaryResult | null> => {
const userId = await resolveWrongUserId();
if (!userId) return null;
try {
const res = await getWrongQuestionSummary({ userId });
return res.data || null;
} catch (err) {
console.error('[wrong] summary err', err);
return null;
}
};
export const fetchWrongQuestionPage = async (params: {
userId?: string | number;
subjectId?: string | number;
current?: number;
size?: number;
startTime?: string;
endTime?: string;
}) => {
const current = params.current ?? 1;
const res = await getWrongQuestionFilterPage({
userId: params.userId,
subjectId: params.subjectId,
current,
size: params.size ?? WRONG_LIST_PAGE_SIZE,
startTime: params.startTime,
endTime: params.endTime,
});
const { rows, totalRows } = normalizeWrongQuestionPage(res.data, current);
return {
rows: rows.map((item: WrongQuestionItem) => mapWrongQuestionItem(item)),
totalRows,
current,
};
};
export const buildSubjectStatRow = (
name: string,
stats: { total: number; newCount: number; reviewCount: number; masteredCount: number },
index: number,
) => ({
name,
shortName: name === '全部' ? '全' : name.slice(0, 1),
tone: SUBJECT_TONES[index % SUBJECT_TONES.length],
total: stats.total,
newCount: stats.newCount,
reviewCount: stats.reviewCount,
masteredCount: stats.masteredCount,
});

View File

@ -7,7 +7,7 @@ import { getCache } from '@/utils/cache';
import { getStoredRole } from '@/utils/roleHome'; import { getStoredRole } from '@/utils/roleHome';
// 免登录白名单(路由 name // 免登录白名单(路由 name
const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile']; const whiteList = ['home', 'login'];
export function userRouternext(router: Router) { export function userRouternext(router: Router) {
router.beforeEach((to, _from, next) => { router.beforeEach((to, _from, next) => {

View File

@ -1,5 +1,5 @@
/* ========================================== /* ==========================================
小乐 AI · 全局样式 灵犀AI · 全局样式
设计风格: Claymorphism (软立体 / 童趣) 设计风格: Claymorphism (软立体 / 童趣)
主色: Indigo + Green 主色: Indigo + Green
========================================== */ ========================================== */

View File

@ -1,6 +1,157 @@
/** 题目 HTML 处理(与学生端 doWork/Question 保持一致) */ /** 题目 HTML 处理(与学生端 doWork/Question 保持一致) */
const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?'; const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?';
const MAX_FORMAT_CACHE_SIZE = 200;
const formatCache = new Map<string, string>();
const escapeHtml = (text: string) =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const extractTagAttr = (tag: string, name: string): string => {
const re = new RegExp(`\\b${name}\\s*=\\s*["']((?:\\\\.|[^"'])*)["']`, 'i');
const match = tag.match(re);
if (!match) return '';
return match[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\'/g, "'");
};
const stripHtmlTags = (value: string) => String(value || '').replace(/<[^>]+>/g, '');
const decodeHtmlEntities = (value: string) =>
String(value || '')
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&amp;/gi, '&');
const resolveFormulaSource = (openTag: string, innerHtml = ''): string => {
const byAttr = extractTagAttr(openTag, 'data-value') || extractTagAttr(openTag, 'data-latex');
if (String(byAttr || '').trim()) return byAttr;
const innerText = decodeHtmlEntities(stripHtmlTags(innerHtml)).trim();
return innerText;
};
const trimLatexWrapper = (value: string) => String(value || '').trim().replace(/^\$+/, '').replace(/\$+$/, '');
const renderMathTag = (openTag: string, innerHtml: string): string => {
const latexRaw = decodeHtmlEntities(extractTagAttr(openTag, 'latex'));
const latex = trimLatexWrapper(latexRaw);
if (latex) {
return renderFormulaContent(latex);
}
const svgUrlRaw = extractTagAttr(openTag, 'math-svg') || extractTagAttr(openTag, 'src');
const svgUrl = String(svgUrlRaw || '').trim().replace(/http:\/\//gi, 'https://');
if (svgUrl) {
return `<img class="formula-img" src="${svgUrl}" style="height:1.4em;vertical-align:middle;" />`;
}
const text = decodeHtmlEntities(stripHtmlTags(innerHtml)).trim();
return renderFormulaContent(text);
};
const processMathTags = (html: string): string => {
if (!html || !/<math\b/i.test(html)) return html;
return html.replace(/<math\b[^>]*>[\s\S]*?<\/math>/gi, (tag) => {
const openTag = tag.match(/^<math\b[^>]*>/i)?.[0] || '';
const innerHtml = tag.replace(/^<math\b[^>]*>/i, '').replace(/<\/math>\s*$/i, '');
return renderMathTag(openTag, innerHtml);
});
};
const findMatchingSpanClose = (html: string, fromIndex: number): number => {
let depth = 1;
const tagRe = /<(\/?)span\b[^>]*>/gi;
tagRe.lastIndex = fromIndex;
let match;
while ((match = tagRe.exec(html)) !== null) {
depth += match[1] === '/' ? -1 : 1;
if (depth === 0) {
return match.index + match[0].length;
}
}
return -1;
};
const isPlainNumericFormula = (formula: string) =>
/^[\-\u2212\uFF0D0-9.+\s(),]+$/.test(formula.trim());
const renderFormulaContent = (formula: string): string => {
const trimmed = formula.trim();
if (!trimmed) return '';
if (isPlainNumericFormula(trimmed)) {
return escapeHtml(trimmed);
}
if (isSimpleFormula(trimmed)) {
return simpleFormulaToHtml(trimmed);
}
const imageUrl = latexToImageUrl(trimmed);
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" referrerpolicy="no-referrer" loading="lazy" onerror="this.onerror=null;this.outerHTML='<span class=&quot;formula-fallback&quot;>${escapeHtml(trimmed)}</span>';" />`;
};
const replaceFormulaSpansInHtml = (html: string): string => {
if (!html || !html.includes('data-w-e-type')) return html;
const openRe = /<span\b[^>]*\bdata-w-e-type\s*=\s*["']formula["'][^>]*>/gi;
let result = '';
let cursor = 0;
let match;
let guard = 0;
while ((match = openRe.exec(html)) !== null) {
if (guard++ > 500) break;
result += html.slice(cursor, match.index);
const openTag = match[0];
const contentStart = match.index + openTag.length;
const closeEnd = findMatchingSpanClose(html, contentStart);
if (closeEnd === -1) {
const formula = resolveFormulaSource(openTag);
result += renderFormulaContent(formula);
cursor = match.index + openTag.length;
openRe.lastIndex = cursor;
continue;
}
const innerHtml = html.slice(contentStart, closeEnd - '</span>'.length);
const formula = resolveFormulaSource(openTag, innerHtml);
result += renderFormulaContent(formula);
cursor = closeEnd;
openRe.lastIndex = closeEnd;
}
result += html.slice(cursor);
return result;
};
const cleanupFormulaSpanRemnants = (html: string): string => {
if (!html) return '';
let result = html;
let prev = '';
let guard = 0;
while (result !== prev && guard < 8) {
prev = result;
result = replaceFormulaSpansInHtml(result);
guard += 1;
}
return result
.replace(/<span\b[^>]*\bdata-w-e-type\s*=\s*["']formula["'][^>]*>[\s\S]*?<\/span>/gi, (tag) => {
const openTag = tag.match(/^<span\b[^>]*>/i)?.[0] || '';
const innerHtml = tag.replace(/^<span\b[^>]*>/i, '').replace(/<\/span>\s*$/i, '');
const formula = resolveFormulaSource(openTag, innerHtml);
return renderFormulaContent(formula);
})
.replace(/\sdata-w-e-type\s*=\s*["'][^"']*["']/gi, '')
.replace(/\sdata-value\s*=\s*["'][^"']*["']/gi, '')
.replace(/\sdata-size\s*=\s*["'][^"']*["']/gi, '')
.replace(/\scontenteditable\s*=\s*["'][^"']*["']/gi, '')
.replace(/<span\s*><\/span>/gi, '');
};
export const fixMathSymbolsInHtml = (html: string): string => { export const fixMathSymbolsInHtml = (html: string): string => {
if (html == null || typeof html !== 'string') return ''; if (html == null || typeof html !== 'string') return '';
@ -131,47 +282,68 @@ const simpleFormulaToHtml = (formula: string): string => {
return `<span class="formula-text" style="font-style:italic;">${text}</span>`; return `<span class="formula-text" style="font-style:italic;">${text}</span>`;
}; };
const processDollarLatex = (text: string): string => {
if (!text || !text.includes('$')) return text;
return text.replace(/\$(?!\$)([\s\S]+?)\$(?!\$)/g, (_match, formula: string) => {
const trimmed = String(formula || '').trim();
if (!trimmed) return _match;
if (isSimpleFormula(trimmed)) {
return simpleFormulaToHtml(trimmed);
}
const imageUrl = latexToImageUrl(trimmed);
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" referrerpolicy="no-referrer" loading="lazy" onerror="this.onerror=null;this.outerHTML='<span class=&quot;formula-fallback&quot;>${escapeHtml(trimmed)}</span>';" />`;
});
};
export const processFormula = (html: string): string => { export const processFormula = (html: string): string => {
if (html == null || typeof html !== 'string') return ''; if (html == null || typeof html !== 'string') return '';
if (!html) return ''; if (!html) return '';
return cleanupFormulaSpanRemnants(html);
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);
}
const imageUrl = latexToImageUrl(formula);
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" />`;
});
}; };
export const isHtmlContent = (value: string) => /<[a-z][\s\S]*>/i.test(value); export const isHtmlContent = (value: string) => /<[a-z][\s\S]*>/i.test(value);
export const formatQuestionHtml = (title?: string, options?: { stripImages?: boolean }) => { export const formatQuestionHtml = (title?: string, options?: { stripImages?: boolean }) => {
if (!title) return ''; if (title == null || title === '') return '';
let t = String(title); const cacheKey = `${options?.stripImages ? '1' : '0'}::${String(title)}`;
if (options?.stripImages) { const cached = formatCache.get(cacheKey);
t = t.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, ''); if (cached != null) return cached;
try {
let t = String(title);
if (options?.stripImages) {
t = t.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
}
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
t = t.replace(/http:\/\//gi, 'https://');
t = processMathTags(t);
t = cleanupFormulaSpanRemnants(t);
t = processDollarLatex(t);
t = fixMathSymbolsInHtml(t);
if (formatCache.size >= MAX_FORMAT_CACHE_SIZE) {
const firstKey = formatCache.keys().next().value;
if (firstKey) formatCache.delete(firstKey);
}
formatCache.set(cacheKey, t);
return t;
} catch (err) {
console.error('[questionHtml] format failed', err);
const fallback = String(title)
.replace(/http:\/\//gi, 'https://')
.replace(/<(?![a-zA-Z/!])[^>]*>/g, '')
.replace(/<(?![a-zA-Z/!])/g, '&lt;');
if (formatCache.size >= MAX_FORMAT_CACHE_SIZE) {
const firstKey = formatCache.keys().next().value;
if (firstKey) formatCache.delete(firstKey);
}
formatCache.set(cacheKey, fallback);
return fallback;
} }
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
t = t.replace(/http:\/\//gi, 'https://');
t = fixMathSymbolsInHtml(t);
t = processFormula(t);
return t;
}; };
/** 错题详情:与 Web 一致优先保留原始 HTML仅做 rich-text 兼容转换 */
export const formatWrongQuestionHtml = (title?: string, options?: { stripImages?: boolean }) =>
formatQuestionHtml(title, options);
export const getQuestionStemSource = (item: Record<string, unknown>) => { export const getQuestionStemSource = (item: Record<string, unknown>) => {
const pid = item.pidTitle ? String(item.pidTitle) : ''; const pid = item.pidTitle ? String(item.pidTitle) : '';
const stem = String(item.stemHtml || item.titleMu || item.title || item.stem || ''); const stem = String(item.stemHtml || item.titleMu || item.title || item.stem || '');

View File

@ -2,7 +2,7 @@ import Request from 'luch-request';
import { getCache, removeCache } from '@/utils/cache'; import { getCache, removeCache } from '@/utils/cache';
/** /**
* AI HTTP luch-request * AI HTTP luch-request
* *
* - Bearer Token * - Bearer Token
* - x-tenant-id * - x-tenant-id