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"> <script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'; import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
onLaunch(() => { onLaunch(() => {
console.log('App Launch'); redirectToRoleHomeIfNeeded();
}); });
onShow(() => { onShow(() => {

View File

@ -1,5 +1,30 @@
import { request } from '@/utils/request'; 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 { export interface PaperRecord {
id: string; id: string;
paperId: string; paperId: string;
@ -11,6 +36,27 @@ export interface PaperRecord {
endTime: string; endTime: string;
remark: string; remark: string;
costTime: number; 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, data,
}); });
/** 已完成作业列表 */ /** 已完成作业/考试记录列表 */
export const getListCompleteRecord = (params?: { export const getListCompleteRecord = (params?: ListCompleteRecordParams) =>
gradingStatus?: string; request<PaperRecord[] | ListCompleteRecordPage>({
subjectId?: string;
}) =>
request<any[]>({
url: '/school/paper/userRecord/listCompleteRecord', url: '/school/paper/userRecord/listCompleteRecord',
method: 'GET', 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 }) => export const getPaperRecordDetail = (data: { recordId: string; reportFlag: number }) =>
request<any>({ request<any>({

View File

@ -75,9 +75,11 @@ export const parentSmsLogin = (data: ParentSmsLoginParams) =>
data: { data: {
bindMaOpenid: true, bindMaOpenid: true,
adminType: 16, adminType: 16,
openid: data.openid,
phone: data.phone, phone: data.phone,
verifyCode: data.verifyCode, verifyCode: data.verifyCode,
wxUser: {
openid: data.openid,
},
}, },
}); });
@ -89,6 +91,24 @@ export const logout = () =>
silent: true, 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 = () => export const getUserInfo = () =>
request<any>({ request<any>({

View File

@ -40,13 +40,71 @@ export interface PageResult<T> {
[key: string]: any; [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>>({ request<PageResult<ParentBindChildItem>>({
url: '/parentBindChild/list', url: '/parentBindChild/list',
method: 'GET', method: 'GET',
params: { params: {
current: params?.current ?? 1, parentId: params.parentId,
size: params?.size ?? 100, 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> <template>
<view class="empty"> <view class="empty">
<view class="empty-illu" :class="`empty-illu-${type}`"> <view class="empty-illu" :class="`empty-illu-${type}`">
<!-- Loading -->
<view v-if="type === 'loading'" class="empty-loader"> <view v-if="type === 'loading'" class="empty-loader">
<view class="dot dot-1"></view> <view class="dot dot-1"></view>
<view class="dot dot-2"></view> <view class="dot dot-2"></view>
<view class="dot dot-3"></view> <view class="dot dot-3"></view>
</view> </view>
<!-- Network --> <view v-else class="empty-art">
<svg v-else-if="type === 'network'" width="80" height="80" viewBox="0 0 24 24" fill="none"> <view class="art-card art-card-back"></view>
<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" <view class="art-card art-card-front">
stroke="#A78BFA" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /> <view class="art-dot"></view>
<line x1="3" y1="3" x2="21" y2="21" stroke="#EF4444" stroke-width="1.6" stroke-linecap="round" /> <view class="art-line art-line-long"></view>
</svg> <view class="art-line art-line-mid"></view>
<!-- Task / 默认空盒子 --> <view class="art-line art-line-short"></view>
<svg v-else-if="type === 'task'" width="80" height="80" viewBox="0 0 24 24" fill="none"> </view>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" <view class="art-spark art-spark-left"></view>
stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" /> <view class="art-spark art-spark-right"></view>
<path d="M14 2v6h6" stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" /> </view>
<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> </view>
<view class="empty-text">{{ content }}</view> <view class="empty-text">{{ content }}</view>
</view> </view>
@ -68,9 +60,16 @@ withDefaults(
inset 0 -6rpx 0 rgba(199, 210, 254, 0.6), inset 0 -6rpx 0 rgba(199, 210, 254, 0.6),
inset 0 4rpx 0 rgba(255, 255, 255, 0.9); inset 0 4rpx 0 rgba(255, 255, 255, 0.9);
svg { &.empty-illu-network {
width: 90rpx; .art-dot {
height: 90rpx; background: #fca5a5;
}
}
&.empty-illu-learn {
.art-dot {
background: #86efac;
}
} }
} }
@ -80,6 +79,82 @@ withDefaults(
font-weight: 500; 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 */ /* loading dots */
.empty-loader { .empty-loader {
display: flex; display: flex;

View File

@ -256,6 +256,20 @@
"pageOrientation": "portrait" "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 { storeToRefs } from 'pinia';
import { useUserStore } from '@/state/modules/user'; import { useUserStore } from '@/state/modules/user';
import { getUnfinishedHomework } from '@/api/homework'; import { getUnfinishedHomework } from '@/api/homework';
import { getCache } from '@/utils/cache'; import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
const userStore = useUserStore(); const userStore = useUserStore();
const { userInfo, token, isLogin } = storeToRefs(userStore); const { userInfo, token, isLogin } = storeToRefs(userStore);
@ -216,10 +216,7 @@ const fetchUnfinished = async () => {
}; };
onShow(() => { onShow(() => {
if (token.value && getCache('role') === 'teacher') { if (redirectToRoleHomeIfNeeded()) return;
uni.reLaunch({ url: '/pages/teacher/index/index' });
return;
}
if (token.value) { if (token.value) {
userStore.fetchUserInfo().catch(() => { /* ignore */ }); userStore.fetchUserInfo().catch(() => { /* ignore */ });
fetchUnfinished(); fetchUnfinished();

View File

@ -184,6 +184,7 @@ import { parentMpLogin, sendParentLoginCode } from '@/api/login';
import { useUserStore } from '@/state/modules/user'; import { useUserStore } from '@/state/modules/user';
import { useTeacherStore } from '@/state/modules/teacher'; import { useTeacherStore } from '@/state/modules/teacher';
import { setCache, getCache } from '@/utils/cache'; import { setCache, getCache } from '@/utils/cache';
import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome';
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com'; const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
const logoUrl = `${OSS_URL}/urm/logo.png`; const logoUrl = `${OSS_URL}/urm/logo.png`;
@ -282,6 +283,7 @@ onLoad((options: any) => {
if (options?.a) { if (options?.a) {
account.value = options.a; account.value = options.a;
} }
if (redirectToRoleHomeIfNeeded()) return;
}); });
const onLogoTap = () => { const onLogoTap = () => {
@ -362,6 +364,7 @@ const onParentAuthorize = async () => {
if (accessToken) { if (accessToken) {
await userStore.parentMpLoginSuccess(accessToken); await userStore.parentMpLoginSuccess(accessToken);
setCache('role', 'parent');
uni.hideLoading(); uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' }); uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => navigateAfterLogin(), 800); setTimeout(() => navigateAfterLogin(), 800);
@ -435,6 +438,7 @@ const onLogin = async () => {
verifyCode: code, verifyCode: code,
openid: parentWxOpenid.value, openid: parentWxOpenid.value,
}); });
setCache('role', 'parent');
uni.hideLoading(); uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' }); uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => navigateAfterLogin(), 800); setTimeout(() => navigateAfterLogin(), 800);

View File

@ -1,25 +1,6 @@
<template> <template>
<ParentPage active="reports" :show-tab="false" :use-scroll-view="false"> <ParentPage active="reports" :show-tab="false" :use-scroll-view="false">
<view class="filter-row"> <ParentFilterRow :items="filterItems" @change="onFilterChange" />
<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>
<view class="module-section"> <view class="module-section">
<ParentCard title="错题知识点占比" subtitle="按错题占比识别最近需要优先补强的知识点"> <ParentCard title="错题知识点占比" subtitle="按错题占比识别最近需要优先补强的知识点">
@ -113,6 +94,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onReady, onShow } from '@dcloudio/uni-app'; import { onReady, onShow } from '@dcloudio/uni-app';
import ParentCard from '../components/ParentCard.vue'; import ParentCard from '../components/ParentCard.vue';
import ParentFilterRow from '../components/ParentFilterRow.vue';
import ParentPage from '../components/ParentPage.vue'; import ParentPage from '../components/ParentPage.vue';
import { import {
abilitySummary, abilitySummary,
@ -131,6 +113,26 @@ const gradeIndex = ref(1);
const categoryIndex = ref(0); const categoryIndex = ref(0);
const activeLevel = ref<ScoreLevel>('weak'); 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 }[] = [ const scoreLevels: { key: ScoreLevel; label: string }[] = [
{ key: 'weak', label: '薄弱' }, { key: 'weak', label: '薄弱' },
{ key: 'pass', 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> </script>
<style lang="scss" scoped> <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 { .module-section {
margin-top: 44rpx; 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="parent-page" :class="{ 'native-scroll': !useScrollView }">
<view class="decor decor-a"></view> <view class="decor decor-a"></view>
<view class="decor decor-b"></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 }"> <view class="page-content" :class="{ 'with-tab': showTab }">
<slot></slot> <slot></slot>
</view> </view>
@ -19,6 +27,10 @@
<script setup lang="ts"> <script setup lang="ts">
import ParentTabBar from './ParentTabBar.vue'; import ParentTabBar from './ParentTabBar.vue';
defineEmits<{
scrolltolower: [];
}>();
// //
withDefaults( withDefaults(
defineProps<{ defineProps<{

View File

@ -1,7 +1,7 @@
<template> <template>
<ParentPage active="home"> <ParentPage active="home">
<view class="profile"> <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="profile-main">
<view class="name-row"> <view class="name-row">
<text class="child-name">{{ activeChild.name }}家长</text> <text class="child-name">{{ activeChild.name }}家长</text>
@ -58,11 +58,33 @@
</template> </template>
<script setup lang="ts"> <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 ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.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 trendText = (status: SubjectTrend['status']) => {
const map: Record<SubjectTrend['status'], string> = { const map: Record<SubjectTrend['status'], string> = {
@ -105,13 +127,8 @@ const handleQuickAction = (item: QuickAction) => {
width: 108rpx; width: 108rpx;
height: 108rpx; height: 108rpx;
border-radius: 36rpx; border-radius: 36rpx;
display: flex; flex-shrink: 0;
align-items: center; background: #ecfeff;
justify-content: center;
color: #ffffff;
font-size: 40rpx;
font-weight: 900;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 12rpx 28rpx rgba(8, 145, 178, 0.24); box-shadow: 0 12rpx 28rpx rgba(8, 145, 178, 0.24);
} }

View File

@ -3,9 +3,9 @@
<view class="account-card"> <view class="account-card">
<ParentCard> <ParentCard>
<view class="account-row"> <view class="account-row">
<view class="account-avatar"></view> <image class="account-avatar" :src="parentAvatarUrl" mode="aspectFill" />
<view class="account-main"> <view class="account-main">
<text class="phone">{{ displayPhone }}</text> <text class="nickname">{{ displayNickname }}</text>
<text class="current-child">当前孩子{{ activeChild.name }} · {{ activeChild.className || '暂无年级' }}</text> <text class="current-child">当前孩子{{ activeChild.name }} · {{ activeChild.className || '暂无年级' }}</text>
</view> </view>
</view> </view>
@ -28,10 +28,19 @@
<text class="child-account">{{ child.account }}</text> <text class="child-account">{{ child.account }}</text>
</view> </view>
</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> </ParentCard>
</view> </view>
<ManualBindChild
:visible="manualBindVisible"
@update:visible="manualBindVisible = $event"
@success="onBindSuccess"
/>
<view class="menu-card"> <view class="menu-card">
<ParentCard> <ParentCard>
<view class="menu-item" @click="showAbout"> <view class="menu-item" @click="showAbout">
@ -50,85 +59,71 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app'; 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 { useUserStore } from '@/state/modules/user';
import ParentCard from '../components/ParentCard.vue'; import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue'; import ParentPage from '../components/ParentPage.vue';
import ManualBindChild from '../components/ManualBindChild.vue';
import { scanAndGoBind } from '../utils/scanBind';
interface ChildItem { const DEFAULT_AVATAR = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/logo.png';
bindId: string;
id: string;
name: string;
account: string;
className: string;
}
const userStore = useUserStore(); 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 parentPhone = ref('');
const childrenList = ref<ChildItem[]>([]); const manualBindVisible = ref(false);
const activeChildIndex = ref(0);
const activeChild = computed(() => childrenList.value[activeChildIndex.value] || { const displayNickname = computed(() => {
name: '--', const nick = userInfo.value.nickName;
className: '--', if (nick != null && String(nick).trim() !== '') {
account: '', return String(nick).trim();
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 phone = userInfo.value.phone || parentPhone.value;
return phone?.trim() || '--';
});
onShow(() => { onShow(async () => {
fetchChildren(); 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) => { const selectChild = (index: number) => {
activeChildIndex.value = index; parentStore.selectChildByIndex(index);
uni.showToast({ uni.showToast({
title: `已切换到${childrenList.value[index]?.name || '孩子'}`, title: `已切换到${childrenList.value[index]?.name || '孩子'}`,
icon: 'none', icon: 'none',
}); });
}; };
const bindChild = () => { const openManualBind = () => {
uni.showToast({ manualBindVisible.value = true;
title: '绑定功能建设中', };
icon: 'none',
}); 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 = () => { const showAbout = () => {
@ -166,13 +161,8 @@ const handleLogout = () => {
width: 112rpx; width: 112rpx;
height: 112rpx; height: 112rpx;
border-radius: 38rpx; border-radius: 38rpx;
display: flex; flex-shrink: 0;
align-items: center; background: #ecfeff;
justify-content: center;
color: #ffffff;
font-size: 40rpx;
font-weight: 900;
background: linear-gradient(180deg, #67e8f9 0%, #0891b2 100%);
box-shadow: 0 12rpx 26rpx rgba(8, 145, 178, 0.22); box-shadow: 0 12rpx 26rpx rgba(8, 145, 178, 0.22);
} }
@ -181,7 +171,7 @@ const handleLogout = () => {
min-width: 0; min-width: 0;
} }
.phone { .nickname {
display: block; display: block;
color: #164e63; color: #164e63;
font-size: 38rpx; font-size: 38rpx;
@ -240,9 +230,15 @@ const handleLogout = () => {
font-size: 22rpx; font-size: 22rpx;
} }
.bind-btn { .bind-actions {
height: 78rpx; display: flex;
gap: 16rpx;
margin-top: 18rpx; margin-top: 18rpx;
}
.bind-action-btn {
flex: 1;
height: 78rpx;
border-radius: 24rpx; border-radius: 24rpx;
border: 2rpx dashed #67e8f9; border: 2rpx dashed #67e8f9;
display: flex; display: flex;
@ -251,6 +247,14 @@ const handleLogout = () => {
color: #0891b2; color: #0891b2;
font-size: 26rpx; font-size: 26rpx;
font-weight: 900; 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 { .menu-card {

View File

@ -27,7 +27,7 @@
:key="tab.key" :key="tab.key"
class="tab-item" class="tab-item"
:class="{ active: activeTab === tab.key }" :class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key" @click="switchTab(tab.key)"
> >
{{ tab.label }} {{ tab.label }}
</view> </view>
@ -77,63 +77,116 @@
</ParentCard> </ParentCard>
</template> </template>
<template v-else-if="activeTab === 'answers'"> <view v-if="activeTab === 'answers'" class="answers-panel">
<ParentCard class="section-card" title="答题卡详情" subtitle="点击题号查看每道题的答题情况"> <ParentCard class="section-card" title="答题卡详情" subtitle="点击题号查看每道题的答题情况">
<view class="answer-legend"> <view class="answer-legend">
<view><text class="legend-dot correct"></text>正确</view> <view><text class="legend-dot correct"></text>正确</view>
<view><text class="legend-dot wrong"></text>错误</view> <view><text class="legend-dot wrong"></text>错误</view>
<view><text class="legend-dot unanswered"></text>未答</view>
</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 <view
v-for="question in detail.questions" v-for="question in questionSummaries"
:key="question.no" :key="question.no"
class="question-no" class="question-no"
:class="[question.status, { active: selectedNo === question.no }]" :class="[question.status, { active: selectedNo === question.no }]"
@click="selectedNo = question.no" @click="selectQuestion(question.no)"
> >
{{ question.no }} {{ question.no }}
</view> </view>
</view> </view>
<xy-Empty v-else type="task" content="暂无题目数据" />
</ParentCard> </ParentCard>
<ParentCard class="section-card" title="当前题目详情"> <ParentCard v-if="selectedNo && selectedQuestionSummary" class="section-card" title="当前题目详情">
<view class="question-head"> <view class="question-head">
<text> {{ selectedQuestion.no }} </text> <text> {{ selectedQuestionSummary.no }} </text>
<text>{{ selectedQuestion.type }}</text> <text>{{ selectedQuestionSummary.type }}</text>
<text :class="selectedQuestion.status === 'correct' ? 'status-correct' : 'status-wrong'"> <text :class="statusClass(selectedQuestionSummary.status)">
{{ selectedQuestion.status === 'correct' ? '正确' : '错误' }} {{ statusLabel(selectedQuestionSummary.status) }}
</text> </text>
</view> </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"> <view class="field-card">
<text class="field-label">题干</text> <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>
<view class="field-card success"> <view class="field-card success">
<text class="field-label">正确答案</text> <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>
<view class="field-card student"> <view class="field-card student">
<text class="field-label">学员答案</text> <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>
<view class="field-card"> <view class="field-card">
<text class="field-label">得分情况</text> <text class="field-label">得分情况</text>
<text class="field-content">{{ selectedQuestion.score }}</text> <text class="field-content">{{ selectedQuestionSummary.score }}</text>
</view> </view>
<view class="field-card"> <view class="field-card">
<text class="field-label">班级得分率</text> <text class="field-label">班级得分率</text>
<text class="field-content">{{ selectedQuestion.classRate }}</text> <text class="field-content">{{ selectedQuestionSummary.classRate }}</text>
</view> </view>
<view class="field-card"> <view class="field-card">
<text class="field-label">学校得分率</text> <text class="field-label">学校得分率</text>
<text class="field-content">{{ selectedQuestion.schoolRate }}</text> <text class="field-content">{{ selectedQuestionSummary.schoolRate }}</text>
</view> </view>
</view> </view>
</ParentCard> </ParentCard>
<ParentCard class="section-card" title="每道题具体情况"> <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-table">
<view class="question-row table-head"> <view class="question-row table-head">
<text>题号</text> <text>题号</text>
@ -144,12 +197,17 @@
<text>班级得分率</text> <text>班级得分率</text>
<text>学校得分率</text> <text>学校得分率</text>
</view> </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.no }}</text>
<text>{{ question.type }}</text> <text>{{ question.type }}</text>
<text>{{ question.score }}</text> <text>{{ question.score }}</text>
<text :class="question.status === 'correct' ? 'status-correct' : 'status-wrong'"> <text :class="statusClass(question.status)">
{{ question.status === 'correct' ? '正确' : '错误' }} {{ statusLabel(question.status) }}
</text> </text>
<text>{{ question.knowledge }}</text> <text>{{ question.knowledge }}</text>
<text>{{ question.classRate }}</text> <text>{{ question.classRate }}</text>
@ -158,9 +216,9 @@
</view> </view>
</scroll-view> </scroll-view>
</ParentCard> </ParentCard>
</template> </view>
<template v-else> <template v-if="activeTab === 'knowledge'">
<ParentCard class="section-card" title="错题知识点占比" subtitle="用于判断最近应优先补强的知识点"> <ParentCard class="section-card" title="错题知识点占比" subtitle="用于判断最近应优先补强的知识点">
<view class="knowledge-summary"> <view class="knowledge-summary">
<view class="wrong-total"> <view class="wrong-total">
@ -223,9 +281,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app'; import { onLoad } from '@dcloudio/uni-app';
import { getJobAnalyse, getPaperRecordDetail } from '@/api/homework';
import ParentCard from '../components/ParentCard.vue'; import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.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 DetailTab = 'analysis' | 'answers' | 'knowledge';
type KnowledgeLevel = 'all' | 'weak' | 'pass' | 'good'; type KnowledgeLevel = 'all' | 'weak' | 'pass' | 'good';
@ -243,19 +313,210 @@ const knowledgeTabs: { key: KnowledgeLevel; label: string }[] = [
{ key: 'good', label: '良好' }, { 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 activeTab = ref<DetailTab>('analysis');
const selectedNo = ref(1); const selectedNo = ref(0);
const activeKnowledgeLevel = ref<KnowledgeLevel>('all'); 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) => { 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 mockDetail = computed(() => reportDetails[recordId.value] || reportDetails.r1);
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 formatRank = (rank?: unknown, total?: unknown) => {
const wrongCount = computed(() => detail.value.questions.filter((item) => item.status === 'wrong').length); 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); 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); 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 = () => { const downloadReport = () => {
uni.showToast({ uni.showToast({
title: '报告生成中', title: '报告生成中',
@ -289,6 +562,9 @@ const goHome = () => {
flex-direction: column; flex-direction: column;
gap: 36rpx; gap: 36rpx;
padding-bottom: calc(144rpx + env(safe-area-inset-bottom)); padding-bottom: calc(144rpx + env(safe-area-inset-bottom));
overflow-x: hidden;
width: 100%;
box-sizing: border-box;
} }
.hero-card { .hero-card {
@ -464,6 +740,11 @@ const goHome = () => {
font-weight: 900; font-weight: 900;
} }
.status-muted {
color: #64748b;
font-weight: 900;
}
.answer-legend { .answer-legend {
display: flex; display: flex;
gap: 28rpx; gap: 28rpx;
@ -491,6 +772,37 @@ const goHome = () => {
background: #ef4444; 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 { .question-grid {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);
@ -517,6 +829,12 @@ const goHome = () => {
background: #fef2f2; background: #fef2f2;
} }
.question-no.unanswered {
border-color: #e2e8f0;
color: #64748b;
background: #f8fafc;
}
.question-no.active { .question-no.active {
border-color: #2563eb; border-color: #2563eb;
box-shadow: 0 0 0 4rpx rgba(37, 99, 235, 0.1); box-shadow: 0 0 0 4rpx rgba(37, 99, 235, 0.1);
@ -535,6 +853,7 @@ const goHome = () => {
display: grid; display: grid;
gap: 18rpx; gap: 18rpx;
margin-top: 22rpx; margin-top: 22rpx;
min-width: 0;
} }
.field-card { .field-card {
@ -542,6 +861,9 @@ const goHome = () => {
border: 2rpx solid #dbeafe; border: 2rpx solid #dbeafe;
border-radius: 18rpx; border-radius: 18rpx;
background: #ffffff; background: #ffffff;
overflow: hidden;
min-width: 0;
box-sizing: border-box;
} }
.field-card.success { .field-card.success {
@ -576,6 +898,64 @@ const goHome = () => {
color: #334155; color: #334155;
font-size: 25rpx; font-size: 25rpx;
line-height: 1.55; 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 { .question-table-scroll {

View File

@ -1,38 +1,22 @@
<template> <template>
<ParentPage active="reports"> <ParentPage active="reports" @scrolltolower="loadMore">
<view class="filter-row"> <ParentFilterRow
<picker class="filter-picker" :range="subjectOptions" :value="subjectIndex" @change="onSubjectChange"> :items="filterItems"
<view class="filter-pill"> @change="onFilterChange"
<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>
<view v-if="filteredReports.length" class="report-list"> <view v-if="reportList.length" class="report-list">
<ParentCard <ParentCard
v-for="item in filteredReports" v-for="item in reportList"
:key="item.id" :key="item.id"
class="report-card" class="report-card"
clickable clickable
@click="openReport(item.id)" @click="openReport(item)"
> >
<view class="report-body"> <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="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> <text class="report-title ellipsis-2">{{ item.title }}</text>
</view> </view>
<view class="score-wrap"> <view class="score-wrap">
@ -41,91 +25,209 @@
</view> </view>
</view> </view>
</ParentCard> </ParentCard>
<view v-if="loadingMore" class="list-tip">加载中...</view>
<view v-else-if="!hasMore" class="list-tip">没有更多了</view>
</view> </view>
<view v-else class="empty-card"> <view v-else-if="!listLoading" class="empty-card">
<xy-Empty type="task" content="暂无符合条件的考试报告" /> <xy-Empty type="task" content="暂无符合条件的考试报告" />
</view> </view>
<view v-if="listLoading" class="loading-wrap">
<xy-Empty type="loading" content="加载中..." />
</view>
</ParentPage> </ParentPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; 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 ParentCard from '../components/ParentCard.vue';
import ParentFilterRow from '../components/ParentFilterRow.vue';
import ParentPage from '../components/ParentPage.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 subjectIndex = ref(0);
const gradeIndex = ref(0); const typeIndex = ref(0);
const categoryIndex = ref(0);
const filteredReports = computed(() => { const reportList = ref<ReportCard[]>([]);
const subject = subjectOptions[subjectIndex.value]; const listLoading = ref(false);
const grade = gradeOptions[gradeIndex.value]; const loadingMore = ref(false);
const category = categoryOptions[categoryIndex.value]; const hasMore = ref(true);
return reportList.filter((item) => { const currentPage = ref(1);
const subjectMatched = subject === '全部学科' || item.subject === subject;
const gradeMatched = grade === '全部年级' || item.grade === grade; const subjectOptionLabels = computed(() => subjectOptions.value.map((item) => item.label));
const categoryMatched = category === '全部类型' || item.category === category;
return subjectMatched && gradeMatched && categoryMatched; 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 buildRequestParams = (): ListCompleteRecordParams => {
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); }; const params: ListCompleteRecordParams = {
const onCategoryChange = (e: any) => { categoryIndex.value = Number(e.detail.value); }; 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 formatScore = (score: number) => `${score.toFixed(1).replace(/\.0$/, '')}%`;
const subjectClass = (subject: ReportItem['subject']) => { const subjectShort = (subject: string) => {
if (subject === '数学') return 'math'; if (!subject) return '多科';
if (subject === '语文') return 'chinese'; return subject.length > 2 ? subject.slice(0, 2) : subject;
if (subject === '英语') return 'english'; };
const subjectClass = (subject: string) => {
if (subject.includes('数学')) return 'math';
if (subject.includes('语文')) return 'chinese';
if (subject.includes('英语')) return 'english';
return 'multi'; return 'multi';
}; };
const openReport = (id: string) => { const openReport = (item: ReportCard) => {
uni.navigateTo({ url: `/pages/parent/reports/detail?id=${id}` }); 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> </script>
<style lang="scss" scoped> <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 { .report-list {
margin-top: 22rpx; margin-top: 22rpx;
} }
@ -203,4 +305,16 @@ const openReport = (id: string) => {
height: 520rpx; height: 520rpx;
margin-top: 24rpx; 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> </style>

View File

@ -1,3 +1,5 @@
import { SUBJECT_FILTER_OPTIONS } from '@/utils/subject';
export interface ParentChild { export interface ParentChild {
id: string; id: string;
name: string; name: string;
@ -53,14 +55,29 @@ export interface ReportDifficultyRow {
rate: string; rate: string;
} }
/** 答题卡列表用的轻量字段(不含 HTML */
export type ReportQuestionSummary = Pick<
ReportQuestion,
'no' | 'type' | 'score' | 'status' | 'classRate' | 'schoolRate' | 'knowledge'
>;
export interface ReportQuestion { export interface ReportQuestion {
no: number; no: number;
type: string; type: string;
score: string; score: string;
status: 'correct' | 'wrong'; status: 'correct' | 'wrong' | 'unanswered';
stem: string; /** 供 rich-text 渲染的题干 HTML */
correctAnswer: string; stemHtml: string;
/** 题干中的图片(与学生端一致,从 HTML 中拆出单独展示) */
stemImages: string[];
/** 供 rich-text 渲染的正确答案 HTML */
correctAnswerHtml: string;
/** 纯文本学员答案(选择题等) */
studentAnswer: string; studentAnswer: string;
/** 供 rich-text 渲染的学员答案 HTML富文本作答 */
studentAnswerHtml: string;
/** 学员上传的图片答案 */
studentAnswerImages: string[];
classRate: string; classRate: string;
schoolRate: string; schoolRate: string;
knowledge: 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 gradeOptions = ['全部年级', '初一', '初二', '初三'];
export const categoryOptions = ['全部类型', '周作业', '月考']; 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 type { Router } from 'uni-mini-router';
import { useUserStore } from '@/state/modules/user'; import { useUserStore } from '@/state/modules/user';
import { getCache } from '@/utils/cache'; import { getCache } from '@/utils/cache';
import { getStoredRole } from '@/utils/roleHome';
// 免登录白名单(路由 name // 免登录白名单(路由 name
const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile']; const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile'];
@ -15,20 +16,23 @@ export function userRouternext(router: Router) {
if (userStore.isLogin) { if (userStore.isLogin) {
const currentRole = getCache('role'); const currentRole = getCache('role');
const targetRole = (to as any).meta?.role; const targetRole = (to as any).meta?.role;
if (currentRole === 'teacher' && to.name === 'home') { if (currentRole && to.name === 'home') {
return next({ const homeName = currentRole === 'teacher'
name: 'teacher-home', ? 'teacher-home'
}); : currentRole === 'parent'
} ? 'parent-home'
if (currentRole === 'parent' && to.name === 'home') { : 'home';
return next({ if (homeName !== 'home') {
name: 'parent-home', return next({ name: homeName });
}); }
} }
if (targetRole && currentRole && targetRole !== currentRole) { if (targetRole && currentRole && targetRole !== currentRole) {
return next({ const homeName = currentRole === 'teacher'
name: currentRole === 'teacher' ? 'teacher-home' : currentRole === 'parent' ? 'parent-home' : 'home', ? 'teacher-home'
}); : currentRole === 'parent'
? 'parent-home'
: 'home';
return next({ name: homeName });
} }
return next(); return next();
} }
@ -45,9 +49,10 @@ export function userRouternext(router: Router) {
router.afterEach((to) => { router.afterEach((to) => {
const userStore = useUserStore(); const userStore = useUserStore();
if (userStore.isLogin && to?.name === 'login') { if (userStore.isLogin && to?.name === 'login') {
const role = getCache('role'); const role = getStoredRole();
if (!role) return; 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 { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { setCache, getCache, removeCache } from '@/utils/cache'; import { setCache, getCache, removeCache } from '@/utils/cache';
import { useParentStore } from '@/state/modules/parent';
import { import {
psdLogin as psdLoginApi, psdLogin as psdLoginApi,
parentSmsLogin as parentSmsLoginApi, parentSmsLogin as parentSmsLoginApi,
getUserInfo as getUserInfoApi, getUserInfo as getUserInfoApi,
selectUser as selectUserApi,
logout as logoutApi, logout as logoutApi,
type AccountLoginParams, type AccountLoginParams,
type ParentSmsLoginParams, type ParentSmsLoginParams,
@ -43,6 +45,8 @@ export const useUserStore = defineStore('user', () => {
removeCache('userInfo'); removeCache('userInfo');
removeCache('teacherInfo'); removeCache('teacherInfo');
removeCache('role'); removeCache('role');
removeCache('parentActiveChildId');
useParentStore().clear();
}; };
/** 拉取并解析用户信息 */ /** 拉取并解析用户信息 */
@ -90,6 +94,20 @@ export const useUserStore = defineStore('user', () => {
await fetchUserInfo(); 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 */ /** 家长端 - 保存登录 token */
const saveParentSession = (accessToken: string) => { const saveParentSession = (accessToken: string) => {
if (!accessToken?.trim()) { if (!accessToken?.trim()) {
@ -139,6 +157,7 @@ export const useUserStore = defineStore('user', () => {
parentMpLoginSuccess, parentMpLoginSuccess,
parentLogin, parentLogin,
fetchUserInfo, fetchUserInfo,
fetchParentUserInfo,
logout, logout,
clear, clear,
updateUserInfo, 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),
})),
];