feat:修改
This commit is contained in:
parent
5fd397b1ea
commit
43eaa23e7a
@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
|
||||
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('App Launch');
|
||||
redirectToRoleHomeIfNeeded();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
|
||||
@ -1,5 +1,30 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
/** 试卷测试类型:1 单元测试 2 周测 3 月测 4 随堂练习 5 课前预习 */
|
||||
export type PaperTestType = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export const PAPER_TEST_TYPE_MAP: Record<PaperTestType, string> = {
|
||||
1: '单元测试',
|
||||
2: '周测',
|
||||
3: '月测',
|
||||
4: '随堂练习',
|
||||
5: '课前预习',
|
||||
};
|
||||
|
||||
export const PAPER_TEST_TYPE_OPTIONS: Array<{ label: string; value?: PaperTestType }> = [
|
||||
{ label: '全部类型' },
|
||||
{ label: '单元测试', value: 1 },
|
||||
{ label: '周测', value: 2 },
|
||||
{ label: '月测', value: 3 },
|
||||
{ label: '随堂练习', value: 4 },
|
||||
{ label: '课前预习', value: 5 },
|
||||
];
|
||||
|
||||
export const getPaperTestTypeLabel = (type?: number | null) => {
|
||||
if (!type) return '';
|
||||
return PAPER_TEST_TYPE_MAP[type as PaperTestType] || '';
|
||||
};
|
||||
|
||||
export interface PaperRecord {
|
||||
id: string;
|
||||
paperId: string;
|
||||
@ -11,6 +36,27 @@ export interface PaperRecord {
|
||||
endTime: string;
|
||||
remark: string;
|
||||
costTime: number;
|
||||
totalScore?: number;
|
||||
score?: number;
|
||||
type?: PaperTestType;
|
||||
gradeName?: string;
|
||||
markTime?: string;
|
||||
submitTime?: string;
|
||||
}
|
||||
|
||||
export interface ListCompleteRecordParams {
|
||||
current?: number;
|
||||
resourceType?: number;
|
||||
userId?: string | number;
|
||||
subjectId?: string;
|
||||
type?: PaperTestType;
|
||||
gradingStatus?: string;
|
||||
}
|
||||
|
||||
export interface ListCompleteRecordPage {
|
||||
rows: PaperRecord[];
|
||||
current?: number;
|
||||
totalRows?: number;
|
||||
}
|
||||
|
||||
/** 获取未完成作业列表 */
|
||||
@ -55,17 +101,35 @@ export const submitExam = (data: {
|
||||
data,
|
||||
});
|
||||
|
||||
/** 已完成作业列表 */
|
||||
export const getListCompleteRecord = (params?: {
|
||||
gradingStatus?: string;
|
||||
subjectId?: string;
|
||||
}) =>
|
||||
request<any[]>({
|
||||
/** 已完成作业/考试记录列表 */
|
||||
export const getListCompleteRecord = (params?: ListCompleteRecordParams) =>
|
||||
request<PaperRecord[] | ListCompleteRecordPage>({
|
||||
url: '/school/paper/userRecord/listCompleteRecord',
|
||||
method: 'GET',
|
||||
params,
|
||||
params: {
|
||||
resourceType: 0,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
|
||||
/** 解析分页或数组两种返回结构 */
|
||||
export const normalizeListCompleteRecord = (
|
||||
data: PaperRecord[] | ListCompleteRecordPage | null | undefined,
|
||||
) => {
|
||||
if (!data) {
|
||||
return { rows: [] as PaperRecord[], totalRows: 0, current: 1 };
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return { rows: data, totalRows: data.length, current: 1 };
|
||||
}
|
||||
const rows = data.rows || [];
|
||||
return {
|
||||
rows,
|
||||
totalRows: data.totalRows ?? rows.length,
|
||||
current: data.current ?? 1,
|
||||
};
|
||||
};
|
||||
|
||||
/** 作业详情(含题目) */
|
||||
export const getPaperRecordDetail = (data: { recordId: string; reportFlag: number }) =>
|
||||
request<any>({
|
||||
|
||||
@ -75,9 +75,11 @@ export const parentSmsLogin = (data: ParentSmsLoginParams) =>
|
||||
data: {
|
||||
bindMaOpenid: true,
|
||||
adminType: 16,
|
||||
openid: data.openid,
|
||||
phone: data.phone,
|
||||
verifyCode: data.verifyCode,
|
||||
wxUser: {
|
||||
openid: data.openid,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -89,6 +91,24 @@ export const logout = () =>
|
||||
silent: true,
|
||||
});
|
||||
|
||||
export interface SysUserInfo {
|
||||
id?: string | number;
|
||||
account?: string;
|
||||
phone?: string;
|
||||
nickName?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
avatarUrl?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息(家长端登录后使用) */
|
||||
export const selectUser = () =>
|
||||
request<SysUserInfo>({
|
||||
url: '/sysUser/selectUser',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
/** 获取学生用户首页信息 */
|
||||
export const getUserInfo = () =>
|
||||
request<any>({
|
||||
|
||||
@ -40,13 +40,71 @@ export interface PageResult<T> {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** 家长绑定孩子列表 */
|
||||
export const getParentBindChildList = (params?: { current?: number; size?: number }) =>
|
||||
/** 家长绑定孩子列表(当前家长已绑定) */
|
||||
export const getParentBindChildList = (params: {
|
||||
parentId: string | number;
|
||||
current?: number;
|
||||
size?: number;
|
||||
}) =>
|
||||
request<PageResult<ParentBindChildItem>>({
|
||||
url: '/parentBindChild/list',
|
||||
method: 'GET',
|
||||
params: {
|
||||
current: params?.current ?? 1,
|
||||
size: params?.size ?? 100,
|
||||
parentId: params.parentId,
|
||||
current: params.current ?? 1,
|
||||
size: params.size ?? 100,
|
||||
bindFlag: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/** 按孩子 ID 查询绑定家长(扫码绑定前校验) */
|
||||
export const getBindParentsByChildId = (childId: string | number) =>
|
||||
request<PageResult<ParentBindChildItem>>({
|
||||
url: '/parentBindChild/list',
|
||||
method: 'GET',
|
||||
params: {
|
||||
childId,
|
||||
size: -1,
|
||||
},
|
||||
});
|
||||
|
||||
/** 按设备序列号查询绑定家长 */
|
||||
export const getBindParentsByDevice = (simSerialNumber: string) =>
|
||||
request<PageResult<any>>({
|
||||
url: '/parentBindDevice/list',
|
||||
method: 'GET',
|
||||
params: {
|
||||
simSerialNumber,
|
||||
size: -1,
|
||||
},
|
||||
});
|
||||
|
||||
/** 手动输入孩子账号绑定 */
|
||||
export const manualBindChild = (childAcc: string) =>
|
||||
request<number>({
|
||||
url: '/parentBind/familyTeacherBindAcc',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
data: `childAcc=${encodeURIComponent(childAcc.trim())}`,
|
||||
});
|
||||
|
||||
/** 扫码后申请绑定孩子/设备 */
|
||||
export const parentBindApply = (params: {
|
||||
childId?: string | number;
|
||||
simSerialNumber?: string;
|
||||
identityType: number;
|
||||
}) => {
|
||||
const childDto = params.childId
|
||||
? { childId: params.childId, identityType: params.identityType }
|
||||
: undefined;
|
||||
const deviceDto = params.simSerialNumber
|
||||
? { simSerialNumber: params.simSerialNumber, identityType: params.identityType }
|
||||
: undefined;
|
||||
return request({
|
||||
url: '/parentBind/apply',
|
||||
method: 'POST',
|
||||
data: { childDto, deviceDto },
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,30 +1,22 @@
|
||||
<template>
|
||||
<view class="empty">
|
||||
<view class="empty-illu" :class="`empty-illu-${type}`">
|
||||
<!-- Loading -->
|
||||
<view v-if="type === 'loading'" class="empty-loader">
|
||||
<view class="dot dot-1"></view>
|
||||
<view class="dot dot-2"></view>
|
||||
<view class="dot dot-3"></view>
|
||||
</view>
|
||||
<!-- Network -->
|
||||
<svg v-else-if="type === 'network'" width="80" height="80" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 13a10 10 0 0 1 14 0M8.5 16.5a5 5 0 0 1 7 0M12 20h0M2 8.82a15 15 0 0 1 20 0"
|
||||
stroke="#A78BFA" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="#EF4444" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
<!-- Task / 默认空盒子 -->
|
||||
<svg v-else-if="type === 'task'" width="80" height="80" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" />
|
||||
<path d="M14 2v6h6" stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" />
|
||||
<path d="M9 13h6M9 17h4" stroke="#A78BFA" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
<!-- Learn -->
|
||||
<svg v-else width="80" height="80" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"
|
||||
stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<view v-else class="empty-art">
|
||||
<view class="art-card art-card-back"></view>
|
||||
<view class="art-card art-card-front">
|
||||
<view class="art-dot"></view>
|
||||
<view class="art-line art-line-long"></view>
|
||||
<view class="art-line art-line-mid"></view>
|
||||
<view class="art-line art-line-short"></view>
|
||||
</view>
|
||||
<view class="art-spark art-spark-left"></view>
|
||||
<view class="art-spark art-spark-right"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-text">{{ content }}</view>
|
||||
</view>
|
||||
@ -68,9 +60,16 @@ withDefaults(
|
||||
inset 0 -6rpx 0 rgba(199, 210, 254, 0.6),
|
||||
inset 0 4rpx 0 rgba(255, 255, 255, 0.9);
|
||||
|
||||
svg {
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
&.empty-illu-network {
|
||||
.art-dot {
|
||||
background: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
&.empty-illu-learn {
|
||||
.art-dot {
|
||||
background: #86efac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,6 +79,82 @@ withDefaults(
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-art {
|
||||
position: relative;
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
position: absolute;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
|
||||
.art-card-back {
|
||||
right: 6rpx;
|
||||
bottom: 4rpx;
|
||||
width: 78rpx;
|
||||
height: 86rpx;
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #c7d2fe 100%);
|
||||
opacity: 0.9;
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
|
||||
.art-card-front {
|
||||
left: 8rpx;
|
||||
top: 6rpx;
|
||||
width: 86rpx;
|
||||
height: 98rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10rpx 24rpx rgba(79, 70, 229, 0.12);
|
||||
}
|
||||
|
||||
.art-dot {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin: 20rpx 0 0 18rpx;
|
||||
border-radius: 50%;
|
||||
background: #93c5fd;
|
||||
}
|
||||
|
||||
.art-line {
|
||||
height: 8rpx;
|
||||
margin: 12rpx 0 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #c7d2fe;
|
||||
}
|
||||
|
||||
.art-line-long {
|
||||
width: 50rpx;
|
||||
}
|
||||
|
||||
.art-line-mid {
|
||||
width: 38rpx;
|
||||
}
|
||||
|
||||
.art-line-short {
|
||||
width: 28rpx;
|
||||
}
|
||||
|
||||
.art-spark {
|
||||
position: absolute;
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #a5f3fc;
|
||||
}
|
||||
|
||||
.art-spark-left {
|
||||
left: 0;
|
||||
bottom: 24rpx;
|
||||
}
|
||||
|
||||
.art-spark-right {
|
||||
right: 0;
|
||||
top: 18rpx;
|
||||
background: #fde68a;
|
||||
}
|
||||
|
||||
/* loading dots */
|
||||
.empty-loader {
|
||||
display: flex;
|
||||
|
||||
@ -256,6 +256,20 @@
|
||||
"pageOrientation": "portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "bind/index",
|
||||
"name": "parent-bind",
|
||||
"meta": {
|
||||
"islogin": true,
|
||||
"role": "parent"
|
||||
},
|
||||
"style": {
|
||||
"navigationBarTitleText": "绑定孩子",
|
||||
"mp-weixin": {
|
||||
"pageOrientation": "portrait"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -143,7 +143,7 @@ import { onShow } from '@dcloudio/uni-app';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useUserStore } from '@/state/modules/user';
|
||||
import { getUnfinishedHomework } from '@/api/homework';
|
||||
import { getCache } from '@/utils/cache';
|
||||
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userInfo, token, isLogin } = storeToRefs(userStore);
|
||||
@ -216,10 +216,7 @@ const fetchUnfinished = async () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
if (token.value && getCache('role') === 'teacher') {
|
||||
uni.reLaunch({ url: '/pages/teacher/index/index' });
|
||||
return;
|
||||
}
|
||||
if (redirectToRoleHomeIfNeeded()) return;
|
||||
if (token.value) {
|
||||
userStore.fetchUserInfo().catch(() => { /* ignore */ });
|
||||
fetchUnfinished();
|
||||
|
||||
@ -184,6 +184,7 @@ import { parentMpLogin, sendParentLoginCode } from '@/api/login';
|
||||
import { useUserStore } from '@/state/modules/user';
|
||||
import { useTeacherStore } from '@/state/modules/teacher';
|
||||
import { setCache, getCache } from '@/utils/cache';
|
||||
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
|
||||
|
||||
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
|
||||
const logoUrl = `${OSS_URL}/urm/logo.png`;
|
||||
@ -282,6 +283,7 @@ onLoad((options: any) => {
|
||||
if (options?.a) {
|
||||
account.value = options.a;
|
||||
}
|
||||
if (redirectToRoleHomeIfNeeded()) return;
|
||||
});
|
||||
|
||||
const onLogoTap = () => {
|
||||
@ -362,6 +364,7 @@ const onParentAuthorize = async () => {
|
||||
|
||||
if (accessToken) {
|
||||
await userStore.parentMpLoginSuccess(accessToken);
|
||||
setCache('role', 'parent');
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '登录成功', icon: 'success' });
|
||||
setTimeout(() => navigateAfterLogin(), 800);
|
||||
@ -435,6 +438,7 @@ const onLogin = async () => {
|
||||
verifyCode: code,
|
||||
openid: parentWxOpenid.value,
|
||||
});
|
||||
setCache('role', 'parent');
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '登录成功', icon: 'success' });
|
||||
setTimeout(() => navigateAfterLogin(), 800);
|
||||
|
||||
@ -1,25 +1,6 @@
|
||||
<template>
|
||||
<ParentPage active="reports" :show-tab="false" :use-scroll-view="false">
|
||||
<view class="filter-row">
|
||||
<picker class="filter-picker" :range="subjectOptions" :value="subjectIndex" @change="onSubjectChange">
|
||||
<view class="filter-pill">
|
||||
<text>{{ subjectOptions[subjectIndex] }}</text>
|
||||
<view class="filter-arrow"></view>
|
||||
</view>
|
||||
</picker>
|
||||
<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="categoryOptions" :value="categoryIndex" @change="onCategoryChange">
|
||||
<view class="filter-pill">
|
||||
<text>{{ categoryOptions[categoryIndex] }}</text>
|
||||
<view class="filter-arrow"></view>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<ParentFilterRow :items="filterItems" @change="onFilterChange" />
|
||||
|
||||
<view class="module-section">
|
||||
<ParentCard title="错题知识点占比" subtitle="按错题占比识别最近需要优先补强的知识点">
|
||||
@ -113,6 +94,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { onReady, onShow } from '@dcloudio/uni-app';
|
||||
import ParentCard from '../components/ParentCard.vue';
|
||||
import ParentFilterRow from '../components/ParentFilterRow.vue';
|
||||
import ParentPage from '../components/ParentPage.vue';
|
||||
import {
|
||||
abilitySummary,
|
||||
@ -131,6 +113,26 @@ const gradeIndex = ref(1);
|
||||
const categoryIndex = ref(0);
|
||||
const activeLevel = ref<ScoreLevel>('weak');
|
||||
|
||||
const filterItems = computed(() => [
|
||||
{ key: 'subject', range: subjectOptions, value: subjectIndex.value },
|
||||
{ key: 'grade', range: gradeOptions, value: gradeIndex.value },
|
||||
{ key: 'category', range: categoryOptions, value: categoryIndex.value },
|
||||
]);
|
||||
|
||||
const onFilterChange = (payload: { key: string; value: number }) => {
|
||||
if (payload.key === 'subject') {
|
||||
subjectIndex.value = payload.value;
|
||||
return;
|
||||
}
|
||||
if (payload.key === 'grade') {
|
||||
gradeIndex.value = payload.value;
|
||||
return;
|
||||
}
|
||||
if (payload.key === 'category') {
|
||||
categoryIndex.value = payload.value;
|
||||
}
|
||||
};
|
||||
|
||||
const scoreLevels: { key: ScoreLevel; label: string }[] = [
|
||||
{ key: 'weak', label: '薄弱' },
|
||||
{ key: 'pass', label: '合格' },
|
||||
@ -237,48 +239,9 @@ const abilityChartOpts = {
|
||||
},
|
||||
};
|
||||
|
||||
const onSubjectChange = (e: any) => { subjectIndex.value = Number(e.detail.value); };
|
||||
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); };
|
||||
const onCategoryChange = (e: any) => { categoryIndex.value = Number(e.detail.value); };
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
padding-top: 12rpx;
|
||||
}
|
||||
|
||||
.filter-picker {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
color: #164e63;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow:
|
||||
0 8rpx 22rpx rgba(8, 145, 178, 0.08),
|
||||
inset 0 -2rpx 0 rgba(207, 250, 254, 0.9);
|
||||
}
|
||||
|
||||
.filter-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8rpx solid transparent;
|
||||
border-right: 8rpx solid transparent;
|
||||
border-top: 10rpx solid #0891b2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.module-section {
|
||||
margin-top: 44rpx;
|
||||
}
|
||||
|
||||
375
src/pages/parent/bind/index.vue
Normal file
375
src/pages/parent/bind/index.vue
Normal file
@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<view class="bind-page">
|
||||
<view v-if="loading" class="state-wrap">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="simSerialNumber" class="section">
|
||||
<view class="section-title">
|
||||
设备
|
||||
<text v-if="hasBindDevice" class="tag-bound">(已绑定)</text>
|
||||
</view>
|
||||
<view class="info-card">
|
||||
<text class="info-main">{{ deviceDisplay }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="childId" class="section">
|
||||
<view class="section-title">
|
||||
孩子
|
||||
<text v-if="hasBindChild" class="tag-bound">(已绑定)</text>
|
||||
</view>
|
||||
<view class="info-card child-card">
|
||||
<image
|
||||
v-if="childAvatar"
|
||||
class="child-avatar"
|
||||
:src="childAvatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="child-meta">
|
||||
<text class="info-main">{{ childDisplayName }}</text>
|
||||
<text v-if="childAccountText" class="info-sub">账号:{{ childAccountText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-title">请选择您的身份</view>
|
||||
<view class="identity-grid">
|
||||
<view
|
||||
v-for="item in PARENT_IDENTITY_LIST"
|
||||
:key="item.id"
|
||||
class="identity-item"
|
||||
:class="{ active: identityType === item.id, disabled: !canBind }"
|
||||
@click="selectIdentity(item.id)"
|
||||
>
|
||||
<image class="identity-icon" :src="item.icon" mode="aspectFit" />
|
||||
<text class="identity-name">{{ item.name }}</text>
|
||||
<view v-if="identityType === item.id" class="identity-check">✓</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer">
|
||||
<view
|
||||
class="submit-btn"
|
||||
:class="{ disabled: !canSubmit || hasAllBound }"
|
||||
@click="submitBind"
|
||||
>
|
||||
{{ hasAllBound ? '已绑定' : submitting ? '提交中...' : '申请绑定' }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import {
|
||||
getBindParentsByChildId,
|
||||
getBindParentsByDevice,
|
||||
parentBindApply,
|
||||
type ParentBindChildItem,
|
||||
} from '@/api/parent';
|
||||
import { useUserStore } from '@/state/modules/user';
|
||||
import {
|
||||
PARENT_IDENTITY_LIST,
|
||||
PARENT_TYPE_OTHER,
|
||||
} from '../utils/parentIdentity';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const childId = ref('');
|
||||
const childAccount = ref('');
|
||||
const simSerialNumber = ref('');
|
||||
const identityType = ref(PARENT_TYPE_OTHER);
|
||||
|
||||
const bindChildRow = ref<ParentBindChildItem | null>(null);
|
||||
const bindDeviceRow = ref<any | null>(null);
|
||||
const hasBindChild = ref(false);
|
||||
const hasBindDevice = ref(false);
|
||||
|
||||
const childDisplayName = computed(() => {
|
||||
if (bindChildRow.value?.child?.name || bindChildRow.value?.child?.nickname) {
|
||||
return bindChildRow.value.child.name || bindChildRow.value.child.nickname || '孩子';
|
||||
}
|
||||
return childAccount.value || '孩子';
|
||||
});
|
||||
|
||||
const childAccountText = computed(() => bindChildRow.value?.child?.account || childAccount.value || '');
|
||||
const childAvatar = computed(() => bindChildRow.value?.child?.avatar || '');
|
||||
|
||||
const deviceDisplay = computed(() => {
|
||||
if (bindDeviceRow.value?.simSerialNumber) return bindDeviceRow.value.simSerialNumber;
|
||||
return simSerialNumber.value;
|
||||
});
|
||||
|
||||
const needBoth = computed(() => !!(childId.value && simSerialNumber.value));
|
||||
|
||||
const hasAllBound = computed(() => {
|
||||
if (needBoth.value) return hasBindChild.value && hasBindDevice.value;
|
||||
if (simSerialNumber.value) return hasBindDevice.value;
|
||||
return hasBindChild.value;
|
||||
});
|
||||
|
||||
const bindChildIdToApply = computed(() => {
|
||||
if (!childId.value || hasBindChild.value) return undefined;
|
||||
return childId.value;
|
||||
});
|
||||
|
||||
const bindDeviceSnToApply = computed(() => {
|
||||
if (!simSerialNumber.value || hasBindDevice.value) return undefined;
|
||||
return simSerialNumber.value;
|
||||
});
|
||||
|
||||
const canBind = computed(() => !!(bindChildIdToApply.value || bindDeviceSnToApply.value));
|
||||
|
||||
const canSubmit = computed(() => canBind.value && !submitting.value);
|
||||
|
||||
const checkBindStatus = async () => {
|
||||
const parentId = userInfo.value.id;
|
||||
if (!parentId) {
|
||||
await userStore.fetchParentUserInfo().catch(() => { /* ignore */ });
|
||||
}
|
||||
const currentParentId = userInfo.value.id;
|
||||
|
||||
if (childId.value) {
|
||||
const res = await getBindParentsByChildId(childId.value);
|
||||
const rows = res.data?.rows || [];
|
||||
if (rows.length) {
|
||||
bindChildRow.value = rows[0];
|
||||
}
|
||||
hasBindChild.value = rows.some(
|
||||
(item) => String(item.parent?.id) === String(currentParentId),
|
||||
);
|
||||
}
|
||||
|
||||
if (simSerialNumber.value) {
|
||||
const res = await getBindParentsByDevice(simSerialNumber.value);
|
||||
const rows = res.data?.rows || [];
|
||||
if (rows.length) {
|
||||
bindDeviceRow.value = rows[0];
|
||||
}
|
||||
hasBindDevice.value = rows.some(
|
||||
(item) => String(item.parent?.id) === String(currentParentId),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(async (query) => {
|
||||
childId.value = query?.childId ? String(query.childId) : '';
|
||||
childAccount.value = query?.account ? decodeURIComponent(String(query.account)) : '';
|
||||
simSerialNumber.value = query?.simSerialNumber
|
||||
? decodeURIComponent(String(query.simSerialNumber))
|
||||
: '';
|
||||
|
||||
if (!childId.value && !simSerialNumber.value) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '参数错误',
|
||||
showCancel: false,
|
||||
success: () => uni.navigateBack(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await checkBindStatus();
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const selectIdentity = (id: number) => {
|
||||
if (!canBind.value) return;
|
||||
identityType.value = id;
|
||||
};
|
||||
|
||||
const submitBind = async () => {
|
||||
if (hasAllBound.value || !canSubmit.value) return;
|
||||
if (!identityType.value) {
|
||||
uni.showToast({ title: '请选择您与孩子的关系', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await parentBindApply({
|
||||
childId: bindChildIdToApply.value,
|
||||
simSerialNumber: bindDeviceSnToApply.value,
|
||||
identityType: identityType.value,
|
||||
});
|
||||
uni.showToast({ title: '已发出绑定申请', icon: 'success' });
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 1200);
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '申请失败', icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bind-page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx 24rpx 180rpx;
|
||||
background: linear-gradient(180deg, #ecfeff 0%, #f8fafc 40%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.state-wrap {
|
||||
padding: 120rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
color: #94a3b8;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 16rpx;
|
||||
color: #64748b;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag-bound {
|
||||
color: #ef4444;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 28rpx;
|
||||
border-radius: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 12rpx 32rpx rgba(8, 145, 178, 0.08);
|
||||
}
|
||||
|
||||
.child-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.child-avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #ecfeff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.child-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-main {
|
||||
display: block;
|
||||
color: #164e63;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.info-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: #94a3b8;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.identity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.identity-item {
|
||||
position: relative;
|
||||
padding: 24rpx 20rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
border: 2rpx solid transparent;
|
||||
box-shadow: 0 8rpx 24rpx rgba(8, 145, 178, 0.06);
|
||||
|
||||
&.active {
|
||||
border-color: #0891b2;
|
||||
background: #ecfeff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.identity-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
color: #164e63;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.identity-check {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
right: 16rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background: #0891b2;
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 -8rpx 24rpx rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #0891b2, #06b6d4);
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
src/pages/parent/components/ManualBindChild.vue
Normal file
157
src/pages/parent/components/ManualBindChild.vue
Normal file
@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<view v-if="visible" class="manual-bind-mask" @click="close">
|
||||
<view class="manual-bind-panel" @click.stop>
|
||||
<text class="panel-title">手动绑定孩子账号</text>
|
||||
<input
|
||||
v-model="childAccount"
|
||||
class="panel-input"
|
||||
type="text"
|
||||
placeholder="请输入孩子账号"
|
||||
placeholder-class="panel-placeholder"
|
||||
:adjust-position="false"
|
||||
@confirm="confirm"
|
||||
/>
|
||||
<view class="panel-actions">
|
||||
<view class="panel-btn cancel" @click="close">取消</view>
|
||||
<view class="panel-btn primary" :class="{ disabled: submitting }" @click="confirm">
|
||||
{{ submitting ? '绑定中...' : '绑定' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { manualBindChild } from '@/api/parent';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const childAccount = ref('');
|
||||
const submitting = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (!val) childAccount.value = '';
|
||||
},
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
if (submitting.value) return;
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
const acc = childAccount.value.trim();
|
||||
if (!acc) {
|
||||
uni.showToast({ title: '请输入孩子账号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (submitting.value) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await manualBindChild(acc);
|
||||
const code = Number(res.data);
|
||||
if (code === 1) {
|
||||
uni.showToast({ title: '绑定成功', icon: 'success' });
|
||||
emit('success');
|
||||
emit('update:visible', false);
|
||||
} else if (code === 2) {
|
||||
uni.showToast({ title: '已向管理员发送绑定请求', icon: 'none' });
|
||||
emit('success');
|
||||
emit('update:visible', false);
|
||||
} else {
|
||||
uni.showToast({ title: '绑定失败', icon: 'none' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '绑定失败', icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.manual-bind-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
}
|
||||
|
||||
.manual-bind-panel {
|
||||
width: 100%;
|
||||
max-width: 620rpx;
|
||||
padding: 40rpx 36rpx 32rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 24rpx 60rpx rgba(8, 145, 178, 0.18);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: block;
|
||||
color: #164e63;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-input {
|
||||
height: 88rpx;
|
||||
margin-top: 32rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #164e63;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.panel-placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 36rpx;
|
||||
}
|
||||
|
||||
.panel-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
|
||||
&.cancel {
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #0891b2, #06b6d4);
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
85
src/pages/parent/components/ParentFilterRow.vue
Normal file
85
src/pages/parent/components/ParentFilterRow.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<view class="filter-row" :style="gridStyle">
|
||||
<picker
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="filter-picker"
|
||||
:range="item.range"
|
||||
:value="item.value"
|
||||
@change="(e) => onPickerChange(item.key, e)"
|
||||
>
|
||||
<view class="filter-pill">
|
||||
<text class="filter-text">{{ item.range[item.value] }}</text>
|
||||
<view class="filter-arrow"></view>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface ParentFilterItem {
|
||||
key: string;
|
||||
range: string[];
|
||||
value: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: ParentFilterItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [payload: { key: string; value: number }];
|
||||
}>();
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${Math.max(props.items?.length || 1, 1)}, 1fr)`,
|
||||
}));
|
||||
|
||||
const onPickerChange = (key: string, e: { detail: { value: string } }) => {
|
||||
emit('change', { key, value: Number(e.detail.value) });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-row {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
padding-top: 12rpx;
|
||||
}
|
||||
|
||||
.filter-picker {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
color: #164e63;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow:
|
||||
0 8rpx 22rpx rgba(8, 145, 178, 0.08),
|
||||
inset 0 -2rpx 0 rgba(207, 250, 254, 0.9);
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
max-width: 260rpx;
|
||||
}
|
||||
|
||||
.filter-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8rpx solid transparent;
|
||||
border-right: 8rpx solid transparent;
|
||||
border-top: 10rpx solid #0891b2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,7 +2,15 @@
|
||||
<view class="parent-page" :class="{ 'native-scroll': !useScrollView }">
|
||||
<view class="decor decor-a"></view>
|
||||
<view class="decor decor-b"></view>
|
||||
<scroll-view v-if="useScrollView" class="page-scroll" scroll-y enhanced :show-scrollbar="false">
|
||||
<scroll-view
|
||||
v-if="useScrollView"
|
||||
class="page-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
:show-scrollbar="false"
|
||||
:lower-threshold="120"
|
||||
@scrolltolower="$emit('scrolltolower')"
|
||||
>
|
||||
<view class="page-content" :class="{ 'with-tab': showTab }">
|
||||
<slot></slot>
|
||||
</view>
|
||||
@ -19,6 +27,10 @@
|
||||
<script setup lang="ts">
|
||||
import ParentTabBar from './ParentTabBar.vue';
|
||||
|
||||
defineEmits<{
|
||||
scrolltolower: [];
|
||||
}>();
|
||||
|
||||
// 家长端页面统一背景、滚动区域和底部导航,业务页只关注内容块。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<ParentPage active="home">
|
||||
<view class="profile">
|
||||
<view class="avatar">{{ activeChild.name.slice(0, 1) }}</view>
|
||||
<image class="avatar" :src="parentAvatarUrl" mode="aspectFill" />
|
||||
<view class="profile-main">
|
||||
<view class="name-row">
|
||||
<text class="child-name">{{ activeChild.name }}家长</text>
|
||||
@ -58,11 +58,33 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useParentStore } from '@/state/modules/parent';
|
||||
import { useUserStore } from '@/state/modules/user';
|
||||
import ParentCard from '../components/ParentCard.vue';
|
||||
import ParentPage from '../components/ParentPage.vue';
|
||||
import { gradeBands, parentChildren, quickActions, type QuickAction, type SubjectTrend } from '../utils/data';
|
||||
import { gradeBands, quickActions, type QuickAction, type SubjectTrend } from '../utils/data';
|
||||
|
||||
const activeChild = parentChildren[0];
|
||||
const DEFAULT_AVATAR = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/logo.png';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const parentStore = useParentStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const { activeChild } = storeToRefs(parentStore);
|
||||
const parentAvatarUrl = computed(() => userInfo.value.avatarUrl || DEFAULT_AVATAR);
|
||||
|
||||
onShow(async () => {
|
||||
try {
|
||||
if (!userInfo.value.id) {
|
||||
await userStore.fetchParentUserInfo();
|
||||
}
|
||||
await parentStore.fetchBindChildren(userInfo.value.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
const trendText = (status: SubjectTrend['status']) => {
|
||||
const map: Record<SubjectTrend['status'], string> = {
|
||||
@ -105,13 +127,8 @@ const handleQuickAction = (item: QuickAction) => {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
|
||||
flex-shrink: 0;
|
||||
background: #ecfeff;
|
||||
box-shadow: 0 12rpx 28rpx rgba(8, 145, 178, 0.24);
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
<view class="account-card">
|
||||
<ParentCard>
|
||||
<view class="account-row">
|
||||
<view class="account-avatar">家</view>
|
||||
<image class="account-avatar" :src="parentAvatarUrl" mode="aspectFill" />
|
||||
<view class="account-main">
|
||||
<text class="phone">{{ displayPhone }}</text>
|
||||
<text class="nickname">{{ displayNickname }}</text>
|
||||
<text class="current-child">当前孩子:{{ activeChild.name }} · {{ activeChild.className || '暂无年级' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@ -28,10 +28,19 @@
|
||||
<text class="child-account">{{ child.account }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bind-btn" @click="bindChild">+ 绑定孩子账号</view>
|
||||
<view class="bind-actions">
|
||||
<view class="bind-action-btn" @click="openManualBind">手动绑定</view>
|
||||
<view class="bind-action-btn primary" @click="onScanBind">扫码绑定</view>
|
||||
</view>
|
||||
</ParentCard>
|
||||
</view>
|
||||
|
||||
<ManualBindChild
|
||||
:visible="manualBindVisible"
|
||||
@update:visible="manualBindVisible = $event"
|
||||
@success="onBindSuccess"
|
||||
/>
|
||||
|
||||
<view class="menu-card">
|
||||
<ParentCard>
|
||||
<view class="menu-item" @click="showAbout">
|
||||
@ -50,85 +59,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { getParentBindChildList, type ParentBindChildItem } from '@/api/parent';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useParentStore } from '@/state/modules/parent';
|
||||
import { useUserStore } from '@/state/modules/user';
|
||||
import ParentCard from '../components/ParentCard.vue';
|
||||
import ParentPage from '../components/ParentPage.vue';
|
||||
import ManualBindChild from '../components/ManualBindChild.vue';
|
||||
import { scanAndGoBind } from '../utils/scanBind';
|
||||
|
||||
interface ChildItem {
|
||||
bindId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
account: string;
|
||||
className: string;
|
||||
}
|
||||
const DEFAULT_AVATAR = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/logo.png';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const loading = ref(false);
|
||||
const parentStore = useParentStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const { childrenList, activeChild, activeChildIndex, loading } = storeToRefs(parentStore);
|
||||
const parentAvatarUrl = computed(() => userInfo.value.avatarUrl || DEFAULT_AVATAR);
|
||||
const parentPhone = ref('');
|
||||
const childrenList = ref<ChildItem[]>([]);
|
||||
const activeChildIndex = ref(0);
|
||||
const manualBindVisible = ref(false);
|
||||
|
||||
const activeChild = computed(() => childrenList.value[activeChildIndex.value] || {
|
||||
name: '--',
|
||||
className: '--',
|
||||
account: '',
|
||||
id: '',
|
||||
bindId: '',
|
||||
const displayNickname = computed(() => {
|
||||
const nick = userInfo.value.nickName;
|
||||
if (nick != null && String(nick).trim() !== '') {
|
||||
return String(nick).trim();
|
||||
}
|
||||
const phone = userInfo.value.phone || parentPhone.value;
|
||||
return phone?.trim() || '--';
|
||||
});
|
||||
|
||||
const displayPhone = computed(() => maskPhone(parentPhone.value));
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
const value = (phone || '').trim();
|
||||
if (!value) return '--';
|
||||
if (value.length < 7) return value;
|
||||
return `${value.slice(0, 3)}****${value.slice(-4)}`;
|
||||
};
|
||||
|
||||
const mapBindRow = (row: ParentBindChildItem): ChildItem => ({
|
||||
bindId: String(row.id ?? row.child?.id ?? ''),
|
||||
id: String(row.child?.id ?? row.id ?? ''),
|
||||
name: row.child?.name || row.child?.nickname || '未命名',
|
||||
account: row.child?.account || '',
|
||||
className: row.child?.gradeName || '',
|
||||
});
|
||||
|
||||
const fetchChildren = async () => {
|
||||
loading.value = true;
|
||||
onShow(async () => {
|
||||
parentPhone.value = userInfo.value.phone || parentPhone.value;
|
||||
if (childrenList.value.length) return;
|
||||
try {
|
||||
const res = await getParentBindChildList();
|
||||
const rows = res.data?.rows || [];
|
||||
childrenList.value = rows.map(mapBindRow);
|
||||
parentPhone.value = rows.find((item) => item.parent?.phone)?.parent?.phone || '';
|
||||
if (activeChildIndex.value >= childrenList.value.length) {
|
||||
activeChildIndex.value = 0;
|
||||
if (!userInfo.value.id) {
|
||||
await userStore.fetchParentUserInfo();
|
||||
}
|
||||
} catch (e: any) {
|
||||
childrenList.value = [];
|
||||
uni.showToast({ title: e?.message || '获取孩子列表失败', icon: 'none' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
await parentStore.fetchBindChildren(userInfo.value.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
fetchChildren();
|
||||
});
|
||||
|
||||
const selectChild = (index: number) => {
|
||||
activeChildIndex.value = index;
|
||||
parentStore.selectChildByIndex(index);
|
||||
uni.showToast({
|
||||
title: `已切换到${childrenList.value[index]?.name || '孩子'}`,
|
||||
icon: 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const bindChild = () => {
|
||||
uni.showToast({
|
||||
title: '绑定功能建设中',
|
||||
icon: 'none',
|
||||
});
|
||||
const openManualBind = () => {
|
||||
manualBindVisible.value = true;
|
||||
};
|
||||
|
||||
const onScanBind = () => {
|
||||
scanAndGoBind();
|
||||
};
|
||||
|
||||
const onBindSuccess = async () => {
|
||||
try {
|
||||
if (!userInfo.value.id) {
|
||||
await userStore.fetchParentUserInfo();
|
||||
}
|
||||
await parentStore.fetchBindChildren(userInfo.value.id);
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '刷新孩子列表失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const showAbout = () => {
|
||||
@ -166,13 +161,8 @@ const handleLogout = () => {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 38rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(180deg, #67e8f9 0%, #0891b2 100%);
|
||||
flex-shrink: 0;
|
||||
background: #ecfeff;
|
||||
box-shadow: 0 12rpx 26rpx rgba(8, 145, 178, 0.22);
|
||||
}
|
||||
|
||||
@ -181,7 +171,7 @@ const handleLogout = () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.phone {
|
||||
.nickname {
|
||||
display: block;
|
||||
color: #164e63;
|
||||
font-size: 38rpx;
|
||||
@ -240,9 +230,15 @@ const handleLogout = () => {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.bind-btn {
|
||||
height: 78rpx;
|
||||
.bind-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.bind-action-btn {
|
||||
flex: 1;
|
||||
height: 78rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx dashed #67e8f9;
|
||||
display: flex;
|
||||
@ -251,6 +247,14 @@ const handleLogout = () => {
|
||||
color: #0891b2;
|
||||
font-size: 26rpx;
|
||||
font-weight: 900;
|
||||
|
||||
&.primary {
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #0891b2, #06b6d4);
|
||||
box-shadow: 0 12rpx 26rpx rgba(8, 145, 178, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
@click="switchTab(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
@ -77,63 +77,116 @@
|
||||
</ParentCard>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'answers'">
|
||||
<view v-if="activeTab === 'answers'" class="answers-panel">
|
||||
<ParentCard class="section-card" title="答题卡详情" subtitle="点击题号查看每道题的答题情况">
|
||||
<view class="answer-legend">
|
||||
<view><text class="legend-dot correct"></text>正确</view>
|
||||
<view><text class="legend-dot wrong"></text>错误</view>
|
||||
<view><text class="legend-dot unanswered"></text>未答</view>
|
||||
</view>
|
||||
<view class="question-grid">
|
||||
<view v-if="pageLoading && !questionSummaries.length" class="answers-loading">
|
||||
<xy-Empty type="loading" content="加载答题卡..." />
|
||||
</view>
|
||||
<view v-else-if="questionSummaries.length" class="question-grid">
|
||||
<view
|
||||
v-for="question in detail.questions"
|
||||
v-for="question in questionSummaries"
|
||||
:key="question.no"
|
||||
class="question-no"
|
||||
:class="[question.status, { active: selectedNo === question.no }]"
|
||||
@click="selectedNo = question.no"
|
||||
@click="selectQuestion(question.no)"
|
||||
>
|
||||
{{ question.no }}
|
||||
</view>
|
||||
</view>
|
||||
<xy-Empty v-else type="task" content="暂无题目数据" />
|
||||
</ParentCard>
|
||||
|
||||
<ParentCard class="section-card" title="当前题目详情">
|
||||
<ParentCard v-if="selectedNo && selectedQuestionSummary" class="section-card" title="当前题目详情">
|
||||
<view class="question-head">
|
||||
<text>第 {{ selectedQuestion.no }} 题</text>
|
||||
<text>{{ selectedQuestion.type }}</text>
|
||||
<text :class="selectedQuestion.status === 'correct' ? 'status-correct' : 'status-wrong'">
|
||||
{{ selectedQuestion.status === 'correct' ? '正确' : '错误' }}
|
||||
<text>第 {{ selectedQuestionSummary.no }} 题</text>
|
||||
<text>{{ selectedQuestionSummary.type }}</text>
|
||||
<text :class="statusClass(selectedQuestionSummary.status)">
|
||||
{{ statusLabel(selectedQuestionSummary.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="detail-fields">
|
||||
<view v-if="detailLoadingNo === selectedNo" class="answers-loading">
|
||||
<xy-Empty type="loading" content="加载题目详情..." />
|
||||
</view>
|
||||
<view v-else-if="selectedQuestionDetail" class="detail-fields">
|
||||
<view class="field-card">
|
||||
<text class="field-label">题干</text>
|
||||
<text class="field-content">{{ selectedQuestion.stem }}</text>
|
||||
<view v-if="selectedQuestionDetail.stemHtml" class="field-content rich-content">
|
||||
<rich-text :nodes="selectedQuestionDetail.stemHtml" />
|
||||
</view>
|
||||
<view v-if="selectedQuestionDetail.stemImages?.length" class="stem-images">
|
||||
<image
|
||||
v-for="(img, imgIdx) in selectedQuestionDetail.stemImages"
|
||||
:key="imgIdx"
|
||||
:src="img"
|
||||
mode="widthFix"
|
||||
class="stem-image"
|
||||
@click="previewStemImage(imgIdx)"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="!selectedQuestionDetail.stemHtml && !selectedQuestionDetail.stemImages?.length"
|
||||
class="field-content"
|
||||
>
|
||||
暂无题干
|
||||
</text>
|
||||
</view>
|
||||
<view class="field-card success">
|
||||
<text class="field-label">正确答案</text>
|
||||
<text class="field-content">{{ selectedQuestion.correctAnswer }}</text>
|
||||
<view v-if="selectedQuestionDetail.correctAnswerHtml" class="field-content rich-content">
|
||||
<rich-text :nodes="selectedQuestionDetail.correctAnswerHtml" />
|
||||
</view>
|
||||
<text v-else class="field-content">--</text>
|
||||
</view>
|
||||
<view class="field-card student">
|
||||
<text class="field-label">学员答案</text>
|
||||
<text class="field-content">{{ selectedQuestion.studentAnswer }}</text>
|
||||
<view v-if="selectedQuestionDetail.studentAnswerImages?.length" class="answer-images">
|
||||
<image
|
||||
v-for="(img, imgIdx) in selectedQuestionDetail.studentAnswerImages"
|
||||
:key="imgIdx"
|
||||
:src="img"
|
||||
mode="widthFix"
|
||||
class="answer-image"
|
||||
@click="previewStudentImage(imgIdx)"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="selectedQuestionDetail.studentAnswerHtml" class="field-content rich-content">
|
||||
<rich-text :nodes="selectedQuestionDetail.studentAnswerHtml" />
|
||||
</view>
|
||||
<text
|
||||
v-else-if="selectedQuestionDetail.studentAnswer"
|
||||
class="field-content"
|
||||
>{{ selectedQuestionDetail.studentAnswer }}</text>
|
||||
<text
|
||||
v-else-if="!selectedQuestionDetail.studentAnswerImages?.length"
|
||||
class="field-content"
|
||||
>未作答</text>
|
||||
</view>
|
||||
<view class="field-card">
|
||||
<text class="field-label">得分情况</text>
|
||||
<text class="field-content">{{ selectedQuestion.score }}</text>
|
||||
<text class="field-content">{{ selectedQuestionSummary.score }}</text>
|
||||
</view>
|
||||
<view class="field-card">
|
||||
<text class="field-label">班级得分率</text>
|
||||
<text class="field-content">{{ selectedQuestion.classRate }}</text>
|
||||
<text class="field-content">{{ selectedQuestionSummary.classRate }}</text>
|
||||
</view>
|
||||
<view class="field-card">
|
||||
<text class="field-label">学校得分率</text>
|
||||
<text class="field-content">{{ selectedQuestion.schoolRate }}</text>
|
||||
<text class="field-content">{{ selectedQuestionSummary.schoolRate }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</ParentCard>
|
||||
|
||||
<ParentCard class="section-card" title="每道题具体情况">
|
||||
<scroll-view scroll-x class="question-table-scroll">
|
||||
<view class="table-toggle" @click="showQuestionTable = !showQuestionTable">
|
||||
<text>{{ showQuestionTable ? '收起列表' : '展开查看全部题目' }}</text>
|
||||
<text class="table-toggle-icon">{{ showQuestionTable ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
<scroll-view v-if="showQuestionTable" scroll-x class="question-table-scroll">
|
||||
<view class="question-table">
|
||||
<view class="question-row table-head">
|
||||
<text>题号</text>
|
||||
@ -144,12 +197,17 @@
|
||||
<text>班级得分率</text>
|
||||
<text>学校得分率</text>
|
||||
</view>
|
||||
<view v-for="question in detail.questions" :key="`row-${question.no}`" class="question-row">
|
||||
<view
|
||||
v-for="question in questionSummaries"
|
||||
:key="`row-${question.no}`"
|
||||
class="question-row"
|
||||
@click="selectQuestion(question.no)"
|
||||
>
|
||||
<text>{{ question.no }}</text>
|
||||
<text>{{ question.type }}</text>
|
||||
<text>{{ question.score }}</text>
|
||||
<text :class="question.status === 'correct' ? 'status-correct' : 'status-wrong'">
|
||||
{{ question.status === 'correct' ? '正确' : '错误' }}
|
||||
<text :class="statusClass(question.status)">
|
||||
{{ statusLabel(question.status) }}
|
||||
</text>
|
||||
<text>{{ question.knowledge }}</text>
|
||||
<text>{{ question.classRate }}</text>
|
||||
@ -158,9 +216,9 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
</ParentCard>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="activeTab === 'knowledge'">
|
||||
<ParentCard class="section-card" title="错题知识点占比" subtitle="用于判断最近应优先补强的知识点">
|
||||
<view class="knowledge-summary">
|
||||
<view class="wrong-total">
|
||||
@ -223,9 +281,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import { getJobAnalyse, getPaperRecordDetail } from '@/api/homework';
|
||||
import ParentCard from '../components/ParentCard.vue';
|
||||
import ParentPage from '../components/ParentPage.vue';
|
||||
import { reportDetails, reportList, type ReportKnowledgeScore } from '../utils/data';
|
||||
import {
|
||||
reportDetails,
|
||||
type ReportKnowledgeScore,
|
||||
type ReportQuestion,
|
||||
type ReportQuestionSummary,
|
||||
type ReportScoreBlock,
|
||||
} from '../utils/data';
|
||||
import {
|
||||
mapPaperQuestion,
|
||||
mapPaperQuestionSummaries,
|
||||
normalizeLegacyQuestion,
|
||||
} from '../utils/paperDetail';
|
||||
|
||||
type DetailTab = 'analysis' | 'answers' | 'knowledge';
|
||||
type KnowledgeLevel = 'all' | 'weak' | 'pass' | 'good';
|
||||
@ -243,19 +313,210 @@ const knowledgeTabs: { key: KnowledgeLevel; label: string }[] = [
|
||||
{ key: 'good', label: '良好' },
|
||||
];
|
||||
|
||||
const reportId = ref('r1');
|
||||
const REPORT_FLAG = 1;
|
||||
|
||||
const recordId = ref('');
|
||||
const reportName = ref('');
|
||||
const reportSubject = ref('');
|
||||
const reportCategory = ref('');
|
||||
const reportDate = ref('');
|
||||
const reportScoreRate = ref('');
|
||||
|
||||
const activeTab = ref<DetailTab>('analysis');
|
||||
const selectedNo = ref(1);
|
||||
const selectedNo = ref(0);
|
||||
const activeKnowledgeLevel = ref<KnowledgeLevel>('all');
|
||||
const pageLoading = ref(false);
|
||||
const showQuestionTable = ref(false);
|
||||
const detailLoadingNo = ref(0);
|
||||
const rawTitleItems = ref<Record<string, unknown>[]>([]);
|
||||
const questionSummaries = ref<ReportQuestionSummary[]>([]);
|
||||
const questionDetailCache = ref<Record<number, ReportQuestion>>({});
|
||||
const paperDetail = ref<Record<string, unknown>>({});
|
||||
const jobAnalyse = ref<Record<string, unknown>>({});
|
||||
|
||||
onLoad((query) => {
|
||||
reportId.value = String(query?.id || 'r1');
|
||||
recordId.value = String(query?.id || '');
|
||||
reportName.value = query?.name ? decodeURIComponent(String(query.name)) : '';
|
||||
reportSubject.value = query?.subject ? decodeURIComponent(String(query.subject)) : '';
|
||||
reportCategory.value = query?.category ? decodeURIComponent(String(query.category)) : '';
|
||||
reportDate.value = String(query?.date || '');
|
||||
reportScoreRate.value = query?.score != null ? String(query.score) : '';
|
||||
loadReportData();
|
||||
});
|
||||
|
||||
const report = computed(() => reportList.find((item) => item.id === reportId.value) || reportList[0]);
|
||||
const detail = computed(() => reportDetails[report.value.id] || reportDetails.r1);
|
||||
const selectedQuestion = computed(() => detail.value.questions.find((item) => item.no === selectedNo.value) || detail.value.questions[0]);
|
||||
const wrongCount = computed(() => detail.value.questions.filter((item) => item.status === 'wrong').length);
|
||||
const mockDetail = computed(() => reportDetails[recordId.value] || reportDetails.r1);
|
||||
|
||||
const formatRank = (rank?: unknown, total?: unknown) => {
|
||||
if (rank == null || rank === '' || total == null || total === '') return '';
|
||||
return `${rank}/${total}`;
|
||||
};
|
||||
|
||||
const scoreBlocks = computed<ReportScoreBlock[]>(() => {
|
||||
const vo = (jobAnalyse.value?.jobReportVo || {}) as Record<string, unknown>;
|
||||
const mark = Number(vo.markScore ?? paperDetail.value.score ?? 0);
|
||||
const total = Number(vo.totalScore ?? paperDetail.value.totalScore ?? 0);
|
||||
const rateFromScore =
|
||||
total > 0 ? `${Math.round((mark / total) * 1000) / 10}%` : reportScoreRate.value ? `${reportScoreRate.value}%` : '--';
|
||||
|
||||
return [
|
||||
{ label: '我的得分', value: String(mark), desc: `总分 ${total || '--'}` },
|
||||
{ label: '满分', value: String(total || '--'), desc: '本次卷面' },
|
||||
{ label: '得分率', value: rateFromScore, desc: total > 0 && mark / total >= 0.8 ? '超过多数同学' : '' },
|
||||
];
|
||||
});
|
||||
|
||||
const ranks = computed(() => {
|
||||
const vo = (jobAnalyse.value?.jobReportVo || {}) as Record<string, unknown>;
|
||||
const paper = paperDetail.value;
|
||||
const classRank = formatRank(
|
||||
vo.classRank ?? paper.classRank ?? paper.classRanking,
|
||||
vo.classTotal ?? paper.classTotal ?? paper.classRankTotal,
|
||||
);
|
||||
const gradeRank = formatRank(
|
||||
vo.gradeRank ?? paper.gradeRank ?? paper.gradeRanking,
|
||||
vo.gradeTotal ?? paper.gradeTotal ?? paper.gradeRankTotal,
|
||||
);
|
||||
|
||||
return [
|
||||
{ label: '班级排名', value: classRank || mockDetail.value.ranks[0]?.value || '--' },
|
||||
{ label: '年级排名', value: gradeRank || mockDetail.value.ranks[1]?.value || '--' },
|
||||
];
|
||||
});
|
||||
|
||||
const report = computed(() => ({
|
||||
category: reportCategory.value || getPaperTestTypeLabelFromPaper(),
|
||||
title: reportName.value || String(paperDetail.value.paperName || '作业详情'),
|
||||
date: reportDate.value,
|
||||
subject: reportSubject.value || String(paperDetail.value.subjectName || '多科'),
|
||||
}));
|
||||
|
||||
const getPaperTestTypeLabelFromPaper = () => {
|
||||
const type = Number(paperDetail.value.type);
|
||||
if (!type) return '';
|
||||
const map: Record<number, string> = {
|
||||
1: '单元测试',
|
||||
2: '周测',
|
||||
3: '月测',
|
||||
4: '随堂练习',
|
||||
5: '课前预习',
|
||||
};
|
||||
return map[type] || '';
|
||||
};
|
||||
|
||||
const detail = computed(() => ({
|
||||
...mockDetail.value,
|
||||
scoreBlocks: scoreBlocks.value,
|
||||
ranks: ranks.value,
|
||||
}));
|
||||
|
||||
const selectedQuestionSummary = computed(
|
||||
() => questionSummaries.value.find((item) => item.no === selectedNo.value) || null,
|
||||
);
|
||||
const selectedQuestionDetail = computed(() =>
|
||||
selectedNo.value ? questionDetailCache.value[selectedNo.value] : undefined,
|
||||
);
|
||||
const wrongCount = computed(
|
||||
() =>
|
||||
questionSummaries.value.filter((item) => item.status === 'wrong' || item.status === 'unanswered').length,
|
||||
);
|
||||
|
||||
const statusLabel = (status?: ReportQuestion['status']) => {
|
||||
if (status === 'correct') return '正确';
|
||||
if (status === 'unanswered') return '未答';
|
||||
return '错误';
|
||||
};
|
||||
|
||||
const statusClass = (status?: ReportQuestion['status']) => {
|
||||
if (status === 'correct') return 'status-correct';
|
||||
if (status === 'unanswered') return 'status-muted';
|
||||
return 'status-wrong';
|
||||
};
|
||||
|
||||
const switchTab = (key: DetailTab) => {
|
||||
activeTab.value = key;
|
||||
if (key === 'answers') {
|
||||
ensureDefaultQuestion();
|
||||
}
|
||||
};
|
||||
|
||||
/** 进入答题卡时默认选中并加载第一题 */
|
||||
const ensureDefaultQuestion = () => {
|
||||
if (!questionSummaries.value.length) return;
|
||||
if (!selectedNo.value) {
|
||||
selectedNo.value = questionSummaries.value[0].no;
|
||||
}
|
||||
loadQuestionDetail(selectedNo.value);
|
||||
};
|
||||
|
||||
const loadQuestionDetail = (no: number) => {
|
||||
if (questionDetailCache.value[no]) {
|
||||
if (detailLoadingNo.value === no) detailLoadingNo.value = 0;
|
||||
return;
|
||||
}
|
||||
if (detailLoadingNo.value === no) return;
|
||||
|
||||
detailLoadingNo.value = no;
|
||||
try {
|
||||
const index = questionSummaries.value.findIndex((item) => item.no === no);
|
||||
if (index >= 0 && rawTitleItems.value[index]) {
|
||||
questionDetailCache.value = {
|
||||
...questionDetailCache.value,
|
||||
[no]: mapPaperQuestion(rawTitleItems.value[index], index),
|
||||
};
|
||||
} else {
|
||||
const mockQuestion = mockDetail.value.questions.find((item) => Number(item.no) === no);
|
||||
if (mockQuestion) {
|
||||
questionDetailCache.value = {
|
||||
...questionDetailCache.value,
|
||||
[no]: normalizeLegacyQuestion(mockQuestion as unknown as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (detailLoadingNo.value === no) {
|
||||
detailLoadingNo.value = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectQuestion = (no: number) => {
|
||||
selectedNo.value = no;
|
||||
loadQuestionDetail(no);
|
||||
};
|
||||
|
||||
const loadReportData = async () => {
|
||||
if (!recordId.value) return;
|
||||
|
||||
pageLoading.value = true;
|
||||
try {
|
||||
const [detailRes, analyseRes] = await Promise.all([
|
||||
getPaperRecordDetail({ recordId: recordId.value, reportFlag: REPORT_FLAG }),
|
||||
getJobAnalyse({ recordId: recordId.value }).catch(() => ({ data: {} })),
|
||||
]);
|
||||
|
||||
paperDetail.value = (detailRes.data || {}) as Record<string, unknown>;
|
||||
jobAnalyse.value = (analyseRes.data || {}) as Record<string, unknown>;
|
||||
|
||||
const titleList = paperDetail.value.titleVoList || paperDetail.value.subjectTitleVoList;
|
||||
rawTitleItems.value = Array.isArray(titleList) ? (titleList as Record<string, unknown>[]) : [];
|
||||
questionSummaries.value = mapPaperQuestionSummaries(rawTitleItems.value);
|
||||
|
||||
if (!reportName.value) {
|
||||
reportName.value = String(paperDetail.value.paperName || '');
|
||||
}
|
||||
if (!reportSubject.value) {
|
||||
reportSubject.value = String(paperDetail.value.subjectName || '');
|
||||
}
|
||||
if (questionSummaries.value.length && !selectedNo.value) {
|
||||
selectedNo.value = questionSummaries.value[0].no;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[parent-report-detail] load err', err);
|
||||
uni.showToast({ title: '报告加载失败', icon: 'none' });
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const knowledgeRate = (item: ReportKnowledgeScore) => Math.round((item.score / item.fullScore) * 100);
|
||||
|
||||
@ -271,6 +532,18 @@ const filteredKnowledgeScores = computed(() => {
|
||||
return detail.value.knowledgeScores.filter((item) => knowledgeLevelOf(item) === activeKnowledgeLevel.value);
|
||||
});
|
||||
|
||||
const previewStemImage = (index: number) => {
|
||||
const urls = selectedQuestionDetail.value?.stemImages || [];
|
||||
if (!urls.length) return;
|
||||
uni.previewImage({ urls, current: urls[index] });
|
||||
};
|
||||
|
||||
const previewStudentImage = (index: number) => {
|
||||
const urls = selectedQuestionDetail.value?.studentAnswerImages || [];
|
||||
if (!urls.length) return;
|
||||
uni.previewImage({ urls, current: urls[index] });
|
||||
};
|
||||
|
||||
const downloadReport = () => {
|
||||
uni.showToast({
|
||||
title: '报告生成中',
|
||||
@ -289,6 +562,9 @@ const goHome = () => {
|
||||
flex-direction: column;
|
||||
gap: 36rpx;
|
||||
padding-bottom: calc(144rpx + env(safe-area-inset-bottom));
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
@ -464,6 +740,11 @@ const goHome = () => {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.status-muted {
|
||||
color: #64748b;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.answer-legend {
|
||||
display: flex;
|
||||
gap: 28rpx;
|
||||
@ -491,6 +772,37 @@ const goHome = () => {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.legend-dot.unanswered {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.answers-loading {
|
||||
min-height: 220rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.answers-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36rpx;
|
||||
}
|
||||
|
||||
.table-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 16rpx 0;
|
||||
color: #2563eb;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table-toggle-icon {
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.question-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
@ -517,6 +829,12 @@ const goHome = () => {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.question-no.unanswered {
|
||||
border-color: #e2e8f0;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.question-no.active {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4rpx rgba(37, 99, 235, 0.1);
|
||||
@ -535,6 +853,7 @@ const goHome = () => {
|
||||
display: grid;
|
||||
gap: 18rpx;
|
||||
margin-top: 22rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-card {
|
||||
@ -542,6 +861,9 @@ const goHome = () => {
|
||||
border: 2rpx solid #dbeafe;
|
||||
border-radius: 18rpx;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-card.success {
|
||||
@ -576,6 +898,64 @@ const goHome = () => {
|
||||
color: #334155;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.55;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rich-content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rich-content :deep(.formula-text) {
|
||||
font-size: 24rpx;
|
||||
font-family: 'Times New Roman', 'STIXGeneral', Georgia, serif;
|
||||
font-style: italic;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
.rich-content :deep(.formula-img) {
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rich-content :deep(.fill-blank) {
|
||||
display: inline-block;
|
||||
min-width: 60rpx;
|
||||
border-bottom: 3rpx solid #2563eb;
|
||||
}
|
||||
|
||||
.stem-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.stem-image {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.answer-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 8rpx;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.answer-image {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 12rpx;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.question-table-scroll {
|
||||
|
||||
@ -1,38 +1,22 @@
|
||||
<template>
|
||||
<ParentPage active="reports">
|
||||
<view class="filter-row">
|
||||
<picker class="filter-picker" :range="subjectOptions" :value="subjectIndex" @change="onSubjectChange">
|
||||
<view class="filter-pill">
|
||||
<text>{{ subjectOptions[subjectIndex] }}</text>
|
||||
<view class="filter-arrow"></view>
|
||||
</view>
|
||||
</picker>
|
||||
<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="categoryOptions" :value="categoryIndex" @change="onCategoryChange">
|
||||
<view class="filter-pill">
|
||||
<text>{{ categoryOptions[categoryIndex] }}</text>
|
||||
<view class="filter-arrow"></view>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<ParentPage active="reports" @scrolltolower="loadMore">
|
||||
<ParentFilterRow
|
||||
:items="filterItems"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
|
||||
<view v-if="filteredReports.length" class="report-list">
|
||||
<view v-if="reportList.length" class="report-list">
|
||||
<ParentCard
|
||||
v-for="item in filteredReports"
|
||||
v-for="item in reportList"
|
||||
:key="item.id"
|
||||
class="report-card"
|
||||
clickable
|
||||
@click="openReport(item.id)"
|
||||
@click="openReport(item)"
|
||||
>
|
||||
<view class="report-body">
|
||||
<view class="subject-badge" :class="subjectClass(item.subject)">{{ item.subject }}</view>
|
||||
<view class="subject-badge" :class="subjectClass(item.subject)">{{ subjectShort(item.subject) }}</view>
|
||||
<view class="report-main">
|
||||
<view class="category">[{{ item.category }}]</view>
|
||||
<view v-if="item.category" class="category">[{{ item.category }}]</view>
|
||||
<text class="report-title ellipsis-2">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="score-wrap">
|
||||
@ -41,91 +25,209 @@
|
||||
</view>
|
||||
</view>
|
||||
</ParentCard>
|
||||
<view v-if="loadingMore" class="list-tip">加载中...</view>
|
||||
<view v-else-if="!hasMore" class="list-tip">没有更多了</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-card">
|
||||
<view v-else-if="!listLoading" class="empty-card">
|
||||
<xy-Empty type="task" content="暂无符合条件的考试报告" />
|
||||
</view>
|
||||
|
||||
<view v-if="listLoading" class="loading-wrap">
|
||||
<xy-Empty type="loading" content="加载中..." />
|
||||
</view>
|
||||
</ParentPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import {
|
||||
getListCompleteRecord,
|
||||
getPaperTestTypeLabel,
|
||||
normalizeListCompleteRecord,
|
||||
PAPER_TEST_TYPE_OPTIONS,
|
||||
type ListCompleteRecordParams,
|
||||
type PaperRecord,
|
||||
} from '@/api/homework';
|
||||
import { useParentStore } from '@/state/modules/parent';
|
||||
import { formatTime } from '@/utils/format';
|
||||
import { SUBJECT_FILTER_OPTIONS, type SubjectFilterOption } from '@/utils/subject';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ParentCard from '../components/ParentCard.vue';
|
||||
import ParentFilterRow from '../components/ParentFilterRow.vue';
|
||||
import ParentPage from '../components/ParentPage.vue';
|
||||
import { categoryOptions, gradeOptions, reportList, subjectOptions, type ReportItem } from '../utils/data';
|
||||
|
||||
interface ReportCard {
|
||||
id: string;
|
||||
subject: string;
|
||||
category: string;
|
||||
title: string;
|
||||
score: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
const parentStore = useParentStore();
|
||||
const { activeChild } = storeToRefs(parentStore);
|
||||
|
||||
const typeOptionLabels = PAPER_TEST_TYPE_OPTIONS.map((item) => item.label);
|
||||
|
||||
const subjectOptions = ref<SubjectFilterOption[]>([...SUBJECT_FILTER_OPTIONS]);
|
||||
const subjectIndex = ref(0);
|
||||
const gradeIndex = ref(0);
|
||||
const categoryIndex = ref(0);
|
||||
const typeIndex = ref(0);
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const subject = subjectOptions[subjectIndex.value];
|
||||
const grade = gradeOptions[gradeIndex.value];
|
||||
const category = categoryOptions[categoryIndex.value];
|
||||
return reportList.filter((item) => {
|
||||
const subjectMatched = subject === '全部学科' || item.subject === subject;
|
||||
const gradeMatched = grade === '全部年级' || item.grade === grade;
|
||||
const categoryMatched = category === '全部类型' || item.category === category;
|
||||
return subjectMatched && gradeMatched && categoryMatched;
|
||||
});
|
||||
const reportList = ref<ReportCard[]>([]);
|
||||
const listLoading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const currentPage = ref(1);
|
||||
|
||||
const subjectOptionLabels = computed(() => subjectOptions.value.map((item) => item.label));
|
||||
|
||||
const filterItems = computed(() => [
|
||||
{ key: 'subject', range: subjectOptionLabels.value, value: subjectIndex.value },
|
||||
{ key: 'type', range: typeOptionLabels, value: typeIndex.value },
|
||||
]);
|
||||
|
||||
const getScoreRate = (item: PaperRecord) => {
|
||||
const total = Number(item.totalScore);
|
||||
const score = Number(item.score);
|
||||
if (total > 0) {
|
||||
return Math.round((score / total) * 1000) / 10;
|
||||
}
|
||||
return score || 0;
|
||||
};
|
||||
|
||||
const mapRecord = (item: PaperRecord): ReportCard => ({
|
||||
id: String(item.id),
|
||||
subject: item.subjectName || '多科',
|
||||
category: getPaperTestTypeLabel(item.type),
|
||||
title: item.paperName || '',
|
||||
score: getScoreRate(item),
|
||||
date: formatTime(item.markTime || item.submitTime || item.endTime, 'YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
const onSubjectChange = (e: any) => { subjectIndex.value = Number(e.detail.value); };
|
||||
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); };
|
||||
const onCategoryChange = (e: any) => { categoryIndex.value = Number(e.detail.value); };
|
||||
const buildRequestParams = (): ListCompleteRecordParams => {
|
||||
const params: ListCompleteRecordParams = {
|
||||
current: currentPage.value,
|
||||
resourceType: 0,
|
||||
};
|
||||
|
||||
if (activeChild.value.id) {
|
||||
params.userId = activeChild.value.id;
|
||||
}
|
||||
|
||||
const subject = subjectOptions.value[subjectIndex.value];
|
||||
if (subject?.subjectId) {
|
||||
params.subjectId = subject.subjectId;
|
||||
}
|
||||
|
||||
const typeOption = PAPER_TEST_TYPE_OPTIONS[typeIndex.value];
|
||||
if (typeOption?.value) {
|
||||
params.type = typeOption.value;
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const fetchReports = async (append = false) => {
|
||||
if (!activeChild.value.id) {
|
||||
reportList.value = [];
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getListCompleteRecord(buildRequestParams());
|
||||
const { rows, totalRows } = normalizeListCompleteRecord(res.data);
|
||||
const mapped = rows.map(mapRecord);
|
||||
|
||||
if (append) {
|
||||
const exists = new Set(reportList.value.map((item) => item.id));
|
||||
reportList.value = reportList.value.concat(mapped.filter((item) => !exists.has(item.id)));
|
||||
} else {
|
||||
reportList.value = mapped;
|
||||
}
|
||||
|
||||
hasMore.value = reportList.value.length < totalRows;
|
||||
};
|
||||
|
||||
const refreshReports = async () => {
|
||||
listLoading.value = true;
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
try {
|
||||
await fetchReports(false);
|
||||
} catch (err) {
|
||||
console.error('[parent-reports] fetch err', err);
|
||||
reportList.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 fetchReports(true);
|
||||
} catch (err) {
|
||||
console.error('[parent-reports] load more err', err);
|
||||
currentPage.value -= 1;
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubjectChange = (e: { detail: { value: string } }) => {
|
||||
subjectIndex.value = Number(e.detail.value);
|
||||
refreshReports();
|
||||
};
|
||||
|
||||
const onTypeChange = (e: { detail: { value: string } }) => {
|
||||
typeIndex.value = Number(e.detail.value);
|
||||
refreshReports();
|
||||
};
|
||||
|
||||
const onFilterChange = (payload: { key: string; value: number }) => {
|
||||
if (payload.key === 'subject') {
|
||||
subjectIndex.value = payload.value;
|
||||
refreshReports();
|
||||
return;
|
||||
}
|
||||
if (payload.key === 'type') {
|
||||
typeIndex.value = payload.value;
|
||||
refreshReports();
|
||||
}
|
||||
};
|
||||
|
||||
const formatScore = (score: number) => `${score.toFixed(1).replace(/\.0$/, '')}%`;
|
||||
|
||||
const subjectClass = (subject: ReportItem['subject']) => {
|
||||
if (subject === '数学') return 'math';
|
||||
if (subject === '语文') return 'chinese';
|
||||
if (subject === '英语') return 'english';
|
||||
const subjectShort = (subject: string) => {
|
||||
if (!subject) return '多科';
|
||||
return subject.length > 2 ? subject.slice(0, 2) : subject;
|
||||
};
|
||||
|
||||
const subjectClass = (subject: string) => {
|
||||
if (subject.includes('数学')) return 'math';
|
||||
if (subject.includes('语文')) return 'chinese';
|
||||
if (subject.includes('英语')) return 'english';
|
||||
return 'multi';
|
||||
};
|
||||
|
||||
const openReport = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages/parent/reports/detail?id=${id}` });
|
||||
const openReport = (item: ReportCard) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/parent/reports/detail?id=${item.id}&name=${encodeURIComponent(item.title)}&subject=${encodeURIComponent(item.subject)}&category=${encodeURIComponent(item.category)}&date=${item.date}&score=${item.score}`,
|
||||
});
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
refreshReports();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
padding-top: 12rpx;
|
||||
}
|
||||
|
||||
.filter-picker {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
color: #164e63;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow:
|
||||
0 8rpx 22rpx rgba(8, 145, 178, 0.08),
|
||||
inset 0 -2rpx 0 rgba(207, 250, 254, 0.9);
|
||||
}
|
||||
|
||||
.filter-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8rpx solid transparent;
|
||||
border-right: 8rpx solid transparent;
|
||||
border-top: 10rpx solid #0891b2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.report-list {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
@ -203,4 +305,16 @@ const openReport = (id: string) => {
|
||||
height: 520rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.loading-wrap {
|
||||
height: 520rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.list-tip {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 24rpx;
|
||||
padding: 12rpx 0 24rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { SUBJECT_FILTER_OPTIONS } from '@/utils/subject';
|
||||
|
||||
export interface ParentChild {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -53,14 +55,29 @@ export interface ReportDifficultyRow {
|
||||
rate: string;
|
||||
}
|
||||
|
||||
/** 答题卡列表用的轻量字段(不含 HTML) */
|
||||
export type ReportQuestionSummary = Pick<
|
||||
ReportQuestion,
|
||||
'no' | 'type' | 'score' | 'status' | 'classRate' | 'schoolRate' | 'knowledge'
|
||||
>;
|
||||
|
||||
export interface ReportQuestion {
|
||||
no: number;
|
||||
type: string;
|
||||
score: string;
|
||||
status: 'correct' | 'wrong';
|
||||
stem: string;
|
||||
correctAnswer: string;
|
||||
status: 'correct' | 'wrong' | 'unanswered';
|
||||
/** 供 rich-text 渲染的题干 HTML */
|
||||
stemHtml: string;
|
||||
/** 题干中的图片(与学生端一致,从 HTML 中拆出单独展示) */
|
||||
stemImages: string[];
|
||||
/** 供 rich-text 渲染的正确答案 HTML */
|
||||
correctAnswerHtml: string;
|
||||
/** 纯文本学员答案(选择题等) */
|
||||
studentAnswer: string;
|
||||
/** 供 rich-text 渲染的学员答案 HTML(富文本作答) */
|
||||
studentAnswerHtml: string;
|
||||
/** 学员上传的图片答案 */
|
||||
studentAnswerImages: string[];
|
||||
classRate: string;
|
||||
schoolRate: string;
|
||||
knowledge: string;
|
||||
@ -271,7 +288,7 @@ export const reportDetails: Record<string, ReportDetail> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const subjectOptions = ['全部学科', '数学', '语文', '英语', '多科'];
|
||||
export const subjectOptions = SUBJECT_FILTER_OPTIONS.map((item) => item.label);
|
||||
export const gradeOptions = ['全部年级', '初一', '初二', '初三'];
|
||||
export const categoryOptions = ['全部类型', '周作业', '月考'];
|
||||
|
||||
|
||||
169
src/pages/parent/utils/paperDetail.ts
Normal file
169
src/pages/parent/utils/paperDetail.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import {
|
||||
extractAnswerImages,
|
||||
extractTitleImages,
|
||||
formatQuestionHtml,
|
||||
getQuestionAnswerSource,
|
||||
getQuestionStemSource,
|
||||
isHtmlContent,
|
||||
} from '@/utils/questionHtml';
|
||||
import type { ReportQuestion, ReportQuestionSummary } from './data';
|
||||
|
||||
export type QuestionStatus = 'correct' | 'wrong' | 'unanswered';
|
||||
|
||||
export const normalizeAnswer = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value.join('、');
|
||||
if (value == null) return '';
|
||||
return String(value)
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/"/g, '')
|
||||
.replace(/,/g, '、');
|
||||
};
|
||||
|
||||
export const formatRateText = (value: unknown) => {
|
||||
if (value == null || value === '') return '--';
|
||||
const num = Number(value);
|
||||
if (!Number.isNaN(num)) {
|
||||
const rate = num <= 1 ? num * 100 : num;
|
||||
return `${Math.round(rate * 10) / 10}%`;
|
||||
}
|
||||
const text = String(value);
|
||||
return text.includes('%') ? text : `${text}%`;
|
||||
};
|
||||
|
||||
export const getQuestionStatus = (item: Record<string, unknown>): QuestionStatus => {
|
||||
const correct =
|
||||
item.correctResult === 1 ||
|
||||
item.correctResult === true ||
|
||||
item.markFlag === 1 ||
|
||||
item.markFlag === true;
|
||||
if (correct) return 'correct';
|
||||
if (!item.submitAnswer && !item.submitAnswerPic) return 'unanswered';
|
||||
return 'wrong';
|
||||
};
|
||||
|
||||
const stripImageUrlsFromText = (text: string) => {
|
||||
let result = text;
|
||||
const links = text.match(/https?:\/\/[^\s,]+/gi) || [];
|
||||
links.forEach((link) => {
|
||||
result = result.replace(link, '');
|
||||
});
|
||||
return result.replace(/[,,、\s]+$/g, '').replace(/^[,,、\s]+/g, '').trim();
|
||||
};
|
||||
|
||||
const buildStudentAnswer = (item: Record<string, unknown>) => {
|
||||
const studentAnswerImages = extractAnswerImages(item);
|
||||
let textAnswer = normalizeAnswer(item.submitAnswer);
|
||||
|
||||
if (textAnswer) {
|
||||
const onlyUrls = stripImageUrlsFromText(textAnswer) === '' && studentAnswerImages.length > 0;
|
||||
if (onlyUrls) {
|
||||
textAnswer = '';
|
||||
} else {
|
||||
textAnswer = stripImageUrlsFromText(textAnswer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!textAnswer && !studentAnswerImages.length) {
|
||||
return { studentAnswer: '未作答', studentAnswerHtml: '', studentAnswerImages: [] };
|
||||
}
|
||||
|
||||
if (textAnswer && isHtmlContent(textAnswer)) {
|
||||
return {
|
||||
studentAnswer: '',
|
||||
studentAnswerHtml: formatQuestionHtml(textAnswer),
|
||||
studentAnswerImages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
studentAnswer: textAnswer,
|
||||
studentAnswerHtml: '',
|
||||
studentAnswerImages,
|
||||
};
|
||||
};
|
||||
|
||||
const getQuestionFullScore = (item: Record<string, unknown>) => Number(item.score ?? item.titleScore ?? 0);
|
||||
|
||||
const getQuestionActualScore = (item: Record<string, unknown>) =>
|
||||
Number(item.actualScore ?? item.markScore ?? item.answerScore ?? item.gotScore ?? 0);
|
||||
|
||||
const formatQuestionScoreText = (item: Record<string, unknown>) => {
|
||||
const fullScore = getQuestionFullScore(item);
|
||||
const actualScore = getQuestionActualScore(item);
|
||||
return `${actualScore}/${fullScore || actualScore || 0}`;
|
||||
};
|
||||
|
||||
export const mapPaperQuestionSummary = (item: Record<string, unknown>, index: number): ReportQuestionSummary => {
|
||||
return {
|
||||
no: Number(item.titleSort ?? item.sort ?? index + 1),
|
||||
type: String(item.titleChannelTypeName || item.chanelTypeName || '综合题'),
|
||||
score: formatQuestionScoreText(item),
|
||||
status: getQuestionStatus(item),
|
||||
classRate: formatRateText(
|
||||
item.classScoreRate ?? item.classRate ?? item.classAvgScoreRate ?? item.classGotScoreRate,
|
||||
),
|
||||
schoolRate: formatRateText(
|
||||
item.schoolScoreRate ?? item.schoolRate ?? item.schoolAvgScoreRate ?? item.schoolGotScoreRate,
|
||||
),
|
||||
knowledge: String(item.knowledgeName || item.knowledge || item.knowledgePointName || '待归类'),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapPaperQuestionSummaries = (list: unknown[]) =>
|
||||
(Array.isArray(list) ? list : []).map((item, index) =>
|
||||
mapPaperQuestionSummary(item as Record<string, unknown>, index),
|
||||
);
|
||||
|
||||
export const mapPaperQuestion = (item: Record<string, unknown>, index: number): ReportQuestion => {
|
||||
const status = getQuestionStatus(item);
|
||||
const stemSource = getQuestionStemSource(item);
|
||||
const answerSource = getQuestionAnswerSource(item);
|
||||
const { studentAnswer, studentAnswerHtml, studentAnswerImages } = buildStudentAnswer(item);
|
||||
const stemImages = extractTitleImages(stemSource);
|
||||
|
||||
return {
|
||||
no: Number(item.titleSort ?? item.sort ?? index + 1),
|
||||
type: String(item.titleChannelTypeName || item.chanelTypeName || '综合题'),
|
||||
score: formatQuestionScoreText(item),
|
||||
status,
|
||||
stemHtml: formatQuestionHtml(stemSource, { stripImages: true }),
|
||||
stemImages,
|
||||
correctAnswerHtml: formatQuestionHtml(answerSource),
|
||||
studentAnswer,
|
||||
studentAnswerHtml,
|
||||
studentAnswerImages,
|
||||
classRate: formatRateText(
|
||||
item.classScoreRate ?? item.classRate ?? item.classAvgScoreRate ?? item.classGotScoreRate,
|
||||
),
|
||||
schoolRate: formatRateText(
|
||||
item.schoolScoreRate ?? item.schoolRate ?? item.schoolAvgScoreRate ?? item.schoolGotScoreRate,
|
||||
),
|
||||
knowledge: String(item.knowledgeName || item.knowledge || item.knowledgePointName || '待归类'),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapPaperQuestions = (list: unknown[]) =>
|
||||
(Array.isArray(list) ? list : []).map((item, index) =>
|
||||
mapPaperQuestion(item as Record<string, unknown>, index),
|
||||
);
|
||||
|
||||
/** 兼容旧版 mock 数据结构 */
|
||||
export const normalizeLegacyQuestion = (item: Record<string, unknown>): ReportQuestion => {
|
||||
const legacy = item as ReportQuestion & { stem?: string; correctAnswer?: string };
|
||||
if (legacy.stemHtml) return legacy as ReportQuestion;
|
||||
return {
|
||||
no: Number(legacy.no),
|
||||
type: String(legacy.type),
|
||||
score: String(legacy.score),
|
||||
status: legacy.status,
|
||||
stemHtml: String(legacy.stem || ''),
|
||||
stemImages: [],
|
||||
correctAnswerHtml: String(legacy.correctAnswer || ''),
|
||||
studentAnswer: String(legacy.studentAnswer || ''),
|
||||
studentAnswerHtml: '',
|
||||
studentAnswerImages: [],
|
||||
classRate: String(legacy.classRate),
|
||||
schoolRate: String(legacy.schoolRate),
|
||||
knowledge: String(legacy.knowledge),
|
||||
};
|
||||
};
|
||||
28
src/pages/parent/utils/parentIdentity.ts
Normal file
28
src/pages/parent/utils/parentIdentity.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/** 家长与孩子身份关系(与 xuexiaole-mobile-v 一致) */
|
||||
export const PARENT_TYPE_FATHER = 1;
|
||||
export const PARENT_TYPE_MOTHER = 2;
|
||||
export const PARENT_TYPE_GRANDFATHER = 3;
|
||||
export const PARENT_TYPE_GRANDMOTHER = 4;
|
||||
export const PARENT_TYPE_GRANDFATHER2 = 5;
|
||||
export const PARENT_TYPE_GRANDMOTHER2 = 6;
|
||||
export const PARENT_TYPE_TEACHER = 7;
|
||||
export const PARENT_TYPE_OTHER = 8;
|
||||
|
||||
const OSS_BASE = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/bindDevice';
|
||||
|
||||
export interface ParentIdentityOption {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const PARENT_IDENTITY_LIST: ParentIdentityOption[] = [
|
||||
{ name: '爸爸', icon: `${OSS_BASE}/baba.png`, id: PARENT_TYPE_FATHER },
|
||||
{ name: '妈妈', icon: `${OSS_BASE}/mama.png`, id: PARENT_TYPE_MOTHER },
|
||||
{ name: '爷爷', icon: `${OSS_BASE}/yeye.png`, id: PARENT_TYPE_GRANDFATHER },
|
||||
{ name: '奶奶', icon: `${OSS_BASE}/nainai.png`, id: PARENT_TYPE_GRANDMOTHER },
|
||||
{ name: '外公', icon: `${OSS_BASE}/wg.png`, id: PARENT_TYPE_GRANDFATHER2 },
|
||||
{ name: '外婆', icon: `${OSS_BASE}/waipo.png`, id: PARENT_TYPE_GRANDMOTHER2 },
|
||||
{ name: '导学师', icon: `${OSS_BASE}/qita.png`, id: PARENT_TYPE_TEACHER },
|
||||
{ name: '其他', icon: `${OSS_BASE}/qita.png`, id: PARENT_TYPE_OTHER },
|
||||
];
|
||||
45
src/pages/parent/utils/scanBind.ts
Normal file
45
src/pages/parent/utils/scanBind.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/** 解析扫码结果(与 xuexiaole-mobile-v useScanBind 一致) */
|
||||
export const parseScanBindResult = (raw: string) => {
|
||||
if (!raw?.trim()) {
|
||||
throw new Error('无法识别扫描结果');
|
||||
}
|
||||
let json: Record<string, any>;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('无法识别扫描结果');
|
||||
}
|
||||
if (!json?.account || !json?.userId || !json?.simSerialNumber || !json?.model) {
|
||||
throw new Error('无法识别扫描结果');
|
||||
}
|
||||
return {
|
||||
account: String(json.account),
|
||||
childId: String(json.userId),
|
||||
simSerialNumber: String(json.simSerialNumber),
|
||||
};
|
||||
};
|
||||
|
||||
/** 小程序扫码并跳转绑定确认页 */
|
||||
export const scanAndGoBind = () => {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: false,
|
||||
success: (res) => {
|
||||
try {
|
||||
const parsed = parseScanBindResult(res.result || '');
|
||||
const query = [
|
||||
`childId=${encodeURIComponent(parsed.childId)}`,
|
||||
`account=${encodeURIComponent(parsed.account)}`,
|
||||
`simSerialNumber=${encodeURIComponent(parsed.simSerialNumber)}`,
|
||||
].join('&');
|
||||
uni.navigateTo({ url: `/pages/parent/bind/index?${query}` });
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '无法识别扫描结果', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
const msg = err?.errMsg || '';
|
||||
if (msg.includes('cancel')) return;
|
||||
uni.showToast({ title: '扫码失败', icon: 'none' });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -4,6 +4,7 @@
|
||||
import type { Router } from 'uni-mini-router';
|
||||
import { useUserStore } from '@/state/modules/user';
|
||||
import { getCache } from '@/utils/cache';
|
||||
import { getStoredRole } from '@/utils/roleHome';
|
||||
|
||||
// 免登录白名单(路由 name)
|
||||
const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile'];
|
||||
@ -15,20 +16,23 @@ export function userRouternext(router: Router) {
|
||||
if (userStore.isLogin) {
|
||||
const currentRole = getCache('role');
|
||||
const targetRole = (to as any).meta?.role;
|
||||
if (currentRole === 'teacher' && to.name === 'home') {
|
||||
return next({
|
||||
name: 'teacher-home',
|
||||
});
|
||||
if (currentRole && to.name === 'home') {
|
||||
const homeName = currentRole === 'teacher'
|
||||
? 'teacher-home'
|
||||
: currentRole === 'parent'
|
||||
? 'parent-home'
|
||||
: 'home';
|
||||
if (homeName !== 'home') {
|
||||
return next({ name: homeName });
|
||||
}
|
||||
if (currentRole === 'parent' && to.name === 'home') {
|
||||
return next({
|
||||
name: 'parent-home',
|
||||
});
|
||||
}
|
||||
if (targetRole && currentRole && targetRole !== currentRole) {
|
||||
return next({
|
||||
name: currentRole === 'teacher' ? 'teacher-home' : currentRole === 'parent' ? 'parent-home' : 'home',
|
||||
});
|
||||
const homeName = currentRole === 'teacher'
|
||||
? 'teacher-home'
|
||||
: currentRole === 'parent'
|
||||
? 'parent-home'
|
||||
: 'home';
|
||||
return next({ name: homeName });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
@ -45,9 +49,10 @@ export function userRouternext(router: Router) {
|
||||
router.afterEach((to) => {
|
||||
const userStore = useUserStore();
|
||||
if (userStore.isLogin && to?.name === 'login') {
|
||||
const role = getCache('role');
|
||||
const role = getStoredRole();
|
||||
if (!role) return;
|
||||
router.replaceAll({ name: role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home' });
|
||||
const homeName = role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home';
|
||||
router.replaceAll({ name: homeName });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
127
src/state/modules/parent.ts
Normal file
127
src/state/modules/parent.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getParentBindChildList, type ParentBindChildItem } from '@/api/parent';
|
||||
import { getCache, removeCache, setCache } from '@/utils/cache';
|
||||
|
||||
export interface ParentChildItem {
|
||||
bindId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
account: string;
|
||||
className: string;
|
||||
school: string;
|
||||
examNo: string;
|
||||
}
|
||||
|
||||
const CACHE_ACTIVE_CHILD_ID = 'parentActiveChildId';
|
||||
|
||||
const emptyChild = (): ParentChildItem => ({
|
||||
bindId: '',
|
||||
id: '',
|
||||
name: '--',
|
||||
account: '',
|
||||
className: '--',
|
||||
school: '--',
|
||||
examNo: '--',
|
||||
});
|
||||
|
||||
const mapBindRow = (row: ParentBindChildItem): ParentChildItem => {
|
||||
const account = row.child?.account || '';
|
||||
return {
|
||||
bindId: String(row.id ?? row.child?.id ?? ''),
|
||||
id: String(row.child?.id ?? row.id ?? ''),
|
||||
name: row.child?.name || row.child?.nickname || '未命名',
|
||||
account,
|
||||
className: row.child?.gradeName || '',
|
||||
school: '',
|
||||
examNo: account,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 家长端 - 绑定孩子列表与当前选中孩子
|
||||
*/
|
||||
export const useParentStore = defineStore('parent', () => {
|
||||
const childrenList = ref<ParentChildItem[]>([]);
|
||||
const activeChildId = ref<string>(getCache(CACHE_ACTIVE_CHILD_ID) || '');
|
||||
const loading = ref(false);
|
||||
|
||||
const activeChild = computed(() => {
|
||||
if (!childrenList.value.length) {
|
||||
return emptyChild();
|
||||
}
|
||||
const matched = childrenList.value.find((item) => item.id === activeChildId.value);
|
||||
return matched || childrenList.value[0];
|
||||
});
|
||||
|
||||
const activeChildIndex = computed(() => {
|
||||
const index = childrenList.value.findIndex((item) => item.id === activeChildId.value);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
const hasActiveChild = computed(() => !!activeChild.value.id);
|
||||
|
||||
const ensureActiveChild = () => {
|
||||
if (!childrenList.value.length) {
|
||||
activeChildId.value = '';
|
||||
removeCache(CACHE_ACTIVE_CHILD_ID);
|
||||
return;
|
||||
}
|
||||
const exists = childrenList.value.some((item) => item.id === activeChildId.value);
|
||||
if (!activeChildId.value || !exists) {
|
||||
activeChildId.value = childrenList.value[0].id;
|
||||
setCache(CACHE_ACTIVE_CHILD_ID, activeChildId.value);
|
||||
}
|
||||
};
|
||||
|
||||
const selectChildByIndex = (index: number) => {
|
||||
const child = childrenList.value[index];
|
||||
if (!child) return;
|
||||
activeChildId.value = child.id;
|
||||
setCache(CACHE_ACTIVE_CHILD_ID, child.id);
|
||||
};
|
||||
|
||||
const fetchBindChildren = async (parentId?: string | number) => {
|
||||
const pid = parentId ?? '';
|
||||
if (pid === '' || pid == null) {
|
||||
childrenList.value = [];
|
||||
ensureActiveChild();
|
||||
return [];
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getParentBindChildList({ parentId: pid });
|
||||
const rows = res.data?.rows || [];
|
||||
childrenList.value = rows.map(mapBindRow);
|
||||
ensureActiveChild();
|
||||
return childrenList.value;
|
||||
} catch (err) {
|
||||
console.error('[parent] fetchBindChildren error', err);
|
||||
childrenList.value = [];
|
||||
ensureActiveChild();
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
childrenList.value = [];
|
||||
activeChildId.value = '';
|
||||
removeCache(CACHE_ACTIVE_CHILD_ID);
|
||||
};
|
||||
|
||||
return {
|
||||
childrenList,
|
||||
activeChildId,
|
||||
activeChild,
|
||||
activeChildIndex,
|
||||
hasActiveChild,
|
||||
loading,
|
||||
fetchBindChildren,
|
||||
selectChildByIndex,
|
||||
ensureActiveChild,
|
||||
clear,
|
||||
};
|
||||
});
|
||||
@ -1,10 +1,12 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { setCache, getCache, removeCache } from '@/utils/cache';
|
||||
import { useParentStore } from '@/state/modules/parent';
|
||||
import {
|
||||
psdLogin as psdLoginApi,
|
||||
parentSmsLogin as parentSmsLoginApi,
|
||||
getUserInfo as getUserInfoApi,
|
||||
selectUser as selectUserApi,
|
||||
logout as logoutApi,
|
||||
type AccountLoginParams,
|
||||
type ParentSmsLoginParams,
|
||||
@ -43,6 +45,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
removeCache('userInfo');
|
||||
removeCache('teacherInfo');
|
||||
removeCache('role');
|
||||
removeCache('parentActiveChildId');
|
||||
useParentStore().clear();
|
||||
};
|
||||
|
||||
/** 拉取并解析用户信息 */
|
||||
@ -90,6 +94,20 @@ export const useUserStore = defineStore('user', () => {
|
||||
await fetchUserInfo();
|
||||
};
|
||||
|
||||
/** 家长端 - 拉取并保存当前用户信息 */
|
||||
const fetchParentUserInfo = async () => {
|
||||
try {
|
||||
const res = await selectUserApi();
|
||||
const info: UserInfo = res.data || {};
|
||||
userInfo.value = info;
|
||||
setCache('userInfo', info);
|
||||
return info;
|
||||
} catch (err) {
|
||||
console.error('[user] fetchParentUserInfo error', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/** 家长端 - 保存登录 token */
|
||||
const saveParentSession = (accessToken: string) => {
|
||||
if (!accessToken?.trim()) {
|
||||
@ -139,6 +157,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
parentMpLoginSuccess,
|
||||
parentLogin,
|
||||
fetchUserInfo,
|
||||
fetchParentUserInfo,
|
||||
logout,
|
||||
clear,
|
||||
updateUserInfo,
|
||||
|
||||
227
src/utils/questionHtml.ts
Normal file
227
src/utils/questionHtml.ts
Normal file
@ -0,0 +1,227 @@
|
||||
/** 题目 HTML 处理(与学生端 doWork/Question 保持一致) */
|
||||
|
||||
const LATEX_RENDER_URL = 'https://latex.codecogs.com/svg.image?';
|
||||
|
||||
export const fixMathSymbolsInHtml = (html: string): string => {
|
||||
if (html == null || typeof html !== 'string') return '';
|
||||
let t = html;
|
||||
t = t.replace(/\uFE63/g, '\u2212');
|
||||
t = t.replace(/<(?![a-zA-Z/!])/g, '<');
|
||||
t = t.replace(/ > /g, ' > ');
|
||||
t = t.replace(/AUB/g, 'A∪B');
|
||||
return t;
|
||||
};
|
||||
|
||||
const latexToImageUrl = (latex: string): string => {
|
||||
if (!latex) return '';
|
||||
let formula = latex.trim();
|
||||
|
||||
const punctuationMap: Record<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, latexCmd] of Object.entries(mathSymbolMap)) {
|
||||
formula = formula.split(symbol).join(latexCmd);
|
||||
}
|
||||
|
||||
formula = formula.replace(/\b([RNZQC])\+/g, '$1^+');
|
||||
formula = formula.replace(/\b([RNZQC])\*/g, '$1^*');
|
||||
formula = formula
|
||||
.replace(/\\overset\s*\{\s*\\rightarrow\s*\}\s*\{([^}]+)\}/g, '\\vec{$1}')
|
||||
.replace(/\\text\s*\{\s*π\s*\}/g, '\\pi')
|
||||
.replace(/\\text\s*\{\s*sin\s*\}/gi, '\\sin')
|
||||
.replace(/\\text\s*\{\s*cos\s*\}/gi, '\\cos')
|
||||
.replace(/\\text\s*\{\s*tan\s*\}/gi, '\\tan')
|
||||
.replace(/\\text\s*\{\s*log\s*\}/gi, '\\log')
|
||||
.replace(/π/g, '\\pi');
|
||||
|
||||
const encoded = encodeURIComponent(formula);
|
||||
return `${LATEX_RENDER_URL}\\inline&space;\\dpi{200}&space;${encoded}`;
|
||||
};
|
||||
|
||||
const isSimpleFormula = (formula: string): boolean => {
|
||||
const complexPatterns = [
|
||||
/\\frac\b/, /\\sqrt\b/, /\\sum\b/, /\\int\b/, /\\prod\b/, /\\lim\b/,
|
||||
/\\vec\b/, /\\overline\b/, /\\underline\b/, /\\overset\b/, /\\underset\b/,
|
||||
/\\binom\b/, /\\matrix\b/, /\\begin\b/,
|
||||
/\^{[^}]+}/, /_{[^}]+}/,
|
||||
];
|
||||
return !complexPatterns.some((pattern) => pattern.test(formula));
|
||||
};
|
||||
|
||||
const simpleFormulaToHtml = (formula: string): string => {
|
||||
let text = formula;
|
||||
text = text.replace(/\\text\s*\{([^}]*)\}/g, '$1');
|
||||
|
||||
const greekMap: Record<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>`;
|
||||
};
|
||||
|
||||
export const processFormula = (html: 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);
|
||||
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 formatQuestionHtml = (title?: string, options?: { stripImages?: boolean }) => {
|
||||
if (!title) return '';
|
||||
let t = String(title);
|
||||
if (options?.stripImages) {
|
||||
t = t.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
|
||||
}
|
||||
t = t.replace(/<u><\/u>/gi, '<u class="fill-blank"></u>');
|
||||
t = t.replace(/http:\/\//gi, 'https://');
|
||||
t = fixMathSymbolsInHtml(t);
|
||||
t = processFormula(t);
|
||||
return t;
|
||||
};
|
||||
|
||||
export const getQuestionStemSource = (item: Record<string, unknown>) => {
|
||||
const pid = item.pidTitle ? String(item.pidTitle) : '';
|
||||
const stem = String(item.stemHtml || item.titleMu || item.title || item.stem || '');
|
||||
if (pid && stem) return `${pid}${stem}`;
|
||||
return pid || stem;
|
||||
};
|
||||
|
||||
export const getQuestionAnswerSource = (item: Record<string, unknown>) =>
|
||||
String(item.answerMu ?? item.answer ?? '');
|
||||
|
||||
const IMAGE_URL_PATTERN = /\.(jpg|jpeg|png|gif|webp|bmp)(\?.*)?$/i;
|
||||
|
||||
export const isImageUrl = (url: string) => {
|
||||
const value = String(url || '').trim();
|
||||
if (!value) return false;
|
||||
if (IMAGE_URL_PATTERN.test(value)) return true;
|
||||
return /(?:cos\.|myqcloud|oss-|\/temp\/|\/upload)/i.test(value);
|
||||
};
|
||||
|
||||
export const parseImageUrls = (raw: unknown) => {
|
||||
const urls = String(raw || '')
|
||||
.split(',')
|
||||
.map((item) => item.replace(/[`\s]/g, '').replace(/http:\/\//gi, 'https://').trim())
|
||||
.filter(Boolean);
|
||||
return urls.filter(isImageUrl);
|
||||
};
|
||||
|
||||
export const extractAnswerImages = (item: Record<string, unknown>) => {
|
||||
const images = new Set<string>();
|
||||
parseImageUrls(item.submitAnswerPic).forEach((url) => images.add(url));
|
||||
|
||||
const answer = String(item.submitAnswer || '');
|
||||
const links = answer.match(/https?:\/\/[^\s,]+/gi) || [];
|
||||
links.forEach((link) => {
|
||||
const url = link.replace(/http:\/\//gi, 'https://').trim();
|
||||
if (isImageUrl(url)) images.add(url);
|
||||
});
|
||||
|
||||
return Array.from(images);
|
||||
};
|
||||
|
||||
export const extractTitleImages = (html: string) => {
|
||||
if (!html) return [] as string[];
|
||||
const images: string[] = [];
|
||||
const imgRegex = /<img[^>]*\s*src=["']([^"']+)["'][^>]*\/?>/gi;
|
||||
let match;
|
||||
while ((match = imgRegex.exec(html)) !== null) {
|
||||
if (/formula-img/i.test(match[0])) continue;
|
||||
const url = (match[1] || '').trim().replace(/http:\/\//gi, 'https://');
|
||||
if (url) images.push(url);
|
||||
}
|
||||
return images;
|
||||
};
|
||||
58
src/utils/roleHome.ts
Normal file
58
src/utils/roleHome.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { getCache } from '@/utils/cache';
|
||||
|
||||
export type UserRole = 'student' | 'teacher' | 'parent';
|
||||
|
||||
const VALID_ROLES: UserRole[] = ['student', 'teacher', 'parent'];
|
||||
|
||||
export const ROLE_HOME_URL: Record<UserRole, string> = {
|
||||
student: '/pages/index/index',
|
||||
teacher: '/pages/teacher/index/index',
|
||||
parent: '/pages/parent/home/index',
|
||||
};
|
||||
|
||||
const isNonEmptyObject = (val: unknown) =>
|
||||
!!val && typeof val === 'object' && Object.keys(val as object).length > 0;
|
||||
|
||||
/** 读取本地已保存的账号类型 */
|
||||
export const getStoredRole = (): UserRole | null => {
|
||||
const role = getCache('role');
|
||||
return VALID_ROLES.includes(role as UserRole) ? (role as UserRole) : null;
|
||||
};
|
||||
|
||||
/** 是否具备可自动进入对应首页的本地登录态 */
|
||||
export const hasStoredSession = (): boolean => {
|
||||
const token = getCache('token');
|
||||
const role = getStoredRole();
|
||||
if (!token || !role) return false;
|
||||
|
||||
if (role === 'teacher') {
|
||||
return isNonEmptyObject(getCache('teacherInfo'));
|
||||
}
|
||||
return isNonEmptyObject(getCache('userInfo'));
|
||||
};
|
||||
|
||||
/** 根据账号类型获取首页路径 */
|
||||
export const getRoleHomeUrl = (role: UserRole | null = getStoredRole()) =>
|
||||
(role ? ROLE_HOME_URL[role] : ROLE_HOME_URL.student);
|
||||
|
||||
/**
|
||||
* 若本地已有 token、账号类型和用户信息,则跳转到对应角色首页
|
||||
* @returns 是否已触发跳转
|
||||
*/
|
||||
export const redirectToRoleHomeIfNeeded = (): boolean => {
|
||||
if (!hasStoredSession()) return false;
|
||||
|
||||
const role = getStoredRole();
|
||||
if (!role) return false;
|
||||
|
||||
const targetUrl = getRoleHomeUrl(role);
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
if (current) {
|
||||
const route = `/${current.route}`;
|
||||
if (route === targetUrl) return false;
|
||||
}
|
||||
|
||||
uni.reLaunch({ url: targetUrl });
|
||||
return true;
|
||||
};
|
||||
106
src/utils/subject.ts
Normal file
106
src/utils/subject.ts
Normal file
@ -0,0 +1,106 @@
|
||||
export interface SubjectOption {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SubjectFilterOption {
|
||||
label: string;
|
||||
value?: number;
|
||||
subjectId?: string;
|
||||
}
|
||||
|
||||
// 学科
|
||||
export const subject_chinese: SubjectOption = {
|
||||
value: 1,
|
||||
label: '语文',
|
||||
};
|
||||
|
||||
export const subject_math: SubjectOption = {
|
||||
value: 2,
|
||||
label: '数学',
|
||||
};
|
||||
|
||||
export const subject_english: SubjectOption = {
|
||||
value: 3,
|
||||
label: '英语',
|
||||
};
|
||||
|
||||
export const subject_physics: SubjectOption = {
|
||||
value: 4,
|
||||
label: '物理',
|
||||
};
|
||||
|
||||
export const subject_chemistry: SubjectOption = {
|
||||
value: 5,
|
||||
label: '化学',
|
||||
};
|
||||
|
||||
export const subject_bio: SubjectOption = {
|
||||
value: 6,
|
||||
label: '生物学',
|
||||
};
|
||||
|
||||
export const subject_politics: SubjectOption = {
|
||||
value: 7,
|
||||
label: '政治(道法)',
|
||||
};
|
||||
|
||||
export const subject_history: SubjectOption = {
|
||||
value: 8,
|
||||
label: '历史',
|
||||
};
|
||||
|
||||
export const subject_geo: SubjectOption = {
|
||||
value: 9,
|
||||
label: '地理',
|
||||
};
|
||||
|
||||
export const subject_science: SubjectOption = {
|
||||
value: 10,
|
||||
label: '科学',
|
||||
};
|
||||
|
||||
export const subject_history_society: SubjectOption = {
|
||||
value: 11,
|
||||
label: '历史与社会',
|
||||
};
|
||||
|
||||
export const subject_info: SubjectOption = {
|
||||
value: 12,
|
||||
label: '信息技术',
|
||||
};
|
||||
|
||||
export const subject_music: SubjectOption = {
|
||||
value: 13,
|
||||
label: '音乐',
|
||||
};
|
||||
|
||||
export const subject_art: SubjectOption = {
|
||||
value: 14,
|
||||
label: '美术',
|
||||
};
|
||||
|
||||
export const SUBJECT_OPTIONS: SubjectOption[] = [
|
||||
subject_chinese,
|
||||
subject_math,
|
||||
subject_english,
|
||||
subject_physics,
|
||||
subject_chemistry,
|
||||
subject_bio,
|
||||
subject_politics,
|
||||
subject_history,
|
||||
subject_geo,
|
||||
subject_science,
|
||||
subject_history_society,
|
||||
subject_info,
|
||||
subject_music,
|
||||
subject_art,
|
||||
];
|
||||
|
||||
export const SUBJECT_FILTER_OPTIONS: SubjectFilterOption[] = [
|
||||
{ label: '全部学科' },
|
||||
...SUBJECT_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
subjectId: String(item.value),
|
||||
})),
|
||||
];
|
||||
Loading…
x
Reference in New Issue
Block a user