feat:修改

This commit is contained in:
阿梦 2026-05-29 20:39:12 +08:00
parent 5fd397b1ea
commit 43eaa23e7a
27 changed files with 2458 additions and 317 deletions

View File

@ -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(() => {

View File

@ -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>({

View File

@ -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>({

View File

@ -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 },
});
};

View File

@ -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;

View File

@ -256,6 +256,20 @@
"pageOrientation": "portrait"
}
}
},
{
"path": "bind/index",
"name": "parent-bind",
"meta": {
"islogin": true,
"role": "parent"
},
"style": {
"navigationBarTitleText": "绑定孩子",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
}
]
}

View File

@ -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();

View File

@ -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);

View File

@ -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;
}

View 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>

View 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>

View 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>

View File

@ -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<{

View File

@ -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);
}

View File

@ -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 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;
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;
}
} catch (e: any) {
childrenList.value = [];
uni.showToast({ title: e?.message || '获取孩子列表失败', icon: 'none' });
} finally {
loading.value = false;
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() || '--';
});
onShow(() => {
fetchChildren();
onShow(async () => {
parentPhone.value = userInfo.value.phone || parentPhone.value;
if (childrenList.value.length) return;
try {
if (!userInfo.value.id) {
await userStore.fetchParentUserInfo();
}
await parentStore.fetchBindChildren(userInfo.value.id);
} catch {
/* ignore */
}
});
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 {

View File

@ -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 {

View File

@ -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>

View File

@ -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 = ['全部类型', '周作业', '月考'];

View 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),
};
};

View 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 },
];

View 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' });
},
});
};

View File

@ -4,6 +4,7 @@
import type { Router } from 'uni-mini-router';
import { useUserStore } from '@/state/modules/user';
import { getCache } from '@/utils/cache';
import { getStoredRole } from '@/utils/roleHome';
// 免登录白名单(路由 name
const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile'];
@ -15,20 +16,23 @@ export function userRouternext(router: Router) {
if (userStore.isLogin) {
const currentRole = getCache('role');
const targetRole = (to as any).meta?.role;
if (currentRole === 'teacher' && to.name === 'home') {
return next({
name: 'teacher-home',
});
}
if (currentRole === 'parent' && to.name === 'home') {
return next({
name: 'parent-home',
});
if (currentRole && to.name === 'home') {
const homeName = currentRole === 'teacher'
? 'teacher-home'
: currentRole === 'parent'
? 'parent-home'
: 'home';
if (homeName !== 'home') {
return next({ name: homeName });
}
}
if (targetRole && currentRole && targetRole !== currentRole) {
return next({
name: currentRole === 'teacher' ? 'teacher-home' : currentRole === 'parent' ? 'parent-home' : 'home',
});
const homeName = currentRole === 'teacher'
? 'teacher-home'
: currentRole === 'parent'
? 'parent-home'
: 'home';
return next({ name: homeName });
}
return next();
}
@ -45,9 +49,10 @@ export function userRouternext(router: Router) {
router.afterEach((to) => {
const userStore = useUserStore();
if (userStore.isLogin && to?.name === 'login') {
const role = getCache('role');
const role = getStoredRole();
if (!role) return;
router.replaceAll({ name: role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home' });
const homeName = role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home';
router.replaceAll({ name: homeName });
}
});
}

127
src/state/modules/parent.ts Normal file
View 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,
};
});

View File

@ -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
View 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, '&lt;');
t = t.replace(/ > /g, ' &gt; ');
t = t.replace(/AUB/g, 'AB');
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
View 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
View 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),
})),
];