diff --git a/src/App.vue b/src/App.vue index facc5dc..5a4eeac 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,8 +1,9 @@ diff --git a/src/pages/parent/components/ManualBindChild.vue b/src/pages/parent/components/ManualBindChild.vue new file mode 100644 index 0000000..1d3606f --- /dev/null +++ b/src/pages/parent/components/ManualBindChild.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/src/pages/parent/components/ParentFilterRow.vue b/src/pages/parent/components/ParentFilterRow.vue new file mode 100644 index 0000000..e91bb0b --- /dev/null +++ b/src/pages/parent/components/ParentFilterRow.vue @@ -0,0 +1,85 @@ + + + + + + diff --git a/src/pages/parent/components/ParentPage.vue b/src/pages/parent/components/ParentPage.vue index 6c697da..b2ccc2a 100644 --- a/src/pages/parent/components/ParentPage.vue +++ b/src/pages/parent/components/ParentPage.vue @@ -2,7 +2,15 @@ - + @@ -19,6 +27,10 @@ diff --git a/src/pages/parent/utils/data.ts b/src/pages/parent/utils/data.ts index 24a83b9..cfff9bd 100644 --- a/src/pages/parent/utils/data.ts +++ b/src/pages/parent/utils/data.ts @@ -1,3 +1,5 @@ +import { SUBJECT_FILTER_OPTIONS } from '@/utils/subject'; + export interface ParentChild { id: string; name: string; @@ -53,14 +55,29 @@ export interface ReportDifficultyRow { rate: string; } +/** 答题卡列表用的轻量字段(不含 HTML) */ +export type ReportQuestionSummary = Pick< + ReportQuestion, + 'no' | 'type' | 'score' | 'status' | 'classRate' | 'schoolRate' | 'knowledge' +>; + export interface ReportQuestion { no: number; type: string; score: string; - status: 'correct' | 'wrong'; - stem: string; - correctAnswer: string; + status: 'correct' | 'wrong' | 'unanswered'; + /** 供 rich-text 渲染的题干 HTML */ + stemHtml: string; + /** 题干中的图片(与学生端一致,从 HTML 中拆出单独展示) */ + stemImages: string[]; + /** 供 rich-text 渲染的正确答案 HTML */ + correctAnswerHtml: string; + /** 纯文本学员答案(选择题等) */ studentAnswer: string; + /** 供 rich-text 渲染的学员答案 HTML(富文本作答) */ + studentAnswerHtml: string; + /** 学员上传的图片答案 */ + studentAnswerImages: string[]; classRate: string; schoolRate: string; knowledge: string; @@ -271,7 +288,7 @@ export const reportDetails: Record = { }, }; -export const subjectOptions = ['全部学科', '数学', '语文', '英语', '多科']; +export const subjectOptions = SUBJECT_FILTER_OPTIONS.map((item) => item.label); export const gradeOptions = ['全部年级', '初一', '初二', '初三']; export const categoryOptions = ['全部类型', '周作业', '月考']; diff --git a/src/pages/parent/utils/paperDetail.ts b/src/pages/parent/utils/paperDetail.ts new file mode 100644 index 0000000..ff40591 --- /dev/null +++ b/src/pages/parent/utils/paperDetail.ts @@ -0,0 +1,169 @@ +import { + extractAnswerImages, + extractTitleImages, + formatQuestionHtml, + getQuestionAnswerSource, + getQuestionStemSource, + isHtmlContent, +} from '@/utils/questionHtml'; +import type { ReportQuestion, ReportQuestionSummary } from './data'; + +export type QuestionStatus = 'correct' | 'wrong' | 'unanswered'; + +export const normalizeAnswer = (value: unknown) => { + if (Array.isArray(value)) return value.join('、'); + if (value == null) return ''; + return String(value) + .replace(/^\[|\]$/g, '') + .replace(/"/g, '') + .replace(/,/g, '、'); +}; + +export const formatRateText = (value: unknown) => { + if (value == null || value === '') return '--'; + const num = Number(value); + if (!Number.isNaN(num)) { + const rate = num <= 1 ? num * 100 : num; + return `${Math.round(rate * 10) / 10}%`; + } + const text = String(value); + return text.includes('%') ? text : `${text}%`; +}; + +export const getQuestionStatus = (item: Record): QuestionStatus => { + const correct = + item.correctResult === 1 || + item.correctResult === true || + item.markFlag === 1 || + item.markFlag === true; + if (correct) return 'correct'; + if (!item.submitAnswer && !item.submitAnswerPic) return 'unanswered'; + return 'wrong'; +}; + +const stripImageUrlsFromText = (text: string) => { + let result = text; + const links = text.match(/https?:\/\/[^\s,]+/gi) || []; + links.forEach((link) => { + result = result.replace(link, ''); + }); + return result.replace(/[,,、\s]+$/g, '').replace(/^[,,、\s]+/g, '').trim(); +}; + +const buildStudentAnswer = (item: Record) => { + const studentAnswerImages = extractAnswerImages(item); + let textAnswer = normalizeAnswer(item.submitAnswer); + + if (textAnswer) { + const onlyUrls = stripImageUrlsFromText(textAnswer) === '' && studentAnswerImages.length > 0; + if (onlyUrls) { + textAnswer = ''; + } else { + textAnswer = stripImageUrlsFromText(textAnswer); + } + } + + if (!textAnswer && !studentAnswerImages.length) { + return { studentAnswer: '未作答', studentAnswerHtml: '', studentAnswerImages: [] }; + } + + if (textAnswer && isHtmlContent(textAnswer)) { + return { + studentAnswer: '', + studentAnswerHtml: formatQuestionHtml(textAnswer), + studentAnswerImages, + }; + } + + return { + studentAnswer: textAnswer, + studentAnswerHtml: '', + studentAnswerImages, + }; +}; + +const getQuestionFullScore = (item: Record) => Number(item.score ?? item.titleScore ?? 0); + +const getQuestionActualScore = (item: Record) => + Number(item.actualScore ?? item.markScore ?? item.answerScore ?? item.gotScore ?? 0); + +const formatQuestionScoreText = (item: Record) => { + const fullScore = getQuestionFullScore(item); + const actualScore = getQuestionActualScore(item); + return `${actualScore}/${fullScore || actualScore || 0}`; +}; + +export const mapPaperQuestionSummary = (item: Record, index: number): ReportQuestionSummary => { + return { + no: Number(item.titleSort ?? item.sort ?? index + 1), + type: String(item.titleChannelTypeName || item.chanelTypeName || '综合题'), + score: formatQuestionScoreText(item), + status: getQuestionStatus(item), + classRate: formatRateText( + item.classScoreRate ?? item.classRate ?? item.classAvgScoreRate ?? item.classGotScoreRate, + ), + schoolRate: formatRateText( + item.schoolScoreRate ?? item.schoolRate ?? item.schoolAvgScoreRate ?? item.schoolGotScoreRate, + ), + knowledge: String(item.knowledgeName || item.knowledge || item.knowledgePointName || '待归类'), + }; +}; + +export const mapPaperQuestionSummaries = (list: unknown[]) => + (Array.isArray(list) ? list : []).map((item, index) => + mapPaperQuestionSummary(item as Record, index), + ); + +export const mapPaperQuestion = (item: Record, index: number): ReportQuestion => { + const status = getQuestionStatus(item); + const stemSource = getQuestionStemSource(item); + const answerSource = getQuestionAnswerSource(item); + const { studentAnswer, studentAnswerHtml, studentAnswerImages } = buildStudentAnswer(item); + const stemImages = extractTitleImages(stemSource); + + return { + no: Number(item.titleSort ?? item.sort ?? index + 1), + type: String(item.titleChannelTypeName || item.chanelTypeName || '综合题'), + score: formatQuestionScoreText(item), + status, + stemHtml: formatQuestionHtml(stemSource, { stripImages: true }), + stemImages, + correctAnswerHtml: formatQuestionHtml(answerSource), + studentAnswer, + studentAnswerHtml, + studentAnswerImages, + classRate: formatRateText( + item.classScoreRate ?? item.classRate ?? item.classAvgScoreRate ?? item.classGotScoreRate, + ), + schoolRate: formatRateText( + item.schoolScoreRate ?? item.schoolRate ?? item.schoolAvgScoreRate ?? item.schoolGotScoreRate, + ), + knowledge: String(item.knowledgeName || item.knowledge || item.knowledgePointName || '待归类'), + }; +}; + +export const mapPaperQuestions = (list: unknown[]) => + (Array.isArray(list) ? list : []).map((item, index) => + mapPaperQuestion(item as Record, index), + ); + +/** 兼容旧版 mock 数据结构 */ +export const normalizeLegacyQuestion = (item: Record): ReportQuestion => { + const legacy = item as ReportQuestion & { stem?: string; correctAnswer?: string }; + if (legacy.stemHtml) return legacy as ReportQuestion; + return { + no: Number(legacy.no), + type: String(legacy.type), + score: String(legacy.score), + status: legacy.status, + stemHtml: String(legacy.stem || ''), + stemImages: [], + correctAnswerHtml: String(legacy.correctAnswer || ''), + studentAnswer: String(legacy.studentAnswer || ''), + studentAnswerHtml: '', + studentAnswerImages: [], + classRate: String(legacy.classRate), + schoolRate: String(legacy.schoolRate), + knowledge: String(legacy.knowledge), + }; +}; diff --git a/src/pages/parent/utils/parentIdentity.ts b/src/pages/parent/utils/parentIdentity.ts new file mode 100644 index 0000000..2cc8b1b --- /dev/null +++ b/src/pages/parent/utils/parentIdentity.ts @@ -0,0 +1,28 @@ +/** 家长与孩子身份关系(与 xuexiaole-mobile-v 一致) */ +export const PARENT_TYPE_FATHER = 1; +export const PARENT_TYPE_MOTHER = 2; +export const PARENT_TYPE_GRANDFATHER = 3; +export const PARENT_TYPE_GRANDMOTHER = 4; +export const PARENT_TYPE_GRANDFATHER2 = 5; +export const PARENT_TYPE_GRANDMOTHER2 = 6; +export const PARENT_TYPE_TEACHER = 7; +export const PARENT_TYPE_OTHER = 8; + +const OSS_BASE = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/bindDevice'; + +export interface ParentIdentityOption { + id: number; + name: string; + icon: string; +} + +export const PARENT_IDENTITY_LIST: ParentIdentityOption[] = [ + { name: '爸爸', icon: `${OSS_BASE}/baba.png`, id: PARENT_TYPE_FATHER }, + { name: '妈妈', icon: `${OSS_BASE}/mama.png`, id: PARENT_TYPE_MOTHER }, + { name: '爷爷', icon: `${OSS_BASE}/yeye.png`, id: PARENT_TYPE_GRANDFATHER }, + { name: '奶奶', icon: `${OSS_BASE}/nainai.png`, id: PARENT_TYPE_GRANDMOTHER }, + { name: '外公', icon: `${OSS_BASE}/wg.png`, id: PARENT_TYPE_GRANDFATHER2 }, + { name: '外婆', icon: `${OSS_BASE}/waipo.png`, id: PARENT_TYPE_GRANDMOTHER2 }, + { name: '导学师', icon: `${OSS_BASE}/qita.png`, id: PARENT_TYPE_TEACHER }, + { name: '其他', icon: `${OSS_BASE}/qita.png`, id: PARENT_TYPE_OTHER }, +]; diff --git a/src/pages/parent/utils/scanBind.ts b/src/pages/parent/utils/scanBind.ts new file mode 100644 index 0000000..60b5eca --- /dev/null +++ b/src/pages/parent/utils/scanBind.ts @@ -0,0 +1,45 @@ +/** 解析扫码结果(与 xuexiaole-mobile-v useScanBind 一致) */ +export const parseScanBindResult = (raw: string) => { + if (!raw?.trim()) { + throw new Error('无法识别扫描结果'); + } + let json: Record; + try { + json = JSON.parse(raw); + } catch { + throw new Error('无法识别扫描结果'); + } + if (!json?.account || !json?.userId || !json?.simSerialNumber || !json?.model) { + throw new Error('无法识别扫描结果'); + } + return { + account: String(json.account), + childId: String(json.userId), + simSerialNumber: String(json.simSerialNumber), + }; +}; + +/** 小程序扫码并跳转绑定确认页 */ +export const scanAndGoBind = () => { + uni.scanCode({ + onlyFromCamera: false, + success: (res) => { + try { + const parsed = parseScanBindResult(res.result || ''); + const query = [ + `childId=${encodeURIComponent(parsed.childId)}`, + `account=${encodeURIComponent(parsed.account)}`, + `simSerialNumber=${encodeURIComponent(parsed.simSerialNumber)}`, + ].join('&'); + uni.navigateTo({ url: `/pages/parent/bind/index?${query}` }); + } catch (e: any) { + uni.showToast({ title: e?.message || '无法识别扫描结果', icon: 'none' }); + } + }, + fail: (err) => { + const msg = err?.errMsg || ''; + if (msg.includes('cancel')) return; + uni.showToast({ title: '扫码失败', icon: 'none' }); + }, + }); +}; diff --git a/src/router/Routeinterception.ts b/src/router/Routeinterception.ts index 7de4a4b..00aba05 100644 --- a/src/router/Routeinterception.ts +++ b/src/router/Routeinterception.ts @@ -4,6 +4,7 @@ import type { Router } from 'uni-mini-router'; import { useUserStore } from '@/state/modules/user'; import { getCache } from '@/utils/cache'; +import { getStoredRole } from '@/utils/roleHome'; // 免登录白名单(路由 name) const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile']; @@ -15,20 +16,23 @@ export function userRouternext(router: Router) { if (userStore.isLogin) { const currentRole = getCache('role'); const targetRole = (to as any).meta?.role; - if (currentRole === 'teacher' && to.name === 'home') { - return next({ - name: 'teacher-home', - }); - } - if (currentRole === 'parent' && to.name === 'home') { - return next({ - name: 'parent-home', - }); + if (currentRole && to.name === 'home') { + const homeName = currentRole === 'teacher' + ? 'teacher-home' + : currentRole === 'parent' + ? 'parent-home' + : 'home'; + if (homeName !== 'home') { + return next({ name: homeName }); + } } if (targetRole && currentRole && targetRole !== currentRole) { - return next({ - name: currentRole === 'teacher' ? 'teacher-home' : currentRole === 'parent' ? 'parent-home' : 'home', - }); + const homeName = currentRole === 'teacher' + ? 'teacher-home' + : currentRole === 'parent' + ? 'parent-home' + : 'home'; + return next({ name: homeName }); } return next(); } @@ -45,9 +49,10 @@ export function userRouternext(router: Router) { router.afterEach((to) => { const userStore = useUserStore(); if (userStore.isLogin && to?.name === 'login') { - const role = getCache('role'); + const role = getStoredRole(); if (!role) return; - router.replaceAll({ name: role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home' }); + const homeName = role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home'; + router.replaceAll({ name: homeName }); } }); } diff --git a/src/state/modules/parent.ts b/src/state/modules/parent.ts new file mode 100644 index 0000000..0091ec5 --- /dev/null +++ b/src/state/modules/parent.ts @@ -0,0 +1,127 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { getParentBindChildList, type ParentBindChildItem } from '@/api/parent'; +import { getCache, removeCache, setCache } from '@/utils/cache'; + +export interface ParentChildItem { + bindId: string; + id: string; + name: string; + account: string; + className: string; + school: string; + examNo: string; +} + +const CACHE_ACTIVE_CHILD_ID = 'parentActiveChildId'; + +const emptyChild = (): ParentChildItem => ({ + bindId: '', + id: '', + name: '--', + account: '', + className: '--', + school: '--', + examNo: '--', +}); + +const mapBindRow = (row: ParentBindChildItem): ParentChildItem => { + const account = row.child?.account || ''; + return { + bindId: String(row.id ?? row.child?.id ?? ''), + id: String(row.child?.id ?? row.id ?? ''), + name: row.child?.name || row.child?.nickname || '未命名', + account, + className: row.child?.gradeName || '', + school: '', + examNo: account, + }; +}; + +/** + * 家长端 - 绑定孩子列表与当前选中孩子 + */ +export const useParentStore = defineStore('parent', () => { + const childrenList = ref([]); + const activeChildId = ref(getCache(CACHE_ACTIVE_CHILD_ID) || ''); + const loading = ref(false); + + const activeChild = computed(() => { + if (!childrenList.value.length) { + return emptyChild(); + } + const matched = childrenList.value.find((item) => item.id === activeChildId.value); + return matched || childrenList.value[0]; + }); + + const activeChildIndex = computed(() => { + const index = childrenList.value.findIndex((item) => item.id === activeChildId.value); + return index >= 0 ? index : 0; + }); + + const hasActiveChild = computed(() => !!activeChild.value.id); + + const ensureActiveChild = () => { + if (!childrenList.value.length) { + activeChildId.value = ''; + removeCache(CACHE_ACTIVE_CHILD_ID); + return; + } + const exists = childrenList.value.some((item) => item.id === activeChildId.value); + if (!activeChildId.value || !exists) { + activeChildId.value = childrenList.value[0].id; + setCache(CACHE_ACTIVE_CHILD_ID, activeChildId.value); + } + }; + + const selectChildByIndex = (index: number) => { + const child = childrenList.value[index]; + if (!child) return; + activeChildId.value = child.id; + setCache(CACHE_ACTIVE_CHILD_ID, child.id); + }; + + const fetchBindChildren = async (parentId?: string | number) => { + const pid = parentId ?? ''; + if (pid === '' || pid == null) { + childrenList.value = []; + ensureActiveChild(); + return []; + } + + loading.value = true; + try { + const res = await getParentBindChildList({ parentId: pid }); + const rows = res.data?.rows || []; + childrenList.value = rows.map(mapBindRow); + ensureActiveChild(); + return childrenList.value; + } catch (err) { + console.error('[parent] fetchBindChildren error', err); + childrenList.value = []; + ensureActiveChild(); + throw err; + } finally { + loading.value = false; + } + }; + + const clear = () => { + childrenList.value = []; + activeChildId.value = ''; + removeCache(CACHE_ACTIVE_CHILD_ID); + }; + + return { + childrenList, + activeChildId, + activeChild, + activeChildIndex, + hasActiveChild, + loading, + fetchBindChildren, + selectChildByIndex, + ensureActiveChild, + clear, + }; +}); diff --git a/src/state/modules/user.ts b/src/state/modules/user.ts index e9faa44..54a676c 100644 --- a/src/state/modules/user.ts +++ b/src/state/modules/user.ts @@ -1,10 +1,12 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { setCache, getCache, removeCache } from '@/utils/cache'; +import { useParentStore } from '@/state/modules/parent'; import { psdLogin as psdLoginApi, parentSmsLogin as parentSmsLoginApi, getUserInfo as getUserInfoApi, + selectUser as selectUserApi, logout as logoutApi, type AccountLoginParams, type ParentSmsLoginParams, @@ -43,6 +45,8 @@ export const useUserStore = defineStore('user', () => { removeCache('userInfo'); removeCache('teacherInfo'); removeCache('role'); + removeCache('parentActiveChildId'); + useParentStore().clear(); }; /** 拉取并解析用户信息 */ @@ -90,6 +94,20 @@ export const useUserStore = defineStore('user', () => { await fetchUserInfo(); }; + /** 家长端 - 拉取并保存当前用户信息 */ + const fetchParentUserInfo = async () => { + try { + const res = await selectUserApi(); + const info: UserInfo = res.data || {}; + userInfo.value = info; + setCache('userInfo', info); + return info; + } catch (err) { + console.error('[user] fetchParentUserInfo error', err); + throw err; + } + }; + /** 家长端 - 保存登录 token */ const saveParentSession = (accessToken: string) => { if (!accessToken?.trim()) { @@ -139,6 +157,7 @@ export const useUserStore = defineStore('user', () => { parentMpLoginSuccess, parentLogin, fetchUserInfo, + fetchParentUserInfo, logout, clear, updateUserInfo, diff --git a/src/utils/questionHtml.ts b/src/utils/questionHtml.ts new file mode 100644 index 0000000..7b0908c --- /dev/null +++ b/src/utils/questionHtml.ts @@ -0,0 +1,227 @@ +/** 题目 HTML 处理(与学生端 doWork/Question 保持一致) */ + +const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?'; + +export 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; +}; + +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, latexCmd] of Object.entries(mathSymbolMap)) { + formula = formula.split(symbol).join(latexCmd); + } + + 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}`; +}; + +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 ``; + }); +}; + +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, ''); + } + t = t.replace(/<\/u>/gi, ''); + t = t.replace(/http:\/\//gi, 'https://'); + t = fixMathSymbolsInHtml(t); + t = processFormula(t); + return t; +}; + +export const getQuestionStemSource = (item: Record) => { + const pid = item.pidTitle ? String(item.pidTitle) : ''; + const stem = String(item.stemHtml || item.titleMu || item.title || item.stem || ''); + if (pid && stem) return `${pid}${stem}`; + return pid || stem; +}; + +export const getQuestionAnswerSource = (item: Record) => + String(item.answerMu ?? item.answer ?? ''); + +const IMAGE_URL_PATTERN = /\.(jpg|jpeg|png|gif|webp|bmp)(\?.*)?$/i; + +export const isImageUrl = (url: string) => { + const value = String(url || '').trim(); + if (!value) return false; + if (IMAGE_URL_PATTERN.test(value)) return true; + return /(?:cos\.|myqcloud|oss-|\/temp\/|\/upload)/i.test(value); +}; + +export const parseImageUrls = (raw: unknown) => { + const urls = String(raw || '') + .split(',') + .map((item) => item.replace(/[`\s]/g, '').replace(/http:\/\//gi, 'https://').trim()) + .filter(Boolean); + return urls.filter(isImageUrl); +}; + +export const extractAnswerImages = (item: Record) => { + const images = new Set(); + parseImageUrls(item.submitAnswerPic).forEach((url) => images.add(url)); + + const answer = String(item.submitAnswer || ''); + const links = answer.match(/https?:\/\/[^\s,]+/gi) || []; + links.forEach((link) => { + const url = link.replace(/http:\/\//gi, 'https://').trim(); + if (isImageUrl(url)) images.add(url); + }); + + return Array.from(images); +}; + +export const extractTitleImages = (html: string) => { + if (!html) return [] as string[]; + const images: string[] = []; + const imgRegex = /]*\s*src=["']([^"']+)["'][^>]*\/?>/gi; + let match; + while ((match = imgRegex.exec(html)) !== null) { + if (/formula-img/i.test(match[0])) continue; + const url = (match[1] || '').trim().replace(/http:\/\//gi, 'https://'); + if (url) images.push(url); + } + return images; +}; diff --git a/src/utils/roleHome.ts b/src/utils/roleHome.ts new file mode 100644 index 0000000..cd7e63f --- /dev/null +++ b/src/utils/roleHome.ts @@ -0,0 +1,58 @@ +import { getCache } from '@/utils/cache'; + +export type UserRole = 'student' | 'teacher' | 'parent'; + +const VALID_ROLES: UserRole[] = ['student', 'teacher', 'parent']; + +export const ROLE_HOME_URL: Record = { + student: '/pages/index/index', + teacher: '/pages/teacher/index/index', + parent: '/pages/parent/home/index', +}; + +const isNonEmptyObject = (val: unknown) => + !!val && typeof val === 'object' && Object.keys(val as object).length > 0; + +/** 读取本地已保存的账号类型 */ +export const getStoredRole = (): UserRole | null => { + const role = getCache('role'); + return VALID_ROLES.includes(role as UserRole) ? (role as UserRole) : null; +}; + +/** 是否具备可自动进入对应首页的本地登录态 */ +export const hasStoredSession = (): boolean => { + const token = getCache('token'); + const role = getStoredRole(); + if (!token || !role) return false; + + if (role === 'teacher') { + return isNonEmptyObject(getCache('teacherInfo')); + } + return isNonEmptyObject(getCache('userInfo')); +}; + +/** 根据账号类型获取首页路径 */ +export const getRoleHomeUrl = (role: UserRole | null = getStoredRole()) => + (role ? ROLE_HOME_URL[role] : ROLE_HOME_URL.student); + +/** + * 若本地已有 token、账号类型和用户信息,则跳转到对应角色首页 + * @returns 是否已触发跳转 + */ +export const redirectToRoleHomeIfNeeded = (): boolean => { + if (!hasStoredSession()) return false; + + const role = getStoredRole(); + if (!role) return false; + + const targetUrl = getRoleHomeUrl(role); + const pages = getCurrentPages(); + const current = pages[pages.length - 1]; + if (current) { + const route = `/${current.route}`; + if (route === targetUrl) return false; + } + + uni.reLaunch({ url: targetUrl }); + return true; +}; diff --git a/src/utils/subject.ts b/src/utils/subject.ts new file mode 100644 index 0000000..0b35ada --- /dev/null +++ b/src/utils/subject.ts @@ -0,0 +1,106 @@ +export interface SubjectOption { + value: number; + label: string; +} + +export interface SubjectFilterOption { + label: string; + value?: number; + subjectId?: string; +} + +// 学科 +export const subject_chinese: SubjectOption = { + value: 1, + label: '语文', +}; + +export const subject_math: SubjectOption = { + value: 2, + label: '数学', +}; + +export const subject_english: SubjectOption = { + value: 3, + label: '英语', +}; + +export const subject_physics: SubjectOption = { + value: 4, + label: '物理', +}; + +export const subject_chemistry: SubjectOption = { + value: 5, + label: '化学', +}; + +export const subject_bio: SubjectOption = { + value: 6, + label: '生物学', +}; + +export const subject_politics: SubjectOption = { + value: 7, + label: '政治(道法)', +}; + +export const subject_history: SubjectOption = { + value: 8, + label: '历史', +}; + +export const subject_geo: SubjectOption = { + value: 9, + label: '地理', +}; + +export const subject_science: SubjectOption = { + value: 10, + label: '科学', +}; + +export const subject_history_society: SubjectOption = { + value: 11, + label: '历史与社会', +}; + +export const subject_info: SubjectOption = { + value: 12, + label: '信息技术', +}; + +export const subject_music: SubjectOption = { + value: 13, + label: '音乐', +}; + +export const subject_art: SubjectOption = { + value: 14, + label: '美术', +}; + +export const SUBJECT_OPTIONS: SubjectOption[] = [ + subject_chinese, + subject_math, + subject_english, + subject_physics, + subject_chemistry, + subject_bio, + subject_politics, + subject_history, + subject_geo, + subject_science, + subject_history_society, + subject_info, + subject_music, + subject_art, +]; + +export const SUBJECT_FILTER_OPTIONS: SubjectFilterOption[] = [ + { label: '全部学科' }, + ...SUBJECT_OPTIONS.map((item) => ({ + ...item, + subjectId: String(item.value), + })), +];