feat: 对接完成
This commit is contained in:
parent
339c871d65
commit
b90a932b9a
@ -210,6 +210,91 @@ export const getWrongQuestionSummary = (params: { userId: string | number }) =>
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface QuestionTitle {
|
||||||
|
actualScore?: number;
|
||||||
|
aiAnalyze?: string;
|
||||||
|
aiAnswer?: string;
|
||||||
|
answer?: string;
|
||||||
|
answerHtml?: string;
|
||||||
|
answerSqs?: string;
|
||||||
|
correctResult?: number;
|
||||||
|
courseId?: number;
|
||||||
|
difficulty?: number;
|
||||||
|
difficultyCode?: number;
|
||||||
|
difficultyLevel?: string;
|
||||||
|
explanation?: string;
|
||||||
|
id?: string;
|
||||||
|
itemId?: number;
|
||||||
|
optionHtml?: string;
|
||||||
|
optionSqs?: string;
|
||||||
|
score?: number;
|
||||||
|
stem?: string;
|
||||||
|
stemHtml?: string;
|
||||||
|
submitAnswer?: string;
|
||||||
|
submitAnswerPic?: string;
|
||||||
|
typeId?: string;
|
||||||
|
typeName?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WrongQuestionItem {
|
||||||
|
id: number;
|
||||||
|
questionId: string;
|
||||||
|
userId?: number;
|
||||||
|
subjectId?: number;
|
||||||
|
textbookId?: number;
|
||||||
|
chapterId?: number;
|
||||||
|
courseId?: number;
|
||||||
|
errorCount: number;
|
||||||
|
errorType?: number;
|
||||||
|
masteryLevel?: number;
|
||||||
|
status?: number;
|
||||||
|
firstErrorTime: string;
|
||||||
|
lastErrorTime: string;
|
||||||
|
lastCorrectTime?: string;
|
||||||
|
questionTitle?: QuestionTitle;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WrongQuestionFilterPageParams {
|
||||||
|
userId?: string | number;
|
||||||
|
subjectId?: string | number;
|
||||||
|
current?: number;
|
||||||
|
size?: number;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterSubject {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学科网 - 错题筛选分页列表 */
|
||||||
|
export const getWrongQuestionFilterPage = (params: WrongQuestionFilterPageParams) => {
|
||||||
|
const query: Record<string, string | number> = {
|
||||||
|
current: params.current ?? 1,
|
||||||
|
size: params.size ?? 10,
|
||||||
|
};
|
||||||
|
if (params.userId) query.userId = params.userId;
|
||||||
|
if (params.subjectId) query.subjectId = Number(params.subjectId);
|
||||||
|
if (params.startTime) query.startTime = params.startTime;
|
||||||
|
if (params.endTime) query.endTime = params.endTime;
|
||||||
|
return request<PageResult<WrongQuestionItem>>({
|
||||||
|
url: '/xkw/wrong/question/filter/page',
|
||||||
|
method: 'GET',
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 学科网 - 错题筛选学科列表 */
|
||||||
|
export const getWrongQuestionFilterSubjects = (params?: { userId?: string | number }) =>
|
||||||
|
request<FilterSubject[]>({
|
||||||
|
url: '/xkw/wrong/question/filter/subjects',
|
||||||
|
method: 'GET',
|
||||||
|
params: params?.userId ? { userId: params.userId } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
/** 家长端 - 错题知识点占比 */
|
/** 家长端 - 错题知识点占比 */
|
||||||
export const getWrongKpointRatio = (params: { subjectId: string | number; userId: string | number }) =>
|
export const getWrongKpointRatio = (params: { subjectId: string | number; userId: string | number }) =>
|
||||||
request<WrongKpointRatioResult>({
|
request<WrongKpointRatioResult>({
|
||||||
|
|||||||
@ -117,6 +117,52 @@ const getTouchPoint = (e: any) => {
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncViewportSize = (reloadWhenVisible = false) => {
|
||||||
|
uni.getSystemInfo({
|
||||||
|
success: (res) => {
|
||||||
|
safeAreaTop.value = res.safeArea?.top || 0;
|
||||||
|
safeAreaBottom.value =
|
||||||
|
(res.screenHeight && res.safeArea ? res.screenHeight - res.safeArea.bottom : 0) || 0;
|
||||||
|
const bottomReserve = controlsReservePx + safeAreaBottom.value;
|
||||||
|
const fallbackWidth = res.windowWidth;
|
||||||
|
const fallbackHeight = res.windowHeight;
|
||||||
|
|
||||||
|
const applySize = (width: number, height: number) => {
|
||||||
|
const nextWidth = Math.max(1, Math.round(width));
|
||||||
|
const nextHeight = Math.max(1, Math.round(height - bottomReserve));
|
||||||
|
const sizeChanged = nextWidth !== canvasWidth.value || nextHeight !== canvasHeight.value;
|
||||||
|
canvasWidth.value = nextWidth;
|
||||||
|
canvasHeight.value = nextHeight;
|
||||||
|
if (!ctx.value) {
|
||||||
|
ctx.value = uni.createCanvasContext('imageCropperCanvas', componentProxy);
|
||||||
|
}
|
||||||
|
if (props.visible && props.imageSrc && (reloadWhenVisible || sizeChanged)) {
|
||||||
|
tryInitAndLoad();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.visible) {
|
||||||
|
nextTick(() => {
|
||||||
|
uni.createSelectorQuery()
|
||||||
|
.in(componentProxy)
|
||||||
|
.select('.cropper-wrapper')
|
||||||
|
.boundingClientRect((rect: any) => {
|
||||||
|
if (rect?.width && rect?.height) {
|
||||||
|
applySize(rect.width, rect.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applySize(fallbackWidth, fallbackHeight);
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySize(fallbackWidth, fallbackHeight);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const tryInitAndLoad = () => {
|
const tryInitAndLoad = () => {
|
||||||
if (!props.visible) return;
|
if (!props.visible) return;
|
||||||
if (!props.imageSrc) return;
|
if (!props.imageSrc) return;
|
||||||
@ -133,21 +179,8 @@ const tryInitAndLoad = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 获取设备信息,用于计算canvas大小和安全区
|
// 横屏真机下 getSystemInfo 可能拿到旋转前尺寸,因此优先同步可视区域真实尺寸
|
||||||
uni.getSystemInfo({
|
syncViewportSize(false);
|
||||||
success: (res) => {
|
|
||||||
canvasWidth.value = res.windowWidth;
|
|
||||||
safeAreaTop.value = res.safeArea?.top || 0;
|
|
||||||
safeAreaBottom.value =
|
|
||||||
(res.screenHeight && res.safeArea ? res.screenHeight - res.safeArea.bottom : 0) || 0;
|
|
||||||
// 预留底部按钮区 + 安全区,避免按钮覆盖图片(尤其真机横屏)
|
|
||||||
const bottomReserve = controlsReservePx + safeAreaBottom.value;
|
|
||||||
canvasHeight.value = Math.max(1, res.windowHeight - bottomReserve);
|
|
||||||
// 自定义组件内的 canvas 必须传组件实例,否则会出现“黑屏/不绘制”
|
|
||||||
ctx.value = uni.createCanvasContext('imageCropperCanvas', componentProxy);
|
|
||||||
tryInitAndLoad();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -157,6 +190,7 @@ watch(
|
|||||||
imgInfo.value = null;
|
imgInfo.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
syncViewportSize(true);
|
||||||
tryInitAndLoad();
|
tryInitAndLoad();
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"islogin": false
|
"islogin": false
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "学小乐AI",
|
"navigationBarTitleText": "灵犀学AI",
|
||||||
"enablePullDownRefresh": false,
|
"enablePullDownRefresh": false,
|
||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"pageOrientation": "portrait"
|
"pageOrientation": "portrait"
|
||||||
@ -34,6 +34,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/agreement/user/index",
|
||||||
|
"name": "agreement-user",
|
||||||
|
"meta": {
|
||||||
|
"islogin": false
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "用户协议",
|
||||||
|
"mp-weixin": {
|
||||||
|
"pageOrientation": "portrait"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/agreement/privacy/index",
|
||||||
|
"name": "agreement-privacy",
|
||||||
|
"meta": {
|
||||||
|
"islogin": false
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "隐私政策",
|
||||||
|
"mp-weixin": {
|
||||||
|
"pageOrientation": "portrait"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/homework/index",
|
"path": "pages/homework/index",
|
||||||
"name": "homework",
|
"name": "homework",
|
||||||
@ -86,6 +112,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/wrong/list",
|
||||||
|
"name": "wrongList",
|
||||||
|
"meta": {
|
||||||
|
"islogin": true
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "错题列表",
|
||||||
|
"mp-weixin": {
|
||||||
|
"pageOrientation": "portrait"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/doWork/index",
|
"path": "pages/doWork/index",
|
||||||
"name": "doWork",
|
"name": "doWork",
|
||||||
@ -276,7 +315,7 @@
|
|||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarTitleText": "学小乐AI",
|
"navigationBarTitleText": "灵犀学AI",
|
||||||
"navigationBarBackgroundColor": "#FFFFFF",
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
"backgroundColor": "#F5F7FF",
|
"backgroundColor": "#F5F7FF",
|
||||||
"pageOrientation": "portrait"
|
"pageOrientation": "portrait"
|
||||||
|
|||||||
60
src/pages/agreement/privacy/index.vue
Normal file
60
src/pages/agreement/privacy/index.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<scroll-view class="agreement-page" scroll-y :show-scrollbar="false">
|
||||||
|
<view class="container">
|
||||||
|
<view class="title">灵犀学隐私政策</view>
|
||||||
|
<view class="date">更新日期:2026-05-30</view>
|
||||||
|
<view class="content">
|
||||||
|
<text>
|
||||||
|
我们重视您的个人信息与隐私保护。为向您提供学习服务,我们会在最小必要范围内收集和使用信息。
|
||||||
|
</text>
|
||||||
|
<text>1. 信息收集:可能包括账号信息、学习记录、设备信息等。</text>
|
||||||
|
<text>2. 信息用途:用于登录鉴权、学习功能实现、学习效果分析与服务优化。</text>
|
||||||
|
<text>3. 信息保护:我们采取合理安全措施保护您的信息,防止泄露、篡改或丢失。</text>
|
||||||
|
<text>4. 信息共享:除法律法规要求外,未经授权不会向第三方提供个人信息。</text>
|
||||||
|
<text>5. 权利保障:您可通过平台或管理员渠道申请查询、更正、删除相关信息。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.agreement-page {
|
||||||
|
height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
src/pages/agreement/user/index.vue
Normal file
61
src/pages/agreement/user/index.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<scroll-view class="agreement-page" scroll-y :show-scrollbar="false">
|
||||||
|
<view class="container">
|
||||||
|
<view class="title">灵犀学用户协议</view>
|
||||||
|
<view class="date">更新日期:2026-05-30</view>
|
||||||
|
<view class="content">
|
||||||
|
<text>
|
||||||
|
欢迎使用灵犀学。为保障您的合法权益,请在使用前仔细阅读本协议。您在注册、登录或使用服务时,
|
||||||
|
即视为已阅读并同意本协议全部内容。
|
||||||
|
</text>
|
||||||
|
<text>1. 服务内容:我们提供学习任务、作业练习、学情分析等功能。</text>
|
||||||
|
<text>2. 账号安全:请妥善保管账号信息,因保管不善造成的风险由用户自行承担。</text>
|
||||||
|
<text>3. 合规使用:不得利用本服务从事违法违规活动,不得侵犯他人合法权益。</text>
|
||||||
|
<text>4. 服务变更:我们可能根据业务需要调整服务内容,并通过合理方式通知。</text>
|
||||||
|
<text>5. 联系方式:如有问题请联系平台运营方或学校管理员。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.agreement-page {
|
||||||
|
height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -367,6 +367,7 @@
|
|||||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
import { uploadFile } from '@/api/upload';
|
import { uploadFile } from '@/api/upload';
|
||||||
import ImageCropper from '@/components/ImageCropper/index.vue';
|
import ImageCropper from '@/components/ImageCropper/index.vue';
|
||||||
|
import { formatQuestionHtml } from '@/utils/questionHtml';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data?: any;
|
data?: any;
|
||||||
@ -377,7 +378,7 @@ const props = defineProps<{
|
|||||||
subjectId?: string;
|
subjectId?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(['submit']);
|
const emit = defineEmits(['submit', 'cropper-visible-change']);
|
||||||
|
|
||||||
/* ============== 音频 ============== */
|
/* ============== 音频 ============== */
|
||||||
const isAudioPlay = ref(false);
|
const isAudioPlay = ref(false);
|
||||||
@ -412,21 +413,18 @@ const previewTitleImage = (index: number) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ============== 公式失败兜底集合 ============== */
|
|
||||||
const failedFormulaUrls = ref<Set<string>>(new Set());
|
|
||||||
|
|
||||||
/* ============== 题目过滤 ============== */
|
/* ============== 题目过滤 ============== */
|
||||||
const filterTitleWithoutImages = (title?: string, cidx?: number | string) => {
|
const renderQuestionHtml = (title?: string, cidx?: number | string, options?: { stripImages?: boolean }) => {
|
||||||
if (!title) return '';
|
if (!title) return '';
|
||||||
let t = title.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
|
let html = formatQuestionHtml(title, options);
|
||||||
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
|
|
||||||
t = t.replace(/http:\/\//gi, 'https://');
|
|
||||||
t = fixMathSymbolsInHtml(t);
|
|
||||||
t = processFormula(t, failedFormulaUrls.value);
|
|
||||||
if (cidx !== undefined) {
|
if (cidx !== undefined) {
|
||||||
t = `(${Number(cidx) + 1})` + t;
|
html = `(${Number(cidx) + 1})${html}`;
|
||||||
}
|
}
|
||||||
return t;
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterTitleWithoutImages = (title?: string, cidx?: number | string) => {
|
||||||
|
return renderQuestionHtml(title, cidx, { stripImages: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseJsonArray = (raw: any) => {
|
const parseJsonArray = (raw: any) => {
|
||||||
@ -466,283 +464,6 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ============== 数学符号修复 ============== */
|
|
||||||
const fixMathSymbolsInHtml = (html: string): string => {
|
|
||||||
if (html == null || typeof html !== 'string') return '';
|
|
||||||
let t = html;
|
|
||||||
t = t.replace(/\uFE63/g, '\u2212');
|
|
||||||
t = t.replace(/<(?![a-zA-Z/!])/g, '<');
|
|
||||||
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<string, string> = {
|
|
||||||
',': ',', '。': '.', ':': ':', ';': ';', '!': '!', '?': '?',
|
|
||||||
'(': '(', ')': ')', '【': '[', '】': ']', '{': '{', '}': '}',
|
|
||||||
'《': '<', '》': '>',
|
|
||||||
"\u201c": '"', "\u201d": '"', "\u2018": "'", "\u2019": "'",
|
|
||||||
'+': '+', '-': '-', '\u2212': '-', '×': '*', '÷': '/',
|
|
||||||
'=': '=', '<': '<', '>': '>', '%': '%', '&': '&',
|
|
||||||
'*': '*', '@': '@', '#': '#', '$': '$', '^': '^',
|
|
||||||
'~': '~', '|': '|', '\': '\\', '/': '/', ' ': ' ',
|
|
||||||
};
|
|
||||||
for (const [cn, en] of Object.entries(punctuationMap)) {
|
|
||||||
formula = formula.split(cn).join(en);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mathSymbolMap: Record<string, string> = {
|
|
||||||
'∈': ' \\in ', '∉': ' \\notin ', '⊂': ' \\subset ', '⊃': ' \\supset ',
|
|
||||||
'⊆': ' \\subseteq ', '⊇': ' \\supseteq ', '∪': ' \\cup ', '∩': ' \\cap ',
|
|
||||||
'∅': ' \\emptyset ',
|
|
||||||
'≤': ' \\leq ', '≥': ' \\geq ', '≠': ' \\neq ', '≈': ' \\approx ',
|
|
||||||
'≡': ' \\equiv ', '∝': ' \\propto ',
|
|
||||||
'±': ' \\pm ', '∓': ' \\mp ', '×': ' \\times ', '÷': ' \\div ',
|
|
||||||
'·': ' \\cdot ', '∘': ' \\circ ',
|
|
||||||
'α': '\\alpha ', 'β': '\\beta ', 'γ': '\\gamma ', 'δ': '\\delta ',
|
|
||||||
'ε': '\\varepsilon ', 'θ': '\\theta ', 'λ': '\\lambda ', 'μ': '\\mu ',
|
|
||||||
'σ': '\\sigma ', 'φ': '\\varphi ', 'ω': '\\omega ',
|
|
||||||
'Δ': '\\Delta ', 'Σ': '\\Sigma ', 'Ω': '\\Omega ',
|
|
||||||
'∞': '\\infty ', '∂': '\\partial ', '∇': '\\nabla ', '∫': '\\int ',
|
|
||||||
'∑': '\\sum ', '∏': '\\prod ', '√': '\\sqrt ', '∠': '\\angle ',
|
|
||||||
'⊥': '\\perp ', '∥': '\\parallel ',
|
|
||||||
'→': '\\to ', '⇒': '\\Rightarrow ', '⇔': '\\Leftrightarrow ',
|
|
||||||
'∀': '\\forall ', '∃': '\\exists ', '¬': '\\neg ',
|
|
||||||
'∧': '\\land ', '∨': '\\lor ',
|
|
||||||
};
|
|
||||||
for (const [symbol, latex] of Object.entries(mathSymbolMap)) {
|
|
||||||
formula = formula.split(symbol).join(latex);
|
|
||||||
}
|
|
||||||
|
|
||||||
formula = formula.replace(/\b([RNZQC])\+/g, '$1^+');
|
|
||||||
formula = formula.replace(/\b([RNZQC])\*/g, '$1^*');
|
|
||||||
|
|
||||||
formula = formula
|
|
||||||
.replace(/\\overset\s*\{\s*\\rightarrow\s*\}\s*\{([^}]+)\}/g, '\\vec{$1}')
|
|
||||||
.replace(/\\text\s*\{\s*π\s*\}/g, '\\pi')
|
|
||||||
.replace(/\\text\s*\{\s*sin\s*\}/gi, '\\sin')
|
|
||||||
.replace(/\\text\s*\{\s*cos\s*\}/gi, '\\cos')
|
|
||||||
.replace(/\\text\s*\{\s*tan\s*\}/gi, '\\tan')
|
|
||||||
.replace(/\\text\s*\{\s*log\s*\}/gi, '\\log')
|
|
||||||
.replace(/π/g, '\\pi');
|
|
||||||
|
|
||||||
const encoded = encodeURIComponent(formula);
|
|
||||||
return `${LATEX_RENDER_URL}\\inline&space;\\dpi{200}&space;${encoded}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSimpleFormula = (formula: string): boolean => {
|
|
||||||
const complexPatterns = [
|
|
||||||
/\\frac\b/, /\\sqrt\b/, /\\sum\b/, /\\int\b/, /\\prod\b/, /\\lim\b/,
|
|
||||||
/\\vec\b/, /\\overline\b/, /\\underline\b/, /\\overset\b/, /\\underset\b/,
|
|
||||||
/\\binom\b/, /\\matrix\b/, /\\begin\b/,
|
|
||||||
/\^{[^}]+}/, /_{[^}]+}/,
|
|
||||||
];
|
|
||||||
return !complexPatterns.some((pattern) => pattern.test(formula));
|
|
||||||
};
|
|
||||||
|
|
||||||
const simpleFormulaToHtml = (formula: string): string => {
|
|
||||||
let text = formula;
|
|
||||||
text = text.replace(/\\text\s*\{([^}]*)\}/g, '$1');
|
|
||||||
|
|
||||||
const greekMap: Record<string, string> = {
|
|
||||||
'\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\delta': 'δ',
|
|
||||||
'\\epsilon': 'ε', '\\varepsilon': 'ε', '\\theta': 'θ', '\\lambda': 'λ',
|
|
||||||
'\\mu': 'μ', '\\pi': 'π', '\\sigma': 'σ', '\\phi': 'φ', '\\varphi': 'φ',
|
|
||||||
'\\omega': 'ω', '\\Delta': 'Δ', '\\Sigma': 'Σ', '\\Omega': 'Ω',
|
|
||||||
};
|
|
||||||
for (const [latex, symbol] of Object.entries(greekMap)) {
|
|
||||||
text = text.split(latex).join(symbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
text = text.replace(/\\sin\b/g, 'sin');
|
|
||||||
text = text.replace(/\\cos\b/g, 'cos');
|
|
||||||
text = text.replace(/\\tan\b/g, 'tan');
|
|
||||||
text = text.replace(/\\log\b/g, 'log');
|
|
||||||
text = text.replace(/\\ln\b/g, 'ln');
|
|
||||||
text = text.replace(/\\infty\b/g, '∞');
|
|
||||||
text = text.replace(/\\pm\b/g, '±');
|
|
||||||
text = text.replace(/\\times\b/g, '×');
|
|
||||||
text = text.replace(/\\div\b/g, '÷');
|
|
||||||
text = text.replace(/\\leq\b/g, '≤');
|
|
||||||
text = text.replace(/\\geq\b/g, '≥');
|
|
||||||
text = text.replace(/\\neq\b/g, '≠');
|
|
||||||
text = text.replace(/\\in\b/g, '∈');
|
|
||||||
text = text.replace(/\\subset\b/g, '⊂');
|
|
||||||
text = text.replace(/\\cup\b/g, '∪');
|
|
||||||
text = text.replace(/\\cap\b/g, '∩');
|
|
||||||
text = text.replace(/\\emptyset\b/g, '∅');
|
|
||||||
text = text.replace(/\\cdot\b/g, '·');
|
|
||||||
text = text.replace(/\\to\b/g, '→');
|
|
||||||
|
|
||||||
const superscriptMap: Record<string, string> = {
|
|
||||||
'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',
|
|
||||||
'5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹',
|
|
||||||
'+': '⁺', '-': '⁻', 'n': 'ⁿ',
|
|
||||||
};
|
|
||||||
const subscriptMap: Record<string, string> = {
|
|
||||||
'0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄',
|
|
||||||
'5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉',
|
|
||||||
'+': '₊', '-': '₋', 'n': 'ₙ', 'i': 'ᵢ', 'k': 'ₖ',
|
|
||||||
};
|
|
||||||
text = text.replace(/\^([0-9n+\-])/g, (_, char) => superscriptMap[char] || `^${char}`);
|
|
||||||
text = text.replace(/_([0-9nik+\-])/g, (_, char) => subscriptMap[char] || `_${char}`);
|
|
||||||
|
|
||||||
text = text.replace(/\\left\s*/g, '');
|
|
||||||
text = text.replace(/\\right\s*/g, '');
|
|
||||||
text = text.replace(/\\/g, '');
|
|
||||||
|
|
||||||
return `<span class="formula-text" style="font-style:italic;">${text}</span>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const processFormula = (html: string, failedUrls?: Set<string>): string => {
|
|
||||||
if (html == null || typeof html !== 'string') return '';
|
|
||||||
if (!html) return '';
|
|
||||||
|
|
||||||
const formulaRegex = /<span\s+((?:[^<>"']+|"[^"]*"|'[^']*')*)data-w-e-type=["']formula["']((?:[^<>"']+|"[^"]*"|'[^']*')*)>[\s\S]*?<\/span>/gi;
|
|
||||||
|
|
||||||
return html.replace(formulaRegex, (match) => {
|
|
||||||
const valueMatch = match.match(/data-value=["']([^"']+)["']/);
|
|
||||||
if (!valueMatch) return match;
|
|
||||||
|
|
||||||
let formula = valueMatch[1]
|
|
||||||
.replace(/\\\\/g, '\\')
|
|
||||||
.replace(/\\"/g, '"')
|
|
||||||
.replace(/\\'/g, "'");
|
|
||||||
|
|
||||||
if (!formula.trim()) return '';
|
|
||||||
|
|
||||||
if (isSimpleFormula(formula)) {
|
|
||||||
return simpleFormulaToHtml(formula);
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = latexToImageUrl(formula);
|
|
||||||
if (failedUrls?.has(imageUrl)) {
|
|
||||||
const display = formula.length > 60 ? formula.substring(0, 60) + '…' : formula;
|
|
||||||
return `<span class="formula-text">[${display}]</span>`;
|
|
||||||
}
|
|
||||||
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" />`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ============== 公式预加载 + 重试 ============== */
|
|
||||||
const extractFormulaImageUrls = (html: string): string[] => {
|
|
||||||
if (html == null || typeof html !== 'string') return [];
|
|
||||||
const formulaRegex = /<span\s+((?:[^<>"']+|"[^"]*"|'[^']*')*)data-w-e-type=["']formula["']((?:[^<>"']+|"[^"]*"|'[^']*')*)>[\s\S]*?<\/span>/gi;
|
|
||||||
const urls: string[] = [];
|
|
||||||
let match;
|
|
||||||
while ((match = formulaRegex.exec(html)) !== null) {
|
|
||||||
const valueMatch = match[0].match(/data-value=["']([^"']+)["']/);
|
|
||||||
if (!valueMatch) continue;
|
|
||||||
let formula = valueMatch[1]
|
|
||||||
.replace(/\\\\/g, '\\')
|
|
||||||
.replace(/\\"/g, '"')
|
|
||||||
.replace(/\\'/g, "'");
|
|
||||||
if (!formula.trim()) continue;
|
|
||||||
if (isSimpleFormula(formula)) continue;
|
|
||||||
urls.push(latexToImageUrl(formula));
|
|
||||||
}
|
|
||||||
return urls;
|
|
||||||
};
|
|
||||||
|
|
||||||
const preloadImageWithRetry = (url: string, maxRetries = 3): Promise<boolean> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let attempt = 0;
|
|
||||||
const tryLoad = () => {
|
|
||||||
uni.getImageInfo({
|
|
||||||
src: url,
|
|
||||||
success: () => resolve(true),
|
|
||||||
fail: () => {
|
|
||||||
attempt++;
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
setTimeout(tryLoad, attempt * 1000);
|
|
||||||
} else {
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
tryLoad();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const collectAllRawHtml = (data: any): string[] => {
|
|
||||||
if (!data) return [];
|
|
||||||
const htmls: string[] = [];
|
|
||||||
if (data.pidTitle) htmls.push(data.pidTitle);
|
|
||||||
if (data.titleMu || data.title) htmls.push(data.titleMu || data.title);
|
|
||||||
if (data.children?.length) {
|
|
||||||
data.children.forEach((c: any) => { if (c.title) htmls.push(c.title); });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let opts = data.optionsMu ?? data.options;
|
|
||||||
if (typeof opts === 'string') opts = JSON.parse(opts);
|
|
||||||
if (Array.isArray(opts)) {
|
|
||||||
opts.forEach((item: any) => {
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
if (item) htmls.push(item);
|
|
||||||
} else if (item?.stem || item?.options) {
|
|
||||||
if (item.stem) htmls.push(String(item.stem));
|
|
||||||
if (Array.isArray(item.options)) {
|
|
||||||
item.options.forEach((sub: any) => {
|
|
||||||
const sv = typeof sub === 'string' ? sub : (sub?.value ?? sub?.optionValue ?? '');
|
|
||||||
if (sv) htmls.push(String(sv));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const v = item?.value ?? item?.optionValue ?? '';
|
|
||||||
if (v) htmls.push(String(v));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
if (data.answerMu || data.answer) htmls.push(data.answerMu || data.answer);
|
|
||||||
if (data.titleAnalyzeMu || data.titleAnalyze) htmls.push(data.titleAnalyzeMu || data.titleAnalyze);
|
|
||||||
if (data.aiAnswer != null) htmls.push(String(data.aiAnswer));
|
|
||||||
if (data.aiAnalyze != null) htmls.push(String(data.aiAnalyze));
|
|
||||||
return htmls;
|
|
||||||
};
|
|
||||||
|
|
||||||
let preloadGen = 0;
|
|
||||||
watch(
|
|
||||||
() => props.data,
|
|
||||||
async (newData) => {
|
|
||||||
const generation = ++preloadGen;
|
|
||||||
failedFormulaUrls.value = new Set();
|
|
||||||
if (!newData) return;
|
|
||||||
|
|
||||||
const allUrls = new Set<string>();
|
|
||||||
for (const html of collectAllRawHtml(newData)) {
|
|
||||||
const processed = fixMathSymbolsInHtml(html);
|
|
||||||
extractFormulaImageUrls(processed).forEach((url) => allUrls.add(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allUrls.size === 0) return;
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
Array.from(allUrls).map(async (url) => ({
|
|
||||||
url,
|
|
||||||
ok: await preloadImageWithRetry(url, 3),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (generation !== preloadGen) return;
|
|
||||||
|
|
||||||
const failed = results.filter((r) => !r.ok).map((r) => r.url);
|
|
||||||
if (failed.length > 0) {
|
|
||||||
console.warn(`[Question] ${failed.length} 张公式图片加载失败,已切换为文本兜底`, failed);
|
|
||||||
failedFormulaUrls.value = new Set(failed);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ============== 选项 ============== */
|
/* ============== 选项 ============== */
|
||||||
const optionList = computed(() => {
|
const optionList = computed(() => {
|
||||||
const data = props.data;
|
const data = props.data;
|
||||||
@ -772,9 +493,7 @@ const optionList = computed(() => {
|
|||||||
const v = item?.html ?? item?.value ?? item?.optionValue ?? item?.content ?? '';
|
const v = item?.html ?? item?.value ?? item?.optionValue ?? item?.content ?? '';
|
||||||
value = v != null && v !== '' ? String(v) : '';
|
value = v != null && v !== '' ? String(v) : '';
|
||||||
}
|
}
|
||||||
value = fixMathSymbolsInHtml(value);
|
value = formatQuestionHtml(value);
|
||||||
value = processFormula(value, failedFormulaUrls.value);
|
|
||||||
value = typeof value === 'string' ? value.replace(/http:\/\//gi, 'https://') : '';
|
|
||||||
return {
|
return {
|
||||||
key:
|
key:
|
||||||
typeof item === 'string'
|
typeof item === 'string'
|
||||||
@ -869,9 +588,7 @@ const yueDuList = computed(() => {
|
|||||||
const subOpts = rawSubOpts.map((opt: any, oIdx: number) => {
|
const subOpts = rawSubOpts.map((opt: any, oIdx: number) => {
|
||||||
let v = typeof opt === 'string' ? opt : (opt?.html ?? opt?.value ?? opt?.optionValue ?? opt?.content ?? '');
|
let v = typeof opt === 'string' ? opt : (opt?.html ?? opt?.value ?? opt?.optionValue ?? opt?.content ?? '');
|
||||||
v = v != null ? String(v) : '';
|
v = v != null ? String(v) : '';
|
||||||
v = fixMathSymbolsInHtml(v);
|
v = formatQuestionHtml(v);
|
||||||
v = processFormula(v, failedFormulaUrls.value);
|
|
||||||
v = v.replace(/http:\/\//gi, 'https://');
|
|
||||||
return {
|
return {
|
||||||
label:
|
label:
|
||||||
typeof opt === 'string'
|
typeof opt === 'string'
|
||||||
@ -883,9 +600,7 @@ const yueDuList = computed(() => {
|
|||||||
let stem = '';
|
let stem = '';
|
||||||
if (item.stem) {
|
if (item.stem) {
|
||||||
stem = String(item.stem);
|
stem = String(item.stem);
|
||||||
stem = fixMathSymbolsInHtml(stem);
|
stem = formatQuestionHtml(stem);
|
||||||
stem = processFormula(stem, failedFormulaUrls.value);
|
|
||||||
stem = stem.replace(/http:\/\//gi, 'https://');
|
|
||||||
}
|
}
|
||||||
let horizontal = false;
|
let horizontal = false;
|
||||||
if (subOpts.length > 0) {
|
if (subOpts.length > 0) {
|
||||||
@ -922,14 +637,7 @@ const resultClass = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filterTitle = (title?: string, cidx?: number | string) => {
|
const filterTitle = (title?: string, cidx?: number | string) => {
|
||||||
if (!title) return '';
|
return renderQuestionHtml(title, cidx);
|
||||||
let t = title.replace(/http:\/\//gi, 'https://');
|
|
||||||
t = fixMathSymbolsInHtml(t);
|
|
||||||
t = processFormula(t, failedFormulaUrls.value);
|
|
||||||
if (cidx !== undefined) {
|
|
||||||
t = `(${Number(cidx) + 1})` + t;
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ============== 单选 ============== */
|
/* ============== 单选 ============== */
|
||||||
@ -1048,6 +756,10 @@ const picList = computed(() => {
|
|||||||
const showCropper = ref(false);
|
const showCropper = ref(false);
|
||||||
const cropperImageSrc = ref('');
|
const cropperImageSrc = ref('');
|
||||||
|
|
||||||
|
watch(showCropper, (visible) => {
|
||||||
|
emit('cropper-visible-change', visible);
|
||||||
|
});
|
||||||
|
|
||||||
const chooseImage = () => {
|
const chooseImage = () => {
|
||||||
const remain = 3 - picList.value.length;
|
const remain = 3 - picList.value.length;
|
||||||
if (remain <= 0) {
|
if (remain <= 0) {
|
||||||
|
|||||||
@ -9,11 +9,11 @@
|
|||||||
<view class="top-back" @click="handleBack">
|
<view class="top-back" @click="handleBack">
|
||||||
<view class="back-arrow"></view>
|
<view class="back-arrow"></view>
|
||||||
</view>
|
</view>
|
||||||
<view class="top-title">
|
<view v-if="!isCropperActive" class="top-title">
|
||||||
<text class="ellipsis">{{ title }}</text>
|
<text class="ellipsis">{{ title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-if="!pageLoading"
|
v-if="!pageLoading && !isCropperActive"
|
||||||
class="top-meta"
|
class="top-meta"
|
||||||
:style="{ paddingRight: menuButtonRightRpx + 'rpx' }"
|
:style="{ paddingRight: menuButtonRightRpx + 'rpx' }"
|
||||||
>
|
>
|
||||||
@ -48,6 +48,7 @@
|
|||||||
:reportFlag="reportFlag"
|
:reportFlag="reportFlag"
|
||||||
:subjectId="paperInfo.subjectId"
|
:subjectId="paperInfo.subjectId"
|
||||||
@submit="submitQuestion"
|
@submit="submitQuestion"
|
||||||
|
@cropper-visible-change="onCropperVisibleChange"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -119,6 +120,7 @@ import Question from './components/Question.vue';
|
|||||||
const pageLoading = ref(true);
|
const pageLoading = ref(true);
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
const menuButtonRightRpx = ref(0);
|
const menuButtonRightRpx = ref(0);
|
||||||
|
const isCropperActive = ref(false);
|
||||||
|
|
||||||
const paperList = ref<any[]>([]);
|
const paperList = ref<any[]>([]);
|
||||||
const paperInfo = ref<any>({});
|
const paperInfo = ref<any>({});
|
||||||
@ -322,6 +324,10 @@ const submitQuestion = (answer: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCropperVisibleChange = (visible: boolean) => {
|
||||||
|
isCropperActive.value = !!visible;
|
||||||
|
};
|
||||||
|
|
||||||
const submitTrain = async () => {
|
const submitTrain = async () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
@ -446,6 +452,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
isCropperActive.value = false;
|
||||||
paperInfo.value = {};
|
paperInfo.value = {};
|
||||||
paperList.value = [];
|
paperList.value = [];
|
||||||
activeIdx.value = 0;
|
activeIdx.value = 0;
|
||||||
@ -495,6 +502,7 @@ onUnmounted(() => {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 3rpx 12rpx 2rpx;
|
padding: 3rpx 12rpx 2rpx;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -301,7 +301,7 @@ onShow(() => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.hist-page {
|
.hist-page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background: #f5f7ff;
|
background: #f5f7ff;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -274,7 +274,7 @@ onShow(() => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.hw-page {
|
.hw-page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background: #f5f7ff;
|
background: #f5f7ff;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
<view class="hero">
|
<view class="hero">
|
||||||
<view class="hero-greet">
|
<view class="hero-greet">
|
||||||
<view class="hero-hello">{{ greeting }} 👋</view>
|
<view class="hero-hello">{{ greeting }} 👋</view>
|
||||||
<view class="hero-title">学小乐<text class="hero-title-ai">AI</text></view>
|
<view class="hero-title">灵犀学<text class="hero-title-ai">AI</text></view>
|
||||||
<view class="hero-sub">让学习像玩游戏一样有趣</view>
|
<view class="hero-sub">让学习像玩游戏一样有趣</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="hero-mascot">
|
<view class="hero-mascot">
|
||||||
@ -57,10 +57,7 @@
|
|||||||
|
|
||||||
<view v-else class="user-card login-card" @click="goLogin">
|
<view v-else class="user-card login-card" @click="goLogin">
|
||||||
<view class="login-illu">
|
<view class="login-illu">
|
||||||
<svg viewBox="0 0 24 24" fill="none">
|
<text class="icon-emoji">👤</text>
|
||||||
<circle cx="12" cy="8" r="4" stroke="#4F46E5" stroke-width="1.8" />
|
|
||||||
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8" stroke="#4F46E5" stroke-width="1.8" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="login-text">
|
<view class="login-text">
|
||||||
<view class="login-title">点击登录账号</view>
|
<view class="login-title">点击登录账号</view>
|
||||||
@ -79,11 +76,7 @@
|
|||||||
<view class="grid-card grid-card-primary" @click="navigate('/pages/homework/index')">
|
<view class="grid-card grid-card-primary" @click="navigate('/pages/homework/index')">
|
||||||
<view class="grid-card-decor"></view>
|
<view class="grid-card-decor"></view>
|
||||||
<view class="grid-card-icon">
|
<view class="grid-card-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none">
|
<text class="icon-emoji">📝</text>
|
||||||
<path d="M4 4h12l4 4v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
|
|
||||||
stroke="#fff" stroke-width="1.8" stroke-linejoin="round" />
|
|
||||||
<path d="M16 4v4h4M7 13h8M7 17h6" stroke="#fff" stroke-width="1.8" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="grid-card-info">
|
<view class="grid-card-info">
|
||||||
<view class="grid-card-title">我的作业</view>
|
<view class="grid-card-title">我的作业</view>
|
||||||
@ -98,10 +91,7 @@
|
|||||||
<view class="grid-card grid-card-cta" @click="navigate('/pages/homework/history/index')">
|
<view class="grid-card grid-card-cta" @click="navigate('/pages/homework/history/index')">
|
||||||
<view class="grid-card-decor"></view>
|
<view class="grid-card-decor"></view>
|
||||||
<view class="grid-card-icon">
|
<view class="grid-card-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none">
|
<text class="icon-emoji">📚</text>
|
||||||
<circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="1.8" />
|
|
||||||
<path d="M12 7v5l3 2" stroke="#fff" stroke-width="1.8" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="grid-card-info">
|
<view class="grid-card-info">
|
||||||
<view class="grid-card-title">作业记录</view>
|
<view class="grid-card-title">作业记录</view>
|
||||||
@ -117,11 +107,12 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="tools">
|
<view class="tools">
|
||||||
<view class="tool-item" v-for="t in tools" :key="t.id" @click="onToolClick(t)">
|
<view class="tool-item" :class="{ disabled: !t.enabled }" v-for="t in tools" :key="t.id" @click="onToolClick(t)">
|
||||||
<view class="tool-icon" :style="{ background: t.color }">
|
<view class="tool-icon" :style="{ background: t.enabled ? t.color : '#e2e8f0' }">
|
||||||
<text class="tool-icon-emoji" v-if="t.emoji">{{ t.emoji }}</text>
|
<text class="tool-icon-emoji" v-if="t.emoji">{{ t.emoji }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-name">{{ t.name }}</text>
|
<text class="tool-name">{{ t.name }}</text>
|
||||||
|
<text v-if="!t.enabled" class="tool-badge">开发中</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -163,13 +154,14 @@ const greeting = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
{ id: 'speak', name: '口算练习', emoji: '🧮', color: 'linear-gradient(135deg,#fde68a 0%,#f59e0b 100%)' },
|
{ id: 'speak', name: '口算练习', emoji: '🧮', color: 'linear-gradient(135deg,#fde68a 0%,#f59e0b 100%)', enabled: false },
|
||||||
{ id: 'word', name: '英语单词', emoji: '🅰️', color: 'linear-gradient(135deg,#bfdbfe 0%,#3b82f6 100%)' },
|
{ id: 'word', name: '英语单词', emoji: '🅰️', color: 'linear-gradient(135deg,#bfdbfe 0%,#3b82f6 100%)', enabled: false },
|
||||||
{ id: 'wrong', name: '错题本', emoji: '📕', color: 'linear-gradient(135deg,#fecaca 0%,#ef4444 100%)' },
|
{ id: 'wrong', name: '错题本', emoji: '📕', color: 'linear-gradient(135deg,#fecaca 0%,#ef4444 100%)', enabled: true },
|
||||||
{ id: 'rank', name: '排行榜', emoji: '🏆', color: 'linear-gradient(135deg,#bbf7d0 0%,#22c55e 100%)' },
|
{ id: 'rank', name: '排行榜', emoji: '🏆', color: 'linear-gradient(135deg,#bbf7d0 0%,#22c55e 100%)', enabled: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const onToolClick = (t: any) => {
|
const onToolClick = (t: any) => {
|
||||||
|
if (!t?.enabled) return;
|
||||||
if (t.id === 'wrong') {
|
if (t.id === 'wrong') {
|
||||||
navigate('/pages/wrong/index');
|
navigate('/pages/wrong/index');
|
||||||
return;
|
return;
|
||||||
@ -500,9 +492,8 @@ onShow(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
svg {
|
.icon-emoji {
|
||||||
width: 56rpx;
|
font-size: 46rpx;
|
||||||
height: 56rpx;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -608,9 +599,8 @@ onShow(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
backdrop-filter: blur(8rpx);
|
backdrop-filter: blur(8rpx);
|
||||||
|
|
||||||
svg {
|
.icon-emoji {
|
||||||
width: 44rpx;
|
font-size: 42rpx;
|
||||||
height: 44rpx;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -664,6 +654,7 @@ onShow(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-item {
|
.tool-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -674,6 +665,10 @@ onShow(() => {
|
|||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-icon {
|
.tool-icon {
|
||||||
@ -698,6 +693,12 @@ onShow(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-badge {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
/* 退出 */
|
/* 退出 */
|
||||||
.logout {
|
.logout {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@ -13,8 +13,8 @@
|
|||||||
<view class="brand-logo-glow"></view>
|
<view class="brand-logo-glow"></view>
|
||||||
<image :src="logoUrl" mode="aspectFit" class="brand-logo-img" />
|
<image :src="logoUrl" mode="aspectFit" class="brand-logo-img" />
|
||||||
</view>
|
</view>
|
||||||
<view class="brand-name">学小乐<text class="brand-name-ai">AI</text></view>
|
<view class="brand-name">灵犀学<text class="brand-name-ai">AI</text></view>
|
||||||
<view class="brand-slogan">智能学习助手 · 让学习更轻松1</view>
|
<view class="brand-slogan">智能学习助手 · 让学习更轻松</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 角色切换 -->
|
<!-- 角色切换 -->
|
||||||
@ -139,12 +139,12 @@
|
|||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="agreement">
|
<!-- <view class="agreement">
|
||||||
<text>登录即表示同意</text>
|
<text>登录即表示同意</text>
|
||||||
<text class="link">《用户协议》</text>
|
<text class="link" @click="openAgreement('user')">《用户协议》</text>
|
||||||
<text>与</text>
|
<text>与</text>
|
||||||
<text class="link">《隐私政策》</text>
|
<text class="link" @click="openAgreement('privacy')">《隐私政策》</text>
|
||||||
</view>
|
</view> -->
|
||||||
|
|
||||||
<!-- 登录按钮 -->
|
<!-- 登录按钮 -->
|
||||||
<block v-if="isParentLogin && parentStage === 'authorize'">
|
<block v-if="isParentLogin && parentStage === 'authorize'">
|
||||||
@ -187,7 +187,7 @@ import { setCache, getCache } from '@/utils/cache';
|
|||||||
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
|
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
|
||||||
|
|
||||||
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
|
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
|
||||||
const logoUrl = `${OSS_URL}/urm/logo.png`;
|
const logoUrl = `https://lxx-web-1313840333.cos.ap-chengdu.myqcloud.com/urm/mode.png`;
|
||||||
|
|
||||||
const PHONE_REG = /^1[3-9]\d{9}$/;
|
const PHONE_REG = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
@ -294,6 +294,13 @@ const onLogoTap = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAgreement = (type: 'user' | 'privacy') => {
|
||||||
|
const url = type === 'user'
|
||||||
|
? '/pages/agreement/user/index'
|
||||||
|
: '/pages/agreement/privacy/index';
|
||||||
|
uni.navigateTo({ url });
|
||||||
|
};
|
||||||
|
|
||||||
const getLoginTargetUrl = () => {
|
const getLoginTargetUrl = () => {
|
||||||
const teacherHome = '/pages/teacher/index/index';
|
const teacherHome = '/pages/teacher/index/index';
|
||||||
const parentHome = '/pages/parent/home/index';
|
const parentHome = '/pages/parent/home/index';
|
||||||
@ -760,6 +767,7 @@ const onLogin = async () => {
|
|||||||
|
|
||||||
.link {
|
.link {
|
||||||
color: #4f46e5;
|
color: #4f46e5;
|
||||||
|
padding: 0 6rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,8 +128,8 @@ const onBindSuccess = async () => {
|
|||||||
|
|
||||||
const showAbout = () => {
|
const showAbout = () => {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '关于学小乐AI',
|
title: '关于灵犀学AI',
|
||||||
content: '学小乐AI为学生、老师和家长提供学习过程分析与成长陪伴。',
|
content: '灵犀学AI为学生、老师和家长提供学习过程分析与成长陪伴。',
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
<image class="avatar" :src="avatarUrl" mode="aspectFill" />
|
<image class="avatar" :src="avatarUrl" mode="aspectFill" />
|
||||||
<view class="profile-main">
|
<view class="profile-main">
|
||||||
<text class="hello">您好,{{ teacherName }}</text>
|
<text class="hello">您好,{{ teacherName }}</text>
|
||||||
<text class="school ellipsis">{{ teacherInfo.schoolName || '欢迎使用学小乐AI教师端' }}</text>
|
<text class="school ellipsis">{{ teacherInfo.schoolName || '欢迎使用灵犀学AI教师端' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="logout-btn" @click="handleLogout">退出</view>
|
<view class="logout-btn" @click="handleLogout">退出</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@ -16,14 +16,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="!pageLoading" class="content">
|
<view v-if="!pageLoading && !loadError" class="content">
|
||||||
<view class="subject-grid">
|
<view class="subject-grid">
|
||||||
<view
|
<view
|
||||||
v-for="subject in subjectStats"
|
v-for="subject in subjectStats"
|
||||||
:key="subject.name"
|
:key="subject.name"
|
||||||
class="subject-card"
|
class="subject-card"
|
||||||
:class="{ active: selectedSubject === subject.name }"
|
@click="openSubject(subject)"
|
||||||
@click="selectSubject(subject.name)"
|
|
||||||
>
|
>
|
||||||
<view class="subject-icon" :class="subject.tone">
|
<view class="subject-icon" :class="subject.tone">
|
||||||
<text>{{ subject.shortName }}</text>
|
<text>{{ subject.shortName }}</text>
|
||||||
@ -48,136 +47,18 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="tabs">
|
|
||||||
<view
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
class="tab"
|
|
||||||
:class="{ active: activeTab === tab.key }"
|
|
||||||
@click="activeTab = tab.key"
|
|
||||||
>
|
|
||||||
<text>{{ tab.label }}</text>
|
|
||||||
<text class="tab-count">{{ getTabCount(tab.key) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="filter-row">
|
|
||||||
<picker class="filter-picker" :range="gradeOptions" :value="gradeIndex" @change="onGradeChange">
|
|
||||||
<view class="filter-pill">
|
|
||||||
<text>{{ gradeOptions[gradeIndex] }}</text>
|
|
||||||
<view class="filter-arrow"></view>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
<picker class="filter-picker" :range="typeOptions" :value="typeIndex" @change="onTypeChange">
|
|
||||||
<view class="filter-pill">
|
|
||||||
<text>{{ typeOptions[typeIndex] }}</text>
|
|
||||||
<view class="filter-arrow"></view>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
<picker class="filter-picker" :range="examOptions" :value="examIndex" @change="onExamChange">
|
|
||||||
<view class="filter-pill">
|
|
||||||
<text>{{ examOptions[examIndex] }}</text>
|
|
||||||
<view class="filter-arrow"></view>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="filteredQuestions.length" class="question-list">
|
|
||||||
<view v-for="item in filteredQuestions" :key="item.id" class="question-card">
|
|
||||||
<view class="question-head">
|
|
||||||
<view class="question-index">
|
|
||||||
<text>第{{ item.number }}题</text>
|
|
||||||
<text>满分{{ item.score }}分</text>
|
|
||||||
</view>
|
|
||||||
<view class="status-tag" :class="item.status">{{ getStatusText(item.status) }}</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="exam-name">{{ item.examName }}</view>
|
|
||||||
<view class="question-title">
|
|
||||||
<rich-text :nodes="item.title" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="item.options.length" class="option-list">
|
|
||||||
<view v-for="option in item.options" :key="option.key" class="option-item">
|
|
||||||
<text class="option-key">{{ option.key }}</text>
|
|
||||||
<view class="option-value">
|
|
||||||
<rich-text :nodes="option.value" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="answer-tabs">
|
|
||||||
<view
|
|
||||||
class="answer-tab"
|
|
||||||
:class="{ active: item.openPanel === 'mine' }"
|
|
||||||
@click="item.openPanel = 'mine'"
|
|
||||||
>
|
|
||||||
我的作答
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
class="answer-tab"
|
|
||||||
:class="{ active: item.openPanel === 'analysis' }"
|
|
||||||
@click="item.openPanel = 'analysis'"
|
|
||||||
>
|
|
||||||
答案&解析
|
|
||||||
</view>
|
|
||||||
<view class="answer-tab action" @click="toggleReview(item)">
|
|
||||||
{{ item.inReviewBook ? '移出复习本' : '加入复习本' }}
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="item.openPanel === 'mine'" class="info-box mine">
|
|
||||||
<view class="info-title">我的作答</view>
|
|
||||||
<view class="info-content">{{ item.myAnswer || '未作答' }}</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<view class="info-box correct">
|
|
||||||
<view class="info-title">正确答案</view>
|
|
||||||
<view class="info-content">{{ item.answer || '暂无答案' }}</view>
|
|
||||||
</view>
|
|
||||||
<view v-if="item.analysis" class="info-box analysis">
|
|
||||||
<view class="info-title">试题解析</view>
|
|
||||||
<view class="info-content">
|
|
||||||
<rich-text :nodes="item.analysis" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view v-if="item.knowledge" class="info-box knowledge">
|
|
||||||
<view class="info-title">知识点</view>
|
|
||||||
<view class="info-content">{{ item.knowledge }}</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<view class="card-actions">
|
|
||||||
<view class="mark-btn" @click="markMastered(item)">
|
|
||||||
{{ item.status === 'mastered' ? '标为未掌握' : '标为已掌握' }}
|
|
||||||
</view>
|
|
||||||
<view class="practice-btn" @click="practiceAgain(item)">再练一次</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-else class="empty-wrap">
|
|
||||||
<xy-Empty type="learn" content="当前筛选下暂无错题" />
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="loading-wrap">
|
<view v-else class="loading-wrap">
|
||||||
<xy-Empty type="loading" content="正在整理错题..." />
|
<xy-Empty type="loading" content="正在整理错题..." />
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="!pageLoading && loadError" class="error-wrap">
|
||||||
|
<xy-Empty type="network" :content="loadError" />
|
||||||
|
<view class="retry-btn" @click="loadSummary">点击重试</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="safe-bottom"></view>
|
<view class="safe-bottom"></view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- <view class="home-float" @click="goHome">
|
|
||||||
<view class="home-roof"></view>
|
|
||||||
<view class="home-body"></view>
|
|
||||||
</view> -->
|
|
||||||
|
|
||||||
<view v-if="!pageLoading && filteredQuestions.length" class="bottom-bar">
|
|
||||||
<view class="download-btn" @click="downloadWrongQuestions">一键下载错题</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -185,99 +66,32 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { getWrongQuestionSummary, type WrongQuestionSummaryResult } from '@/api/parent';
|
import type { WrongQuestionSummaryResult } from '@/api/parent';
|
||||||
import { getListCompleteRecord, getPaperRecordDetail } from '../../api/homework';
|
|
||||||
import { resource_type_homework } from '@/constants/doWork';
|
|
||||||
import { useParentStore } from '@/state/modules/parent';
|
import { useParentStore } from '@/state/modules/parent';
|
||||||
import { useUserStore } from '@/state/modules/user';
|
import { useUserStore } from '@/state/modules/user';
|
||||||
|
import { buildSubjectStatRow, fetchWrongSummary } from './shared';
|
||||||
type WrongStatus = 'new' | 'review' | 'mastered';
|
|
||||||
type TabKey = 'all' | 'new' | 'mastered';
|
|
||||||
|
|
||||||
interface WrongOption {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WrongQuestion {
|
|
||||||
id: string;
|
|
||||||
recordId: string;
|
|
||||||
subject: string;
|
|
||||||
grade: string;
|
|
||||||
type: string;
|
|
||||||
examName: string;
|
|
||||||
number: number;
|
|
||||||
score: number;
|
|
||||||
title: string;
|
|
||||||
options: WrongOption[];
|
|
||||||
myAnswer: string;
|
|
||||||
answer: string;
|
|
||||||
analysis: string;
|
|
||||||
knowledge: string;
|
|
||||||
status: WrongStatus;
|
|
||||||
inReviewBook: boolean;
|
|
||||||
openPanel: 'mine' | 'analysis';
|
|
||||||
}
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const parentStore = useParentStore();
|
const parentStore = useParentStore();
|
||||||
const { userInfo } = storeToRefs(userStore);
|
const { userInfo } = storeToRefs(userStore);
|
||||||
const { activeChild } = storeToRefs(parentStore);
|
|
||||||
|
|
||||||
const pageLoading = ref(true);
|
const pageLoading = ref(true);
|
||||||
const summaryData = ref<WrongQuestionSummaryResult | null>(null);
|
const summaryData = ref<WrongQuestionSummaryResult | null>(null);
|
||||||
const wrongQuestions = ref<WrongQuestion[]>([]);
|
const loadError = ref('');
|
||||||
const selectedSubject = ref('全部');
|
const skipFirstOnShow = ref(true);
|
||||||
const activeTab = ref<TabKey>('all');
|
|
||||||
const gradeIndex = ref(0);
|
|
||||||
const typeIndex = ref(0);
|
|
||||||
const examIndex = ref(0);
|
|
||||||
|
|
||||||
const tabs: { key: TabKey; label: string }[] = [
|
|
||||||
{ key: 'all', label: '全部错题' },
|
|
||||||
{ key: 'new', label: '未掌握' },
|
|
||||||
{ key: 'mastered', label: '已掌握' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const subjectTones = ['blue', 'green', 'orange', 'rose', 'violet'];
|
|
||||||
|
|
||||||
const totalWrong = computed(() => {
|
const totalWrong = computed(() => {
|
||||||
const subjects = summaryData.value?.subjects || [];
|
const subjects = summaryData.value?.subjects || [];
|
||||||
if (subjects.length) {
|
if (subjects.length) {
|
||||||
return subjects.reduce((sum, item) => sum + (item.totalCount || 0), 0);
|
return subjects.reduce((sum, item) => sum + (item.totalCount || 0), 0);
|
||||||
}
|
}
|
||||||
return wrongQuestions.value.length;
|
return 0;
|
||||||
});
|
|
||||||
|
|
||||||
const buildSubjectStatRow = (
|
|
||||||
name: string,
|
|
||||||
stats: { total: number; newCount: number; reviewCount: number; masteredCount: number },
|
|
||||||
index: number,
|
|
||||||
) => ({
|
|
||||||
name,
|
|
||||||
shortName: name === '全部' ? '全' : name.slice(0, 1),
|
|
||||||
tone: subjectTones[index % subjectTones.length],
|
|
||||||
total: stats.total,
|
|
||||||
newCount: stats.newCount,
|
|
||||||
reviewCount: stats.reviewCount,
|
|
||||||
masteredCount: stats.masteredCount,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const subjectStats = computed(() => {
|
const subjectStats = computed(() => {
|
||||||
const apiSubjects = summaryData.value?.subjects || [];
|
const apiSubjects = summaryData.value?.subjects || [];
|
||||||
if (apiSubjects.length) {
|
return apiSubjects.map((item, index) => ({
|
||||||
const allStats = apiSubjects.reduce<{ total: number; newCount: number; reviewCount: number; masteredCount: number }>(
|
...buildSubjectStatRow(
|
||||||
(acc, item) => ({
|
|
||||||
total: acc.total + (item.totalCount || 0),
|
|
||||||
newCount: acc.newCount + (item.newCount || 0),
|
|
||||||
reviewCount: acc.reviewCount + (item.reviewCount || 0),
|
|
||||||
masteredCount: acc.masteredCount + (item.masteredCount || 0),
|
|
||||||
}),
|
|
||||||
{ total: 0, newCount: 0, reviewCount: 0, masteredCount: 0 },
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
buildSubjectStatRow('全部', allStats, 0),
|
|
||||||
...apiSubjects.map((item, index) => buildSubjectStatRow(
|
|
||||||
item.subjectName || '其他',
|
item.subjectName || '其他',
|
||||||
{
|
{
|
||||||
total: item.totalCount || 0,
|
total: item.totalCount || 0,
|
||||||
@ -285,240 +99,46 @@ const subjectStats = computed(() => {
|
|||||||
reviewCount: item.reviewCount || 0,
|
reviewCount: item.reviewCount || 0,
|
||||||
masteredCount: item.masteredCount || 0,
|
masteredCount: item.masteredCount || 0,
|
||||||
},
|
},
|
||||||
index + 1,
|
index,
|
||||||
)),
|
),
|
||||||
];
|
subjectId: item.subjectId,
|
||||||
}
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
const subjects = ['全部', ...Array.from(new Set(wrongQuestions.value.map((item) => item.subject).filter(Boolean)))];
|
const openSubject = (subject: { name: string; subjectId?: number }) => {
|
||||||
return subjects.map((name, index) => {
|
const query = [`subject=${encodeURIComponent(subject.name)}`];
|
||||||
const list = name === '全部' ? wrongQuestions.value : wrongQuestions.value.filter((item) => item.subject === name);
|
if (subject.subjectId != null) {
|
||||||
return buildSubjectStatRow(name, {
|
query.push(`subjectId=${subject.subjectId}`);
|
||||||
total: list.length,
|
}
|
||||||
newCount: list.filter((item) => item.status === 'new').length,
|
const userId = parentStore.activeChild.id || parentStore.activeChildId;
|
||||||
reviewCount: list.filter((item) => item.inReviewBook).length,
|
if (userId) {
|
||||||
masteredCount: list.filter((item) => item.status === 'mastered').length,
|
query.push(`userId=${userId}`);
|
||||||
}, index);
|
}
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/wrong/list?${query.join('&')}`,
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const scopedQuestions = computed(() => {
|
|
||||||
let list = wrongQuestions.value;
|
|
||||||
if (selectedSubject.value !== '全部') {
|
|
||||||
list = list.filter((item) => item.subject === selectedSubject.value);
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
});
|
|
||||||
|
|
||||||
const gradeOptions = computed(() => ['不限年级', ...Array.from(new Set(scopedQuestions.value.map((item) => item.grade).filter(Boolean)))]);
|
|
||||||
const typeOptions = computed(() => ['不限类型', ...Array.from(new Set(scopedQuestions.value.map((item) => item.type).filter(Boolean)))]);
|
|
||||||
const examOptions = computed(() => ['选择考试', ...Array.from(new Set(scopedQuestions.value.map((item) => item.examName).filter(Boolean)))]);
|
|
||||||
|
|
||||||
const filteredQuestions = computed(() => {
|
|
||||||
const grade = gradeOptions.value[gradeIndex.value];
|
|
||||||
const type = typeOptions.value[typeIndex.value];
|
|
||||||
const exam = examOptions.value[examIndex.value];
|
|
||||||
return scopedQuestions.value.filter((item) => {
|
|
||||||
const tabMatched = activeTab.value === 'all'
|
|
||||||
|| (activeTab.value === 'new' && item.status !== 'mastered')
|
|
||||||
|| (activeTab.value === 'mastered' && item.status === 'mastered');
|
|
||||||
const gradeMatched = !grade || grade === '不限年级' || item.grade === grade;
|
|
||||||
const typeMatched = !type || type === '不限类型' || item.type === type;
|
|
||||||
const examMatched = !exam || exam === '选择考试' || item.examName === exam;
|
|
||||||
return tabMatched && gradeMatched && typeMatched && examMatched;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const getTabCount = (key: TabKey) => {
|
|
||||||
if (key === 'all') return scopedQuestions.value.length;
|
|
||||||
if (key === 'new') return scopedQuestions.value.filter((item) => item.status !== 'mastered').length;
|
|
||||||
return scopedQuestions.value.filter((item) => item.status === 'mastered').length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectSubject = (name: string) => {
|
|
||||||
selectedSubject.value = name;
|
|
||||||
gradeIndex.value = 0;
|
|
||||||
typeIndex.value = 0;
|
|
||||||
examIndex.value = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); };
|
|
||||||
const onTypeChange = (e: any) => { typeIndex.value = Number(e.detail.value); };
|
|
||||||
const onExamChange = (e: any) => { examIndex.value = Number(e.detail.value); };
|
|
||||||
|
|
||||||
const normalizeHtml = (html?: string) => {
|
|
||||||
if (!html) return '';
|
|
||||||
return String(html)
|
|
||||||
.replace(/http:\/\//gi, 'https://')
|
|
||||||
.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAnswer = (value: any) => {
|
|
||||||
if (Array.isArray(value)) return value.join('、');
|
|
||||||
if (value == null) return '';
|
|
||||||
return String(value).replace(/^\[|\]$/g, '').replace(/"/g, '').replace(/,/g, '、');
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseOptions = (raw: any): WrongOption[] => {
|
|
||||||
let list = raw;
|
|
||||||
if (typeof list === 'string') {
|
|
||||||
try {
|
|
||||||
list = JSON.parse(list);
|
|
||||||
} catch {
|
|
||||||
list = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list.map((item, index) => ({
|
|
||||||
key: typeof item === 'string' ? String.fromCharCode(65 + index) : (item?.key || item?.optionKey || item?.label || String.fromCharCode(65 + index)),
|
|
||||||
value: normalizeHtml(typeof item === 'string' ? item : (item?.value || item?.optionValue || '')),
|
|
||||||
})).filter((item) => item.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGradeText = (record: any, item: any) => {
|
|
||||||
return item.gradeName || item.grade || record.gradeName || record.grade || '未分年级';
|
|
||||||
};
|
|
||||||
|
|
||||||
const isWrongQuestion = (item: any) => {
|
|
||||||
const hasAnswer = Boolean(item.submitAnswer || item.submitAnswerPic);
|
|
||||||
const correct = item.correctResult === 1 || item.correctResult === true || item.markFlag === 1 || item.markFlag === true;
|
|
||||||
return hasAnswer && !correct;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapQuestion = (record: any, item: any, index: number): WrongQuestion => ({
|
|
||||||
id: String(item.itemId || item.id || `${record.id}-${index}`),
|
|
||||||
recordId: String(record.id || record.recordId || ''),
|
|
||||||
subject: item.subjectName || record.subjectName || '其他',
|
|
||||||
grade: getGradeText(record, item),
|
|
||||||
type: item.titleChannelTypeName || item.chanelTypeName || '综合题',
|
|
||||||
examName: record.paperName || record.jobName || item.paperName || '未命名考试',
|
|
||||||
number: Number(item.titleSort || item.sort || index + 1),
|
|
||||||
score: Number(item.titleScore || item.score || 0),
|
|
||||||
title: normalizeHtml(item.titleMu || item.title || item.pidTitle || '暂无题干'),
|
|
||||||
options: parseOptions(item.optionsMu ?? item.options),
|
|
||||||
myAnswer: normalizeAnswer(item.submitAnswer || item.submitAnswerPic),
|
|
||||||
answer: normalizeAnswer(item.answerMu || item.answer),
|
|
||||||
analysis: normalizeHtml(item.titleAnalyzeMu || item.titleAnalyze || item.aiAnalyze),
|
|
||||||
knowledge: item.knowledgeName || item.knowledge || item.knowledgePointName || '待归类',
|
|
||||||
status: 'new',
|
|
||||||
inReviewBook: false,
|
|
||||||
openPanel: 'analysis',
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolveUserId = async () => {
|
|
||||||
try {
|
|
||||||
if (!userInfo.value.id) {
|
|
||||||
await userStore.fetchParentUserInfo();
|
|
||||||
}
|
|
||||||
if (userInfo.value.id) {
|
|
||||||
await parentStore.fetchBindChildren(userInfo.value.id);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeChild.value.id) {
|
|
||||||
return activeChild.value.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!userInfo.value.id) {
|
|
||||||
await userStore.fetchUserInfo();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
return userInfo.value.id || '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
const userId = await resolveUserId();
|
if (pageLoading.value) return;
|
||||||
if (!userId) {
|
|
||||||
summaryData.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await getWrongQuestionSummary({ userId });
|
|
||||||
summaryData.value = res.data || null;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[wrong] summary err', err);
|
|
||||||
summaryData.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadWrongQuestions = async () => {
|
|
||||||
pageLoading.value = true;
|
pageLoading.value = true;
|
||||||
|
loadError.value = '';
|
||||||
try {
|
try {
|
||||||
await loadSummary();
|
summaryData.value = await fetchWrongSummary();
|
||||||
const recordsRes: any = await getListCompleteRecord();
|
|
||||||
const records = Array.isArray(recordsRes?.data) ? recordsRes.data : Array.isArray(recordsRes) ? recordsRes : [];
|
|
||||||
const detailRecords = await Promise.all(
|
|
||||||
records.slice(0, 20).map(async (record: any) => {
|
|
||||||
try {
|
|
||||||
const detailRes: any = await getPaperRecordDetail({
|
|
||||||
recordId: record.id || record.recordId,
|
|
||||||
reportFlag: 1,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
record,
|
|
||||||
questions: detailRes?.data?.titleVoList || detailRes?.data?.subjectTitleVoList || [],
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[wrong] detail err', err);
|
console.error('[wrong/index] summary load failed', err);
|
||||||
return { record, questions: [] };
|
summaryData.value = null;
|
||||||
}
|
loadError.value = '错题数据加载失败,请检查网络后重试';
|
||||||
}),
|
|
||||||
);
|
|
||||||
wrongQuestions.value = detailRecords.reduce<WrongQuestion[]>((list, { record, questions }) => {
|
|
||||||
const current = questions
|
|
||||||
.filter(isWrongQuestion)
|
|
||||||
.map((item: any, index: number) => mapQuestion(record, item, index));
|
|
||||||
return list.concat(current);
|
|
||||||
}, []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[wrong] load err', err);
|
|
||||||
uni.showToast({ title: '错题加载失败', icon: 'none' });
|
|
||||||
wrongQuestions.value = [];
|
|
||||||
} finally {
|
} finally {
|
||||||
pageLoading.value = false;
|
pageLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: WrongStatus) => {
|
|
||||||
if (status === 'mastered') return '已掌握';
|
|
||||||
if (status === 'review') return '复习中';
|
|
||||||
return '未掌握';
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleReview = (item: WrongQuestion) => {
|
|
||||||
item.inReviewBook = !item.inReviewBook;
|
|
||||||
if (item.inReviewBook && item.status === 'new') item.status = 'review';
|
|
||||||
if (!item.inReviewBook && item.status === 'review') item.status = 'new';
|
|
||||||
uni.showToast({ title: item.inReviewBook ? '已加入复习本' : '已移出复习本', icon: 'none' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const markMastered = (item: WrongQuestion) => {
|
|
||||||
item.status = item.status === 'mastered' ? 'new' : 'mastered';
|
|
||||||
if (item.status === 'mastered') item.inReviewBook = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const practiceAgain = (item: WrongQuestion) => {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/doWork/index?id=${item.recordId}&recordId=${item.recordId}&reportFlag=1&resourceType=${resource_type_homework}&idx=${Math.max(item.number - 1, 0)}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadWrongQuestions = () => {
|
|
||||||
uni.showToast({ title: '下载功能对接中', icon: 'none' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const goHome = () => {
|
|
||||||
uni.reLaunch({ url: '/pages/index/index' });
|
|
||||||
};
|
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
|
if (skipFirstOnShow.value) {
|
||||||
|
skipFirstOnShow.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!userInfo.value.id) {
|
if (!userInfo.value.id) {
|
||||||
await userStore.fetchParentUserInfo();
|
await userStore.fetchParentUserInfo();
|
||||||
@ -533,7 +153,8 @@ onShow(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
loadWrongQuestions();
|
pageLoading.value = false;
|
||||||
|
loadSummary();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -654,18 +275,13 @@ onLoad(() => {
|
|||||||
border-radius: 32rpx;
|
border-radius: 32rpx;
|
||||||
background: rgba(255, 255, 255, 0.94);
|
background: rgba(255, 255, 255, 0.94);
|
||||||
box-shadow: 0 14rpx 28rpx rgba(8, 145, 178, 0.08);
|
box-shadow: 0 14rpx 28rpx rgba(8, 145, 178, 0.08);
|
||||||
transition: transform 160ms ease, border-color 160ms ease;
|
transition: transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-card:active {
|
.subject-card:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-card.active {
|
|
||||||
border-color: #22d3ee;
|
|
||||||
box-shadow: 0 18rpx 34rpx rgba(8, 145, 178, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subject-icon {
|
.subject-icon {
|
||||||
grid-row: span 2;
|
grid-row: span 2;
|
||||||
width: 96rpx;
|
width: 96rpx;
|
||||||
@ -730,354 +346,35 @@ onLoad(() => {
|
|||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 14rpx;
|
|
||||||
margin-top: 28rpx;
|
|
||||||
padding: 8rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
background: rgba(226, 232, 240, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
flex: 1;
|
|
||||||
height: 66rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
color: #ffffff;
|
|
||||||
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
|
|
||||||
box-shadow: 0 10rpx 18rpx rgba(8, 145, 178, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-count {
|
|
||||||
min-width: 32rpx;
|
|
||||||
height: 32rpx;
|
|
||||||
padding: 0 8rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: inherit;
|
|
||||||
background: rgba(255, 255, 255, 0.24);
|
|
||||||
font-size: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 14rpx;
|
|
||||||
margin-top: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-picker {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill {
|
|
||||||
height: 70rpx;
|
|
||||||
padding: 0 16rpx;
|
|
||||||
border: 2rpx solid #e2e8f0;
|
|
||||||
border-radius: 22rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
color: #334155;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill text {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-arrow {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 7rpx solid transparent;
|
|
||||||
border-right: 7rpx solid transparent;
|
|
||||||
border-top: 9rpx solid #0891b2;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24rpx;
|
|
||||||
margin-top: 26rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-card {
|
|
||||||
padding: 28rpx;
|
|
||||||
border-radius: 34rpx;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-index {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-index text:first-child {
|
|
||||||
color: #f97316;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-index text:last-child {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag {
|
|
||||||
padding: 8rpx 18rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.new {
|
|
||||||
color: #b45309;
|
|
||||||
background: #fef3c7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.review {
|
|
||||||
color: #0369a1;
|
|
||||||
background: #e0f2fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.mastered {
|
|
||||||
color: #15803d;
|
|
||||||
background: #dcfce7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exam-name {
|
|
||||||
margin-top: 18rpx;
|
|
||||||
color: #164e63;
|
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 900;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-title {
|
|
||||||
margin-top: 20rpx;
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-title :deep(image),
|
|
||||||
.option-value :deep(image),
|
|
||||||
.info-content :deep(image) {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-list {
|
|
||||||
margin-top: 16rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12rpx;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 25rpx;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-key {
|
|
||||||
color: #0f172a;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-value {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-tabs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
margin-top: 24rpx;
|
|
||||||
border-top: 2rpx solid #f1f5f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-tab {
|
|
||||||
min-height: 76rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-tab.active {
|
|
||||||
color: #0891b2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-tab.action {
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
padding: 20rpx;
|
|
||||||
border: 2rpx solid transparent;
|
|
||||||
border-radius: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box.mine {
|
|
||||||
border-color: #fde68a;
|
|
||||||
background: #fffbeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box.correct {
|
|
||||||
border-color: #bbf7d0;
|
|
||||||
background: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box.analysis {
|
|
||||||
border-color: #bae6fd;
|
|
||||||
background: #f0f9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box.knowledge {
|
|
||||||
border-color: #fde68a;
|
|
||||||
background: #fffbeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-title {
|
|
||||||
margin-bottom: 10rpx;
|
|
||||||
color: #164e63;
|
|
||||||
font-size: 23rpx;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-content {
|
|
||||||
color: #334155;
|
|
||||||
font-size: 25rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-top: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark-btn,
|
|
||||||
.practice-btn {
|
|
||||||
flex: 1;
|
|
||||||
height: 72rpx;
|
|
||||||
border-radius: 22rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark-btn {
|
|
||||||
color: #0891b2;
|
|
||||||
background: #ecfeff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.practice-btn {
|
|
||||||
color: #ffffff;
|
|
||||||
background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%);
|
|
||||||
box-shadow: 0 10rpx 18rpx rgba(22, 163, 74, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-wrap,
|
|
||||||
.loading-wrap {
|
.loading-wrap {
|
||||||
height: 560rpx;
|
height: 560rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-float {
|
.error-wrap {
|
||||||
position: fixed;
|
height: 560rpx;
|
||||||
right: 32rpx;
|
|
||||||
bottom: 144rpx;
|
|
||||||
z-index: 10;
|
|
||||||
width: 88rpx;
|
|
||||||
height: 88rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(180deg, #38bdf8, #0284c7);
|
gap: 20rpx;
|
||||||
box-shadow: 0 12rpx 24rpx rgba(2, 132, 199, 0.28);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-roof {
|
.retry-btn {
|
||||||
position: absolute;
|
min-width: 220rpx;
|
||||||
top: 27rpx;
|
height: 72rpx;
|
||||||
width: 32rpx;
|
padding: 0 32rpx;
|
||||||
height: 32rpx;
|
border-radius: 999rpx;
|
||||||
border-left: 5rpx solid #ffffff;
|
|
||||||
border-top: 5rpx solid #ffffff;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-body {
|
|
||||||
width: 34rpx;
|
|
||||||
height: 28rpx;
|
|
||||||
margin-top: 20rpx;
|
|
||||||
border: 5rpx solid #ffffff;
|
|
||||||
border-top: 0;
|
|
||||||
border-radius: 0 0 6rpx 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-bar {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 9;
|
|
||||||
padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom));
|
|
||||||
background: rgba(255, 255, 255, 0.94);
|
|
||||||
box-shadow: 0 -10rpx 24rpx rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn {
|
|
||||||
height: 84rpx;
|
|
||||||
border-radius: 22rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%);
|
font-size: 24rpx;
|
||||||
font-size: 28rpx;
|
font-weight: 700;
|
||||||
font-weight: 900;
|
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
|
||||||
|
box-shadow: 0 10rpx 18rpx rgba(8, 145, 178, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.safe-bottom {
|
.safe-bottom {
|
||||||
height: 150rpx;
|
height: 48rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
482
src/pages/wrong/list.vue
Normal file
482
src/pages/wrong/list.vue
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
<template>
|
||||||
|
<view class="wrong-list-page">
|
||||||
|
<scroll-view class="page-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||||
|
<view class="content">
|
||||||
|
<view class="filter-row">
|
||||||
|
<picker
|
||||||
|
class="filter-picker"
|
||||||
|
:range="timeRangeLabels"
|
||||||
|
:value="timeRangeIndex"
|
||||||
|
@change="onTimeRangeChange"
|
||||||
|
>
|
||||||
|
<view class="filter-pill">
|
||||||
|
<text>{{ timeRangeLabels[timeRangeIndex] }}</text>
|
||||||
|
<view class="filter-arrow"></view>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="listLoading" class="loading-wrap">
|
||||||
|
<xy-Empty type="loading" content="正在整理错题..." />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="wrongQuestions.length" class="question-list">
|
||||||
|
<view v-for="item in wrongQuestions" :key="item.id" class="question-card">
|
||||||
|
<view class="question-main" @click="toggleExpand(item)">
|
||||||
|
<view class="question-preview rich-content">
|
||||||
|
<rich-text :nodes="item.previewHtml" />
|
||||||
|
</view>
|
||||||
|
<view class="meta-row">
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">错误次数</text>
|
||||||
|
<text class="meta-value error-count">{{ item.errorCount ?? 0 }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">首次错误</text>
|
||||||
|
<text class="meta-value">{{ item.firstErrorTimeText }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">最近错误</text>
|
||||||
|
<text class="meta-value">{{ item.lastErrorTimeText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-head-actions">
|
||||||
|
<view class="expand-arrow" :class="{ expanded: item.expanded }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="item.expanded && item.questionTitle" class="expand-section">
|
||||||
|
<view v-if="item.questionTitle.typeName" class="expand-item">
|
||||||
|
<text class="expand-label">题型</text>
|
||||||
|
<text class="expand-value">{{ item.questionTitle.typeName }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.questionTitle.difficultyLevel" class="expand-item">
|
||||||
|
<text class="expand-label">难度</text>
|
||||||
|
<text class="expand-value">{{ item.questionTitle.difficultyLevel }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.questionTitle.score" class="expand-item">
|
||||||
|
<text class="expand-label">分值</text>
|
||||||
|
<text class="expand-value">{{ item.questionTitle.score }}分</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.questionTitle.submitAnswer" class="expand-item full">
|
||||||
|
<text class="expand-label">提交答案</text>
|
||||||
|
<text class="expand-value submit-answer">{{ item.questionTitle.submitAnswer }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.expandContent?.stemHtml" class="expand-item full">
|
||||||
|
<text class="expand-label">题干</text>
|
||||||
|
<view class="expand-value rich-content">
|
||||||
|
<rich-text :nodes="item.expandContent.stemHtml" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.expandContent?.parsedOptions?.length" class="expand-item full">
|
||||||
|
<text class="expand-label">选项</text>
|
||||||
|
<view class="expand-value rich-content">
|
||||||
|
<view
|
||||||
|
v-for="opt in item.expandContent.parsedOptions"
|
||||||
|
:key="opt.index"
|
||||||
|
class="option-item"
|
||||||
|
>
|
||||||
|
<text class="option-index">{{ opt.index }}.</text>
|
||||||
|
<view class="option-html">
|
||||||
|
<rich-text :nodes="opt.html" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else-if="item.expandContent?.optionHtmlFallback"
|
||||||
|
class="expand-item full"
|
||||||
|
>
|
||||||
|
<text class="expand-label">选项</text>
|
||||||
|
<view class="expand-value rich-content">
|
||||||
|
<rich-text :nodes="item.expandContent.optionHtmlFallback" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.expandContent?.answerHtml" class="expand-item full">
|
||||||
|
<text class="expand-label">正确答案</text>
|
||||||
|
<view class="expand-value answer-text rich-content">
|
||||||
|
<rich-text :nodes="item.expandContent.answerHtml" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.expandContent?.explanationHtml" class="expand-item full">
|
||||||
|
<text class="expand-label">解析</text>
|
||||||
|
<view class="expand-value rich-content">
|
||||||
|
<rich-text :nodes="item.expandContent.explanationHtml" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-wrap">
|
||||||
|
<xy-Empty type="learn" :content="emptyText" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loadingMore" class="load-more">加载中...</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="safe-bottom"></view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { onLoad } from '@dcloudio/uni-app';
|
||||||
|
import { useParentStore } from '@/state/modules/parent';
|
||||||
|
import {
|
||||||
|
TIME_RANGE_OPTIONS,
|
||||||
|
ensureExpandContent,
|
||||||
|
fetchWrongQuestionPage,
|
||||||
|
getTimeRange,
|
||||||
|
resolveWrongUserId,
|
||||||
|
type WrongQuestion,
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
const parentStore = useParentStore();
|
||||||
|
const listLoading = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const wrongQuestions = ref<WrongQuestion[]>([]);
|
||||||
|
const selectedSubject = ref('');
|
||||||
|
const selectedSubjectId = ref('');
|
||||||
|
const cachedUserId = ref('');
|
||||||
|
const timeRange = ref('');
|
||||||
|
const emptyText = ref('暂无错题');
|
||||||
|
|
||||||
|
const timeRangeLabels = TIME_RANGE_OPTIONS.map((item) => item.label);
|
||||||
|
const timeRangeIndex = computed(() => {
|
||||||
|
const idx = TIME_RANGE_OPTIONS.findIndex((item) => item.value === timeRange.value);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onTimeRangeChange = (e: any) => {
|
||||||
|
const index = Number(e.detail.value);
|
||||||
|
timeRange.value = TIME_RANGE_OPTIONS[index]?.value ?? '';
|
||||||
|
initList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (item: WrongQuestion) => {
|
||||||
|
if (!item.expanded) {
|
||||||
|
ensureExpandContent(item);
|
||||||
|
}
|
||||||
|
item.expanded = !item.expanded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserId = async () => {
|
||||||
|
if (cachedUserId.value) return cachedUserId.value;
|
||||||
|
const userId = parentStore.activeChild.id
|
||||||
|
|| parentStore.activeChildId
|
||||||
|
|| await resolveWrongUserId();
|
||||||
|
cachedUserId.value = userId;
|
||||||
|
return userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchQuestions = async (append = false) => {
|
||||||
|
const userId = await getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
wrongQuestions.value = [];
|
||||||
|
hasMore.value = false;
|
||||||
|
emptyText.value = '未找到孩子信息,请先绑定孩子';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = append ? currentPage.value : 1;
|
||||||
|
const { startTime, endTime } = getTimeRange(timeRange.value);
|
||||||
|
const { rows, totalRows } = await fetchWrongQuestionPage({
|
||||||
|
userId,
|
||||||
|
subjectId: selectedSubjectId.value || undefined,
|
||||||
|
current: page,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
const exists = new Set(wrongQuestions.value.map((item) => item.id));
|
||||||
|
wrongQuestions.value = wrongQuestions.value.concat(
|
||||||
|
rows.filter((item: WrongQuestion) => !exists.has(item.id)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
wrongQuestions.value = rows;
|
||||||
|
}
|
||||||
|
hasMore.value = wrongQuestions.value.length < totalRows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initList = async () => {
|
||||||
|
if (listLoading.value) return;
|
||||||
|
listLoading.value = true;
|
||||||
|
currentPage.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
try {
|
||||||
|
await fetchQuestions(false);
|
||||||
|
emptyText.value = wrongQuestions.value.length ? '' : '暂无错题';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[wrong/list] load failed', err);
|
||||||
|
wrongQuestions.value = [];
|
||||||
|
emptyText.value = '网络出问题了啦~';
|
||||||
|
hasMore.value = false;
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (listLoading.value || loadingMore.value || !hasMore.value) return;
|
||||||
|
loadingMore.value = true;
|
||||||
|
currentPage.value += 1;
|
||||||
|
try {
|
||||||
|
await fetchQuestions(true);
|
||||||
|
} catch {
|
||||||
|
currentPage.value -= 1;
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
const subject = options?.subject ? decodeURIComponent(options.subject) : '';
|
||||||
|
selectedSubject.value = subject;
|
||||||
|
selectedSubjectId.value = options?.subjectId ? String(options.subjectId) : '';
|
||||||
|
cachedUserId.value = options?.userId ? String(options.userId) : '';
|
||||||
|
const title = subject ? `${subject}错题` : '错题列表';
|
||||||
|
uni.setNavigationBarTitle({ title });
|
||||||
|
timeRange.value = '';
|
||||||
|
initList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.wrong-list-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 24rpx 28rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-picker {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill {
|
||||||
|
height: 70rpx;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border: 2rpx solid #e2e8f0;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
color: #334155;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-arrow {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 7rpx solid transparent;
|
||||||
|
border-right: 7rpx solid transparent;
|
||||||
|
border-top: 9rpx solid #0891b2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-card {
|
||||||
|
border-radius: 34rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-main {
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-preview {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.65;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-preview :deep(image),
|
||||||
|
.rich-content :deep(image),
|
||||||
|
.option-html :deep(image) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(.formula-text) {
|
||||||
|
font-family: 'Times New Roman', 'STIXGeneral', Georgia, serif;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(.formula-img) {
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
padding: 16rpx 12rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #f8fafc;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
display: block;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-count {
|
||||||
|
color: #e64340;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 10rpx solid transparent;
|
||||||
|
border-right: 10rpx solid transparent;
|
||||||
|
border-top: 12rpx solid #94a3b8;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-section {
|
||||||
|
padding: 0 28rpx 28rpx;
|
||||||
|
border-top: 2rpx solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-item.full {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 120rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-item.full .expand-label {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-answer {
|
||||||
|
color: #e64340;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-text {
|
||||||
|
color: #15803d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-index {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #0891b2;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-html {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-wrap,
|
||||||
|
.loading-wrap {
|
||||||
|
height: 560rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
padding: 24rpx 0 12rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-bottom {
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
250
src/pages/wrong/shared.ts
Normal file
250
src/pages/wrong/shared.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import {
|
||||||
|
getWrongQuestionFilterPage,
|
||||||
|
getWrongQuestionSummary,
|
||||||
|
type PageResult,
|
||||||
|
type QuestionTitle,
|
||||||
|
type WrongQuestionItem,
|
||||||
|
type WrongQuestionSummaryResult,
|
||||||
|
} from '@/api/parent';
|
||||||
|
import { formatWrongQuestionHtml } from '@/utils/questionHtml';
|
||||||
|
import { useParentStore } from '@/state/modules/parent';
|
||||||
|
import { useUserStore } from '@/state/modules/user';
|
||||||
|
|
||||||
|
export type { QuestionTitle, WrongQuestionItem };
|
||||||
|
|
||||||
|
export interface WrongOption {
|
||||||
|
index: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WrongQuestion extends WrongQuestionItem {
|
||||||
|
expanded: boolean;
|
||||||
|
previewHtml: string;
|
||||||
|
firstErrorTimeText: string;
|
||||||
|
lastErrorTimeText: string;
|
||||||
|
expandContent?: WrongExpandContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WrongExpandContent {
|
||||||
|
parsedOptions: WrongOption[] | null;
|
||||||
|
stemHtml: string;
|
||||||
|
optionHtmlFallback: string;
|
||||||
|
answerHtml: string;
|
||||||
|
explanationHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUBJECT_TONES = ['blue', 'green', 'orange', 'rose', 'violet'];
|
||||||
|
|
||||||
|
export const TIME_RANGE_OPTIONS = [
|
||||||
|
{ value: '', label: '全部时间' },
|
||||||
|
{ value: 'today', label: '今天' },
|
||||||
|
{ value: '7days', label: '近7天' },
|
||||||
|
{ value: '30days', label: '近30天' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const normalizeHtml = (html?: string, options?: { stripImages?: boolean }) =>
|
||||||
|
formatWrongQuestionHtml(html, options);
|
||||||
|
|
||||||
|
export const getPreviewHtml = (item: WrongQuestionItem): string => {
|
||||||
|
if (!item.questionTitle) return '--';
|
||||||
|
const html = item.questionTitle.stemHtml || item.questionTitle.stem || '';
|
||||||
|
if (!html) return '--';
|
||||||
|
return formatWrongQuestionHtml(html, { stripImages: true }) || '--';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseOptions = (optionHtml?: string): WrongOption[] | null => {
|
||||||
|
if (!optionHtml) return null;
|
||||||
|
try {
|
||||||
|
const parsed = typeof optionHtml === 'string' ? JSON.parse(optionHtml) : optionHtml;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item: any) => ({
|
||||||
|
index: item.index || '',
|
||||||
|
html: formatWrongQuestionHtml(item.html || ''),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not JSON, fallback to HTML string */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimeRange = (rangeKey: string): { startTime?: string; endTime?: string } => {
|
||||||
|
if (!rangeKey) return {};
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 23:59:59`;
|
||||||
|
let start: Date;
|
||||||
|
if (rangeKey === 'today') {
|
||||||
|
start = now;
|
||||||
|
} else if (rangeKey === '7days') {
|
||||||
|
start = new Date(now.getTime() - 6 * 86400000);
|
||||||
|
} else {
|
||||||
|
start = new Date(now.getTime() - 29 * 86400000);
|
||||||
|
}
|
||||||
|
const startTime = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')} 00:00:00`;
|
||||||
|
return { startTime, endTime };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatWrongTime = (time?: string) => {
|
||||||
|
if (!time) return '--';
|
||||||
|
return time.replace('T', ' ').slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreviewText = (item: WrongQuestionItem): string => {
|
||||||
|
const html = item.questionTitle?.stemHtml || item.questionTitle?.stem || '';
|
||||||
|
if (!html) return '--';
|
||||||
|
return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 120) || '--';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWrongQuestionShell = (item: WrongQuestionItem): WrongQuestion => ({
|
||||||
|
...item,
|
||||||
|
expanded: false,
|
||||||
|
previewHtml: getPreviewText(item),
|
||||||
|
firstErrorTimeText: formatWrongTime(item.firstErrorTime),
|
||||||
|
lastErrorTimeText: formatWrongTime(item.lastErrorTime),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hydrateWrongQuestionPreview = (item: WrongQuestion) => {
|
||||||
|
try {
|
||||||
|
item.previewHtml = getPreviewHtml(item);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[wrong] preview hydrate failed', err);
|
||||||
|
item.previewHtml = getPreviewText(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapWrongQuestionItem = (item: WrongQuestionItem): WrongQuestion => ({
|
||||||
|
...createWrongQuestionShell(item),
|
||||||
|
previewHtml: getPreviewHtml(item),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ensureExpandContent = (item: WrongQuestion) => {
|
||||||
|
if (item.expandContent || !item.questionTitle) return;
|
||||||
|
const qt = item.questionTitle;
|
||||||
|
try {
|
||||||
|
item.expandContent = {
|
||||||
|
parsedOptions: parseOptions(qt.optionHtml),
|
||||||
|
stemHtml: normalizeHtml(qt.stemHtml || qt.stem),
|
||||||
|
optionHtmlFallback: normalizeHtml(qt.optionHtml),
|
||||||
|
answerHtml: normalizeHtml(qt.answerHtml || qt.answer),
|
||||||
|
explanationHtml: normalizeHtml(qt.explanation),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[wrong] expand content failed', err);
|
||||||
|
item.expandContent = {
|
||||||
|
parsedOptions: null,
|
||||||
|
stemHtml: getPreviewText(item),
|
||||||
|
optionHtmlFallback: '',
|
||||||
|
answerHtml: String(qt.answer || qt.answerHtml || ''),
|
||||||
|
explanationHtml: String(qt.explanation || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeWrongQuestionPage = (
|
||||||
|
data: WrongQuestionItem[] | PageResult<WrongQuestionItem> | null | undefined,
|
||||||
|
page = 1,
|
||||||
|
) => {
|
||||||
|
if (!data) {
|
||||||
|
return { rows: [] as WrongQuestionItem[], totalRows: 0, current: page };
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return { rows: data, totalRows: data.length, current: page };
|
||||||
|
}
|
||||||
|
const rows = data.rows || data.records || [];
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
totalRows: data.totalRows ?? rows.length,
|
||||||
|
current: data.current ?? page,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WRONG_LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export const resolveWrongUserId = async () => {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const parentStore = useParentStore();
|
||||||
|
|
||||||
|
if (parentStore.activeChild.id) {
|
||||||
|
return parentStore.activeChild.id;
|
||||||
|
}
|
||||||
|
if (parentStore.activeChildId) {
|
||||||
|
return parentStore.activeChildId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!userStore.userInfo.id) {
|
||||||
|
await userStore.fetchParentUserInfo();
|
||||||
|
}
|
||||||
|
if (userStore.userInfo.id) {
|
||||||
|
await parentStore.fetchBindChildren(userStore.userInfo.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentStore.activeChild.id) {
|
||||||
|
return parentStore.activeChild.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!userStore.userInfo.id) {
|
||||||
|
await userStore.fetchUserInfo();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
return userStore.userInfo.id || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchWrongSummary = async (): Promise<WrongQuestionSummaryResult | null> => {
|
||||||
|
const userId = await resolveWrongUserId();
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getWrongQuestionSummary({ userId });
|
||||||
|
return res.data || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[wrong] summary err', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchWrongQuestionPage = async (params: {
|
||||||
|
userId?: string | number;
|
||||||
|
subjectId?: string | number;
|
||||||
|
current?: number;
|
||||||
|
size?: number;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}) => {
|
||||||
|
const current = params.current ?? 1;
|
||||||
|
const res = await getWrongQuestionFilterPage({
|
||||||
|
userId: params.userId,
|
||||||
|
subjectId: params.subjectId,
|
||||||
|
current,
|
||||||
|
size: params.size ?? WRONG_LIST_PAGE_SIZE,
|
||||||
|
startTime: params.startTime,
|
||||||
|
endTime: params.endTime,
|
||||||
|
});
|
||||||
|
const { rows, totalRows } = normalizeWrongQuestionPage(res.data, current);
|
||||||
|
return {
|
||||||
|
rows: rows.map((item: WrongQuestionItem) => mapWrongQuestionItem(item)),
|
||||||
|
totalRows,
|
||||||
|
current,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSubjectStatRow = (
|
||||||
|
name: string,
|
||||||
|
stats: { total: number; newCount: number; reviewCount: number; masteredCount: number },
|
||||||
|
index: number,
|
||||||
|
) => ({
|
||||||
|
name,
|
||||||
|
shortName: name === '全部' ? '全' : name.slice(0, 1),
|
||||||
|
tone: SUBJECT_TONES[index % SUBJECT_TONES.length],
|
||||||
|
total: stats.total,
|
||||||
|
newCount: stats.newCount,
|
||||||
|
reviewCount: stats.reviewCount,
|
||||||
|
masteredCount: stats.masteredCount,
|
||||||
|
});
|
||||||
@ -7,7 +7,7 @@ import { getCache } from '@/utils/cache';
|
|||||||
import { getStoredRole } from '@/utils/roleHome';
|
import { getStoredRole } from '@/utils/roleHome';
|
||||||
|
|
||||||
// 免登录白名单(路由 name)
|
// 免登录白名单(路由 name)
|
||||||
const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile'];
|
const whiteList = ['home', 'login'];
|
||||||
|
|
||||||
export function userRouternext(router: Router) {
|
export function userRouternext(router: Router) {
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* ==========================================
|
/* ==========================================
|
||||||
学小乐 AI · 全局样式
|
灵犀学 AI · 全局样式
|
||||||
设计风格: Claymorphism (软立体 / 童趣)
|
设计风格: Claymorphism (软立体 / 童趣)
|
||||||
主色: Indigo + Green
|
主色: Indigo + Green
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|||||||
@ -1,6 +1,157 @@
|
|||||||
/** 题目 HTML 处理(与学生端 doWork/Question 保持一致) */
|
/** 题目 HTML 处理(与学生端 doWork/Question 保持一致) */
|
||||||
|
|
||||||
const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?';
|
const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?';
|
||||||
|
const MAX_FORMAT_CACHE_SIZE = 200;
|
||||||
|
const formatCache = new Map<string, string>();
|
||||||
|
|
||||||
|
const escapeHtml = (text: string) =>
|
||||||
|
text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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 `<img class="formula-img" src="${svgUrl}" style="height:1.4em;vertical-align:middle;" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = decodeHtmlEntities(stripHtmlTags(innerHtml)).trim();
|
||||||
|
return renderFormulaContent(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMathTags = (html: string): string => {
|
||||||
|
if (!html || !/<math\b/i.test(html)) return html;
|
||||||
|
return html.replace(/<math\b[^>]*>[\s\S]*?<\/math>/gi, (tag) => {
|
||||||
|
const openTag = tag.match(/^<math\b[^>]*>/i)?.[0] || '';
|
||||||
|
const innerHtml = tag.replace(/^<math\b[^>]*>/i, '').replace(/<\/math>\s*$/i, '');
|
||||||
|
return renderMathTag(openTag, innerHtml);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMatchingSpanClose = (html: string, fromIndex: number): number => {
|
||||||
|
let depth = 1;
|
||||||
|
const tagRe = /<(\/?)span\b[^>]*>/gi;
|
||||||
|
tagRe.lastIndex = fromIndex;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRe.exec(html)) !== null) {
|
||||||
|
depth += match[1] === '/' ? -1 : 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return match.index + match[0].length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPlainNumericFormula = (formula: string) =>
|
||||||
|
/^[\-\u2212\uFF0D0-9.+\s(),]+$/.test(formula.trim());
|
||||||
|
|
||||||
|
const renderFormulaContent = (formula: string): string => {
|
||||||
|
const trimmed = formula.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (isPlainNumericFormula(trimmed)) {
|
||||||
|
return escapeHtml(trimmed);
|
||||||
|
}
|
||||||
|
if (isSimpleFormula(trimmed)) {
|
||||||
|
return simpleFormulaToHtml(trimmed);
|
||||||
|
}
|
||||||
|
const imageUrl = latexToImageUrl(trimmed);
|
||||||
|
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" referrerpolicy="no-referrer" loading="lazy" onerror="this.onerror=null;this.outerHTML='<span class="formula-fallback">${escapeHtml(trimmed)}</span>';" />`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceFormulaSpansInHtml = (html: string): string => {
|
||||||
|
if (!html || !html.includes('data-w-e-type')) return html;
|
||||||
|
|
||||||
|
const openRe = /<span\b[^>]*\bdata-w-e-type\s*=\s*["']formula["'][^>]*>/gi;
|
||||||
|
let result = '';
|
||||||
|
let cursor = 0;
|
||||||
|
let match;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while ((match = openRe.exec(html)) !== null) {
|
||||||
|
if (guard++ > 500) break;
|
||||||
|
result += html.slice(cursor, match.index);
|
||||||
|
const openTag = match[0];
|
||||||
|
const contentStart = match.index + openTag.length;
|
||||||
|
const closeEnd = findMatchingSpanClose(html, contentStart);
|
||||||
|
|
||||||
|
if (closeEnd === -1) {
|
||||||
|
const formula = resolveFormulaSource(openTag);
|
||||||
|
result += renderFormulaContent(formula);
|
||||||
|
cursor = match.index + openTag.length;
|
||||||
|
openRe.lastIndex = cursor;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerHtml = html.slice(contentStart, closeEnd - '</span>'.length);
|
||||||
|
const formula = resolveFormulaSource(openTag, innerHtml);
|
||||||
|
result += renderFormulaContent(formula);
|
||||||
|
cursor = closeEnd;
|
||||||
|
openRe.lastIndex = closeEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += html.slice(cursor);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupFormulaSpanRemnants = (html: string): string => {
|
||||||
|
if (!html) return '';
|
||||||
|
let result = html;
|
||||||
|
let prev = '';
|
||||||
|
let guard = 0;
|
||||||
|
while (result !== prev && guard < 8) {
|
||||||
|
prev = result;
|
||||||
|
result = replaceFormulaSpansInHtml(result);
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
.replace(/<span\b[^>]*\bdata-w-e-type\s*=\s*["']formula["'][^>]*>[\s\S]*?<\/span>/gi, (tag) => {
|
||||||
|
const openTag = tag.match(/^<span\b[^>]*>/i)?.[0] || '';
|
||||||
|
const innerHtml = tag.replace(/^<span\b[^>]*>/i, '').replace(/<\/span>\s*$/i, '');
|
||||||
|
const formula = resolveFormulaSource(openTag, innerHtml);
|
||||||
|
return renderFormulaContent(formula);
|
||||||
|
})
|
||||||
|
.replace(/\sdata-w-e-type\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
.replace(/\sdata-value\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
.replace(/\sdata-size\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
.replace(/\scontenteditable\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
.replace(/<span\s*><\/span>/gi, '');
|
||||||
|
};
|
||||||
|
|
||||||
export const fixMathSymbolsInHtml = (html: string): string => {
|
export const fixMathSymbolsInHtml = (html: string): string => {
|
||||||
if (html == null || typeof html !== 'string') return '';
|
if (html == null || typeof html !== 'string') return '';
|
||||||
@ -131,47 +282,68 @@ const simpleFormulaToHtml = (formula: string): string => {
|
|||||||
return `<span class="formula-text" style="font-style:italic;">${text}</span>`;
|
return `<span class="formula-text" style="font-style:italic;">${text}</span>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processDollarLatex = (text: string): string => {
|
||||||
|
if (!text || !text.includes('$')) return text;
|
||||||
|
return text.replace(/\$(?!\$)([\s\S]+?)\$(?!\$)/g, (_match, formula: string) => {
|
||||||
|
const trimmed = String(formula || '').trim();
|
||||||
|
if (!trimmed) return _match;
|
||||||
|
if (isSimpleFormula(trimmed)) {
|
||||||
|
return simpleFormulaToHtml(trimmed);
|
||||||
|
}
|
||||||
|
const imageUrl = latexToImageUrl(trimmed);
|
||||||
|
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" referrerpolicy="no-referrer" loading="lazy" onerror="this.onerror=null;this.outerHTML='<span class="formula-fallback">${escapeHtml(trimmed)}</span>';" />`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const processFormula = (html: string): string => {
|
export const processFormula = (html: string): string => {
|
||||||
if (html == null || typeof html !== 'string') return '';
|
if (html == null || typeof html !== 'string') return '';
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
|
return cleanupFormulaSpanRemnants(html);
|
||||||
const formulaRegex = /<span\s+((?:[^<>"']+|"[^"]*"|'[^']*')*)data-w-e-type=["']formula["']((?:[^<>"']+|"[^"]*"|'[^']*')*)>[\s\S]*?<\/span>/gi;
|
|
||||||
|
|
||||||
return html.replace(formulaRegex, (match) => {
|
|
||||||
const valueMatch = match.match(/data-value=["']([^"']+)["']/);
|
|
||||||
if (!valueMatch) return match;
|
|
||||||
|
|
||||||
let formula = valueMatch[1]
|
|
||||||
.replace(/\\\\/g, '\\')
|
|
||||||
.replace(/\\"/g, '"')
|
|
||||||
.replace(/\\'/g, "'");
|
|
||||||
|
|
||||||
if (!formula.trim()) return '';
|
|
||||||
|
|
||||||
if (isSimpleFormula(formula)) {
|
|
||||||
return simpleFormulaToHtml(formula);
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = latexToImageUrl(formula);
|
|
||||||
return `<img class="formula-img" src="${imageUrl}" style="height:1.4em;vertical-align:middle;" />`;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isHtmlContent = (value: string) => /<[a-z][\s\S]*>/i.test(value);
|
export const isHtmlContent = (value: string) => /<[a-z][\s\S]*>/i.test(value);
|
||||||
|
|
||||||
export const formatQuestionHtml = (title?: string, options?: { stripImages?: boolean }) => {
|
export const formatQuestionHtml = (title?: string, options?: { stripImages?: boolean }) => {
|
||||||
if (!title) return '';
|
if (title == null || title === '') return '';
|
||||||
|
const cacheKey = `${options?.stripImages ? '1' : '0'}::${String(title)}`;
|
||||||
|
const cached = formatCache.get(cacheKey);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
try {
|
||||||
let t = String(title);
|
let t = String(title);
|
||||||
if (options?.stripImages) {
|
if (options?.stripImages) {
|
||||||
t = t.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
|
t = t.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
|
||||||
}
|
}
|
||||||
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
|
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
|
||||||
t = t.replace(/http:\/\//gi, 'https://');
|
t = t.replace(/http:\/\//gi, 'https://');
|
||||||
|
t = processMathTags(t);
|
||||||
|
t = cleanupFormulaSpanRemnants(t);
|
||||||
|
t = processDollarLatex(t);
|
||||||
t = fixMathSymbolsInHtml(t);
|
t = fixMathSymbolsInHtml(t);
|
||||||
t = processFormula(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;
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 错题详情:与 Web 一致优先保留原始 HTML,仅做 rich-text 兼容转换 */
|
||||||
|
export const formatWrongQuestionHtml = (title?: string, options?: { stripImages?: boolean }) =>
|
||||||
|
formatQuestionHtml(title, options);
|
||||||
|
|
||||||
export const getQuestionStemSource = (item: Record<string, unknown>) => {
|
export const getQuestionStemSource = (item: Record<string, unknown>) => {
|
||||||
const pid = item.pidTitle ? String(item.pidTitle) : '';
|
const pid = item.pidTitle ? String(item.pidTitle) : '';
|
||||||
const stem = String(item.stemHtml || item.titleMu || item.title || item.stem || '');
|
const stem = String(item.stemHtml || item.titleMu || item.title || item.stem || '');
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Request from 'luch-request';
|
|||||||
import { getCache, removeCache } from '@/utils/cache';
|
import { getCache, removeCache } from '@/utils/cache';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学小乐 AI 通用 HTTP 客户端(基于 luch-request)
|
* 灵犀学 AI 通用 HTTP 客户端(基于 luch-request)
|
||||||
*
|
*
|
||||||
* - 自动注入 Bearer Token
|
* - 自动注入 Bearer Token
|
||||||
* - 自动注入租户头 x-tenant-id
|
* - 自动注入租户头 x-tenant-id
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user