diff --git a/src/api/parent.ts b/src/api/parent.ts index 53ae4c7..196adb4 100644 --- a/src/api/parent.ts +++ b/src/api/parent.ts @@ -210,6 +210,91 @@ export const getWrongQuestionSummary = (params: { userId: string | number }) => 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 = { + 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>({ + url: '/xkw/wrong/question/filter/page', + method: 'GET', + params: query, + }); +}; + +/** 学科网 - 错题筛选学科列表 */ +export const getWrongQuestionFilterSubjects = (params?: { userId?: string | number }) => + request({ + 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 }) => request({ diff --git a/src/components/ImageCropper/index.vue b/src/components/ImageCropper/index.vue index ccd8a3e..bcc2662 100644 --- a/src/components/ImageCropper/index.vue +++ b/src/components/ImageCropper/index.vue @@ -117,6 +117,52 @@ const getTouchPoint = (e: any) => { 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 = () => { if (!props.visible) return; if (!props.imageSrc) return; @@ -133,21 +179,8 @@ const tryInitAndLoad = () => { }; onMounted(() => { - // 获取设备信息,用于计算canvas大小和安全区 - uni.getSystemInfo({ - 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(); - }, - }); + // 横屏真机下 getSystemInfo 可能拿到旋转前尺寸,因此优先同步可视区域真实尺寸 + syncViewportSize(false); }); watch( @@ -157,6 +190,7 @@ watch( imgInfo.value = null; return; } + syncViewportSize(true); tryInitAndLoad(); }, { immediate: true } diff --git a/src/pages.json b/src/pages.json index a59bb07..991c60b 100644 --- a/src/pages.json +++ b/src/pages.json @@ -14,7 +14,7 @@ "islogin": false }, "style": { - "navigationBarTitleText": "学小乐AI", + "navigationBarTitleText": "灵犀学AI", "enablePullDownRefresh": false, "mp-weixin": { "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", "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", "name": "doWork", @@ -276,7 +315,7 @@ ], "globalStyle": { "navigationBarTextStyle": "black", - "navigationBarTitleText": "学小乐AI", + "navigationBarTitleText": "灵犀学AI", "navigationBarBackgroundColor": "#FFFFFF", "backgroundColor": "#F5F7FF", "pageOrientation": "portrait" diff --git a/src/pages/agreement/privacy/index.vue b/src/pages/agreement/privacy/index.vue new file mode 100644 index 0000000..49c411b --- /dev/null +++ b/src/pages/agreement/privacy/index.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/pages/agreement/user/index.vue b/src/pages/agreement/user/index.vue new file mode 100644 index 0000000..df9a94a --- /dev/null +++ b/src/pages/agreement/user/index.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/src/pages/doWork/components/Question.vue b/src/pages/doWork/components/Question.vue index e8baea2..5ff683e 100644 --- a/src/pages/doWork/components/Question.vue +++ b/src/pages/doWork/components/Question.vue @@ -367,6 +367,7 @@ import { ref, computed, watch, onUnmounted } from 'vue'; import { uploadFile } from '@/api/upload'; import ImageCropper from '@/components/ImageCropper/index.vue'; +import { formatQuestionHtml } from '@/utils/questionHtml'; const props = defineProps<{ data?: any; @@ -377,7 +378,7 @@ const props = defineProps<{ subjectId?: string; }>(); -const emit = defineEmits(['submit']); +const emit = defineEmits(['submit', 'cropper-visible-change']); /* ============== 音频 ============== */ const isAudioPlay = ref(false); @@ -412,21 +413,18 @@ const previewTitleImage = (index: number) => { }); }; -/* ============== 公式失败兜底集合 ============== */ -const failedFormulaUrls = ref>(new Set()); - /* ============== 题目过滤 ============== */ -const filterTitleWithoutImages = (title?: string, cidx?: number | string) => { +const renderQuestionHtml = (title?: string, cidx?: number | string, options?: { stripImages?: boolean }) => { if (!title) return ''; - let t = title.replace(/]*formula-img)[^>]*\/?>/gi, ''); - t = t.replace(/<\/u>/gi, ''); - t = t.replace(/http:\/\//gi, 'https://'); - t = fixMathSymbolsInHtml(t); - t = processFormula(t, failedFormulaUrls.value); + let html = formatQuestionHtml(title, options); 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) => { @@ -466,283 +464,6 @@ watch( { 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, '<'); - t = t.replace(/ > /g, ' > '); - t = t.replace(/AUB/g, 'A∪B'); - 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 = { - ',': ',', '。': '.', ':': ':', ';': ';', '!': '!', '?': '?', - '(': '(', ')': ')', '【': '[', '】': ']', '{': '{', '}': '}', - '《': '<', '》': '>', - "\u201c": '"', "\u201d": '"', "\u2018": "'", "\u2019": "'", - '+': '+', '-': '-', '\u2212': '-', '×': '*', '÷': '/', - '=': '=', '<': '<', '>': '>', '%': '%', '&': '&', - '*': '*', '@': '@', '#': '#', '$': '$', '^': '^', - '~': '~', '|': '|', '\': '\\', '/': '/', ' ': ' ', - }; - for (const [cn, en] of Object.entries(punctuationMap)) { - formula = formula.split(cn).join(en); - } - - const mathSymbolMap: Record = { - '∈': ' \\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 = { - '\\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 = { - '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', - '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', - '+': '⁺', '-': '⁻', 'n': 'ⁿ', - }; - const subscriptMap: Record = { - '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 `${text}`; -}; - -const processFormula = (html: string, failedUrls?: Set): string => { - if (html == null || typeof html !== 'string') return ''; - if (!html) return ''; - - const formulaRegex = /"']+|"[^"]*"|'[^']*')*)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 `[${display}]`; - } - return ``; - }); -}; - -/* ============== 公式预加载 + 重试 ============== */ -const extractFormulaImageUrls = (html: string): string[] => { - if (html == null || typeof html !== 'string') return []; - const formulaRegex = /"']+|"[^"]*"|'[^']*')*)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 => { - 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(); - 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 data = props.data; @@ -772,9 +493,7 @@ const optionList = computed(() => { const v = item?.html ?? item?.value ?? item?.optionValue ?? item?.content ?? ''; value = v != null && v !== '' ? String(v) : ''; } - value = fixMathSymbolsInHtml(value); - value = processFormula(value, failedFormulaUrls.value); - value = typeof value === 'string' ? value.replace(/http:\/\//gi, 'https://') : ''; + value = formatQuestionHtml(value); return { key: typeof item === 'string' @@ -869,9 +588,7 @@ const yueDuList = computed(() => { const subOpts = rawSubOpts.map((opt: any, oIdx: number) => { let v = typeof opt === 'string' ? opt : (opt?.html ?? opt?.value ?? opt?.optionValue ?? opt?.content ?? ''); v = v != null ? String(v) : ''; - v = fixMathSymbolsInHtml(v); - v = processFormula(v, failedFormulaUrls.value); - v = v.replace(/http:\/\//gi, 'https://'); + v = formatQuestionHtml(v); return { label: typeof opt === 'string' @@ -883,9 +600,7 @@ const yueDuList = computed(() => { let stem = ''; if (item.stem) { stem = String(item.stem); - stem = fixMathSymbolsInHtml(stem); - stem = processFormula(stem, failedFormulaUrls.value); - stem = stem.replace(/http:\/\//gi, 'https://'); + stem = formatQuestionHtml(stem); } let horizontal = false; if (subOpts.length > 0) { @@ -922,14 +637,7 @@ const resultClass = computed(() => { }); const filterTitle = (title?: string, cidx?: number | string) => { - if (!title) return ''; - 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; + return renderQuestionHtml(title, cidx); }; /* ============== 单选 ============== */ @@ -1048,6 +756,10 @@ const picList = computed(() => { const showCropper = ref(false); const cropperImageSrc = ref(''); +watch(showCropper, (visible) => { + emit('cropper-visible-change', visible); +}); + const chooseImage = () => { const remain = 3 - picList.value.length; if (remain <= 0) { diff --git a/src/pages/doWork/index.vue b/src/pages/doWork/index.vue index a226ade..700e24d 100644 --- a/src/pages/doWork/index.vue +++ b/src/pages/doWork/index.vue @@ -9,11 +9,11 @@ - + {{ title }} @@ -48,6 +48,7 @@ :reportFlag="reportFlag" :subjectId="paperInfo.subjectId" @submit="submitQuestion" + @cropper-visible-change="onCropperVisibleChange" /> @@ -119,6 +120,7 @@ import Question from './components/Question.vue'; const pageLoading = ref(true); const submitLoading = ref(false); const menuButtonRightRpx = ref(0); +const isCropperActive = ref(false); const paperList = ref([]); const paperInfo = ref({}); @@ -322,6 +324,10 @@ const submitQuestion = (answer: any) => { } }; +const onCropperVisibleChange = (visible: boolean) => { + isCropperActive.value = !!visible; +}; + const submitTrain = async () => { clearTimer(); submitLoading.value = true; @@ -446,6 +452,7 @@ onMounted(() => { onUnmounted(() => { clearTimer(); + isCropperActive.value = false; paperInfo.value = {}; paperList.value = []; activeIdx.value = 0; @@ -495,6 +502,7 @@ onUnmounted(() => { z-index: 10; display: flex; align-items: center; + justify-content: space-between; padding: 3rpx 12rpx 2rpx; gap: 8rpx; } diff --git a/src/pages/homework/history/index.vue b/src/pages/homework/history/index.vue index 947b28c..06fee30 100644 --- a/src/pages/homework/history/index.vue +++ b/src/pages/homework/history/index.vue @@ -301,7 +301,7 @@ onShow(() => { diff --git a/src/pages/wrong/list.vue b/src/pages/wrong/list.vue new file mode 100644 index 0000000..bee1539 --- /dev/null +++ b/src/pages/wrong/list.vue @@ -0,0 +1,482 @@ + + + + + diff --git a/src/pages/wrong/shared.ts b/src/pages/wrong/shared.ts new file mode 100644 index 0000000..3c40d09 --- /dev/null +++ b/src/pages/wrong/shared.ts @@ -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 | 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 => { + 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, +}); diff --git a/src/router/Routeinterception.ts b/src/router/Routeinterception.ts index 00aba05..d7e2486 100644 --- a/src/router/Routeinterception.ts +++ b/src/router/Routeinterception.ts @@ -7,7 +7,7 @@ import { getCache } from '@/utils/cache'; import { getStoredRole } from '@/utils/roleHome'; // 免登录白名单(路由 name) -const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile']; +const whiteList = ['home', 'login']; export function userRouternext(router: Router) { router.beforeEach((to, _from, next) => { diff --git a/src/style/global.scss b/src/style/global.scss index 29240e6..f3d4507 100644 --- a/src/style/global.scss +++ b/src/style/global.scss @@ -1,5 +1,5 @@ /* ========================================== - 学小乐 AI · 全局样式 + 灵犀学 AI · 全局样式 设计风格: Claymorphism (软立体 / 童趣) 主色: Indigo + Green ========================================== */ diff --git a/src/utils/questionHtml.ts b/src/utils/questionHtml.ts index 7b0908c..77383cf 100644 --- a/src/utils/questionHtml.ts +++ b/src/utils/questionHtml.ts @@ -1,6 +1,157 @@ /** 题目 HTML 处理(与学生端 doWork/Question 保持一致) */ const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?'; +const MAX_FORMAT_CACHE_SIZE = 200; +const formatCache = new Map(); + +const escapeHtml = (text: string) => + text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +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(/ /gi, ' ') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&/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 ``; + } + + const text = decodeHtmlEntities(stripHtmlTags(innerHtml)).trim(); + return renderFormulaContent(text); +}; + +const processMathTags = (html: string): string => { + if (!html || !/]*>[\s\S]*?<\/math>/gi, (tag) => { + const openTag = tag.match(/^]*>/i)?.[0] || ''; + const innerHtml = tag.replace(/^]*>/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 ``; +}; + +const replaceFormulaSpansInHtml = (html: string): string => { + if (!html || !html.includes('data-w-e-type')) return html; + + const openRe = /]*\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 - ''.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(/]*\bdata-w-e-type\s*=\s*["']formula["'][^>]*>[\s\S]*?<\/span>/gi, (tag) => { + const openTag = tag.match(/^]*>/i)?.[0] || ''; + const innerHtml = tag.replace(/^]*>/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>/gi, ''); +}; export const fixMathSymbolsInHtml = (html: string): string => { if (html == null || typeof html !== 'string') return ''; @@ -131,47 +282,68 @@ const simpleFormulaToHtml = (formula: string): string => { return `${text}`; }; +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 ``; + }); +}; + export const processFormula = (html: string): string => { if (html == null || typeof html !== 'string') return ''; if (!html) return ''; - - const formulaRegex = /"']+|"[^"]*"|'[^']*')*)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 ``; - }); + return cleanupFormulaSpanRemnants(html); }; export const isHtmlContent = (value: string) => /<[a-z][\s\S]*>/i.test(value); export const formatQuestionHtml = (title?: string, options?: { stripImages?: boolean }) => { - if (!title) return ''; - let t = String(title); - if (options?.stripImages) { - t = t.replace(/]*formula-img)[^>]*\/?>/gi, ''); + if (title == null || title === '') return ''; + const cacheKey = `${options?.stripImages ? '1' : '0'}::${String(title)}`; + const cached = formatCache.get(cacheKey); + if (cached != null) return cached; + try { + let t = String(title); + if (options?.stripImages) { + t = t.replace(/]*formula-img)[^>]*\/?>/gi, ''); + } + t = t.replace(/<\/u>/gi, ''); + 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, '<'); + 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>/gi, ''); - 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) => { const pid = item.pidTitle ? String(item.pidTitle) : ''; const stem = String(item.stemHtml || item.titleMu || item.title || item.stem || ''); diff --git a/src/utils/request.ts b/src/utils/request.ts index 4ea6cee..63c6fba 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -2,7 +2,7 @@ import Request from 'luch-request'; import { getCache, removeCache } from '@/utils/cache'; /** - * 学小乐 AI 通用 HTTP 客户端(基于 luch-request) + * 灵犀学 AI 通用 HTTP 客户端(基于 luch-request) * * - 自动注入 Bearer Token * - 自动注入租户头 x-tenant-id