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 @@
+
+
+
+ 手动绑定孩子账号
+
+
+ 取消
+
+ {{ submitting ? '绑定中...' : '绑定' }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+ onPickerChange(item.key, e)"
+ >
+
+ {{ item.range[item.value] }}
+
+
+
+
+
+
+
+
+
+
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),
+ })),
+];