2026-06-22 18:50:03 +08:00

782 lines
21 KiB
Vue

<template>
<view class="dw-page">
<!-- 装饰背景 -->
<view class="bg-decor bg-decor-a"></view>
<view class="bg-decor bg-decor-b"></view>
<!-- 顶部条 -->
<view class="top">
<view class="top-back" @click="handleBack">
<view class="back-arrow"></view>
</view>
<view v-if="!isCropperActive" class="top-title">
<text class="ellipsis">{{ title }}</text>
</view>
<view
v-if="!pageLoading && !isCropperActive"
class="top-meta"
:style="{ paddingRight: menuButtonRightRpx + 'rpx' }"
>
<view class="top-progress">
<text class="cur">{{ activeIdx + 1 }}</text>
<text class="divider">/</text>
<text class="total">{{ paperList.length }}</text>
</view>
<view class="top-clock">
<image class="clock-icon" src="/static/image/svg/time.svg" mode="aspectFit" />
<text>{{ timeFilter }}</text>
</view>
</view>
</view>
<!-- 主体 -->
<view v-if="!pageLoading" class="main">
<view class="book-box">
<Question
v-if="paperList.length && paperList[activeIdx]"
:key="`question-${activeIdx}-${paperList[activeIdx]?.id ?? ''}`"
:data="paperList[activeIdx]"
:idx="activeIdx + 1"
:total="paperList.length"
:resourceType="resourceType"
:reportFlag="reportFlag"
:subjectId="paperInfo.subjectId"
@submit="submitQuestion"
@cropper-visible-change="onCropperVisibleChange"
/>
</view>
<!-- 底部题号 + 操作 -->
<view class="bottom">
<scroll-view class="indicator" scroll-x :show-scrollbar="false">
<view class="indicator-track">
<view
v-for="(item, idx) in paperList"
v-show="!item.unLook"
:key="item.id || idx"
:class="[
'indicator-item',
{
active: activeIdx === idx,
success: reportFlag && (item.correctResult || item.markFlag),
error: reportFlag && !(item.correctResult || item.markFlag) && (item.submitAnswer || item.submitAnswerPic),
empty: !reportFlag && !item.submitAnswer && !item.submitAnswerPic,
answered: !reportFlag && (item.submitAnswer || item.submitAnswerPic),
},
]"
@click="switchQuestion(idx)"
>
<text>{{ idx + 1 }}</text>
</view>
</view>
</scroll-view>
<view
v-if="!reportFlag"
:class="['btn-next', { disabled: submitLoading, submit: activeIdx >= paperList.length - 1 }]"
@click="handleNext"
>
<text>{{ activeIdx < paperList.length - 1 ? '下一题' : '提交' }}</text>
<view class="btn-arrow">
<svg viewBox="0 0 16 16" fill="none">
<path d="m6 4 4 4-4 4" stroke="#FFFFFF" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</view>
</view>
</view>
</view>
<!-- 加载中 -->
<view v-else class="loading-page">
<Loading :text="reportFlag ? '正在加载报告中...' : '正在精选题目中...'" />
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import {
startExam,
saveRecordAnswer,
submitExam,
getPaperRecordDetail,
} from '@/api/homework';
import {
resource_type_homework,
normalizeResourceType,
isHomeworkExamResourceType,
} from '@/constants/doWork';
import Loading from '@/components/Loading/index.vue';
import Question from './components/Question.vue';
const pageLoading = ref(true);
const submitLoading = ref(false);
const menuButtonRightRpx = ref(0);
const isCropperActive = ref(false);
const paperList = ref<any[]>([]);
const paperInfo = ref<any>({});
const activeIdx = ref(0);
const reportFlag = ref<0 | 1>(0);
const params = ref<any>({});
let timer: any = null;
const initialIdx = ref(0);
const resourceType = computed(() =>
normalizeResourceType(params.value.resourceType || paperInfo.value.resourceType),
);
const normalizePaperList = (list: any, markUnLook = false) => {
if (!Array.isArray(list)) return [];
if (!markUnLook) return list;
return list.map((item: any, idx: number) => ({
...item,
unLook: idx > 0,
}));
};
const normalizeAnswerPic = (value: any) => String(value || '')
.split(',')
.map((url) => String(url || '').replace(/[`]/g, '').replace(/http:\/\//gi, 'https://').trim())
.filter(Boolean)
.join(',');
const isUploadImageQuestion = (item: any) => {
const typeText = [
item?.titleChannelTypeName,
item?.channelTypeName,
item?.typeName,
item?.titleTypeName,
].filter(Boolean).join(',');
return /上传|拍照/.test(typeText);
};
const extractPicLinksFromAnswer = (answer: any) => {
const raw = String(answer || '');
if (!raw) return '';
const links = raw.match(/https?:\/\/[^\s,]+/g) || [];
return normalizeAnswerPic(links.join(','));
};
const getSubmitAnswerPic = (item: any) => {
const answerPic = normalizeAnswerPic(item?.submitAnswerPic);
if (answerPic) return answerPic;
if (!isUploadImageQuestion(item)) return '';
return extractPicLinksFromAnswer(item?.submitAnswer);
};
const getQuestionKey = (item: any) => item?.id || item?.itemId || item?.jobStuTittleId || '';
const normalizeCurrentMultiAnswer = () => {
const cur = paperList.value[activeIdx.value];
if (!cur || cur.titleChannelTypeName !== '多选题') return;
const raw = String(cur.submitAnswer || '');
cur.submitAnswer = raw
.split(',')
.flatMap((item) => item.split(''))
.map((item) => item.trim())
.filter(Boolean)
.sort()
.join(',');
};
const title = computed(() => {
const name = paperInfo.value.paperName;
if (name) {
return name.replace(/-\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, '');
}
return '作业';
});
const timeFilter = computed(() => {
const cost = paperInfo.value.costTime || 0;
const h = Math.floor(cost / 3600);
const m = Math.floor((cost % 3600) / 60);
const s = cost % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
});
onLoad((options: any) => {
initialIdx.value = Number(options.idx) || 0;
// 与 Web route.params.id 一致:带 id 时为查看报告
if (options.id) {
reportFlag.value = 1;
params.value = {
...options,
id: options.id,
recordId: options.recordId || options.id,
resourceType: normalizeResourceType(options.resourceType),
};
} else {
reportFlag.value = Number(options.reportFlag || 0) as 0 | 1;
params.value = {
...options,
resourceType: normalizeResourceType(options.resourceType),
};
if (reportFlag.value === 1 && params.value.recordId && !params.value.id) {
params.value.id = params.value.recordId;
}
}
init();
});
const handleBack = () => {
if (reportFlag.value) {
uni.navigateBack();
return;
}
uni.showModal({
title: '提示',
content: '是否确认退出,退出后不保留答题记录',
confirmColor: '#4f46e5',
success: (res) => {
if (res.confirm) {
clearTimer();
uni.navigateBack();
}
},
});
};
const switchQuestion = async (idx?: number) => {
if (idx === activeIdx.value) {
if (!reportFlag.value && isHomeworkExamResourceType(resourceType.value)) {
await saveAnswer();
}
return;
}
if (idx !== undefined) {
if (!reportFlag.value && isHomeworkExamResourceType(resourceType.value)) {
normalizeCurrentMultiAnswer();
await saveAnswer();
}
activeIdx.value = idx;
} else if (activeIdx.value < paperList.value.length - 1) {
if (!reportFlag.value && isHomeworkExamResourceType(resourceType.value)) {
normalizeCurrentMultiAnswer();
await saveAnswer();
}
activeIdx.value++;
}
if (!reportFlag.value && paperList.value[activeIdx.value]) {
paperList.value[activeIdx.value].unLook = false;
}
};
const handleNext = async () => {
if (submitLoading.value) return;
if (activeIdx.value < paperList.value.length - 1) {
await switchQuestion();
} else {
if (paperList.value.some((i) => !i.submitAnswer && !i.submitAnswerPic)) {
uni.showModal({
title: '提示',
content: '还有题目未作答,确定要提交答案吗?',
confirmColor: '#4f46e5',
success: (res) => {
if (res.confirm) submitTrain();
},
});
} else {
submitTrain();
}
}
};
const saveAnswer = async () => {
const cur = paperList.value[activeIdx.value];
if (!cur) return;
try {
await saveRecordAnswer({
answer: cur.submitAnswer || '',
answerPic: cur.submitAnswerPic || '',
costTime: (paperInfo.value.costTime || 0) * 1000,
itemId: getQuestionKey(cur),
recordId: params.value.recordId,
});
} catch (err) {
console.error('[doWork] save err', err);
}
};
const submitQuestion = (answer: any) => {
if (reportFlag.value) return;
const idx = activeIdx.value;
if (answer.submitAnswer !== undefined) {
paperList.value[idx].submitAnswer = answer.submitAnswer;
}
if (answer.submitAnswerPic !== undefined) {
paperList.value[idx].submitAnswerPic = answer.submitAnswerPic;
}
if (answer.toNext && activeIdx.value < paperList.value.length - 1) {
setTimeout(() => switchQuestion(), 300);
}
};
const onCropperVisibleChange = (visible: boolean) => {
isCropperActive.value = !!visible;
};
const submitTrain = async () => {
clearTimer();
submitLoading.value = true;
uni.showLoading({ title: '提交中...', mask: true });
try {
normalizeCurrentMultiAnswer();
await saveAnswer();
await submitExam({
answerDtoList: paperList.value.map((item) => ({
itemId: getQuestionKey(item),
answer: item.submitAnswer || '',
answerPic: getSubmitAnswerPic(item),
})),
costTime: (paperInfo.value.costTime || 0) * 1000,
recordId: params.value.recordId,
});
uni.hideLoading();
uni.showModal({
title: '提示',
content: '试卷提交成功请等待批改',
showCancel: false,
confirmText: '好的',
confirmColor: '#4f46e5',
success: () => uni.navigateBack(),
});
} catch (err: any) {
uni.hideLoading();
uni.showToast({ title: err?.message || '提交失败', icon: 'none' });
startTimer();
} finally {
submitLoading.value = false;
}
};
const startTimer = (init?: boolean) => {
clearTimer();
if (init) paperInfo.value.costTime = 0;
timer = setInterval(() => {
paperInfo.value.costTime = (paperInfo.value.costTime || 0) + 1;
}, 1000);
};
const clearTimer = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const initReport = async () => {
const res = await getPaperRecordDetail({
recordId: params.value.recordId || params.value.id,
reportFlag: 1,
});
paperInfo.value = res.data;
paperList.value = normalizePaperList(res.data?.titleVoList || res.data?.subjectTitleVoList);
};
const initNewTrain = async () => {
if (resourceType.value !== resource_type_homework) {
throw new Error('暂不支持该练习类型');
}
const res: any = await startExam(params.value.recordId);
paperInfo.value = res.data || res;
if (paperInfo.value.costTime) {
paperInfo.value.costTime = Math.floor(paperInfo.value.costTime / 1000);
}
paperList.value = normalizePaperList(
paperInfo.value?.titleVoList || paperInfo.value?.subjectTitleVoList || [],
true,
);
};
const getMenuButtonInfo = () => {
try {
// #ifdef MP-WEIXIN
const m = uni.getMenuButtonBoundingClientRect();
const sys = uni.getSystemInfoSync();
if (m && sys) {
const rightPx = sys.windowWidth - m.right;
const widthPx = m.width;
const pxToRpx = 750 / sys.windowWidth;
menuButtonRightRpx.value = Math.ceil((rightPx + widthPx) * pxToRpx) + 16;
} else {
menuButtonRightRpx.value = 120;
}
// #endif
// #ifndef MP-WEIXIN
menuButtonRightRpx.value = 0;
// #endif
} catch {
menuButtonRightRpx.value = 120;
}
};
const init = async () => {
try {
pageLoading.value = true;
if (reportFlag.value) {
await initReport();
} else {
await initNewTrain();
startTimer();
}
const target = Math.min(initialIdx.value, paperList.value.length - 1);
activeIdx.value = Math.max(0, target);
if (!reportFlag.value && paperList.value[activeIdx.value]) {
paperList.value[activeIdx.value].unLook = false;
}
} catch (err: any) {
console.error('[doWork] init err', err);
uni.showToast({ title: err?.message || '加载失败', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
} finally {
pageLoading.value = false;
}
};
onMounted(() => {
getMenuButtonInfo();
});
onUnmounted(() => {
clearTimer();
isCropperActive.value = false;
paperInfo.value = {};
paperList.value = [];
activeIdx.value = 0;
});
</script>
<style lang="scss" scoped>
.dw-page {
height: 100vh;
width: 100vw;
overflow: hidden;
box-sizing: border-box;
position: relative;
background: #f5f7ff;
display: flex;
flex-direction: column;
}
/* 装饰背景 */
.bg-decor {
position: absolute;
border-radius: 50%;
filter: blur(60rpx);
pointer-events: none;
z-index: 0;
&-a {
top: -80rpx;
right: -80rpx;
width: 280rpx;
height: 280rpx;
background: radial-gradient(circle, rgba(129, 140, 248, 0.35), transparent 70%);
}
&-b {
bottom: -80rpx;
left: -80rpx;
width: 280rpx;
height: 280rpx;
background: radial-gradient(circle, rgba(34, 197, 94, 0.3), transparent 70%);
}
}
/* 顶部条 */
.top {
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 3rpx 12rpx 2rpx;
gap: 8rpx;
}
.top-back {
width: 34rpx;
height: 34rpx;
flex-shrink: 0;
background: #ffffff;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4rpx 10rpx rgba(99, 102, 241, 0.18),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.6);
&:active {
transform: scale(0.92);
}
}
.back-arrow {
width: 9rpx;
height: 9rpx;
border-left: 2rpx solid #4f46e5;
border-bottom: 2rpx solid #4f46e5;
transform: rotate(45deg);
margin-left: 4rpx;
}
.top-title {
flex: 1;
min-width: 0;
font-size: 17rpx;
font-weight: 700;
color: #312e81;
text-align: center;
text {
max-width: 100%;
}
}
.top-meta {
display: flex;
align-items: center;
gap: 6rpx;
flex-shrink: 0;
}
.top-progress {
display: flex;
align-items: baseline;
gap: 2rpx;
padding: 3rpx 10rpx;
background: linear-gradient(180deg, #818cf8, #4f46e5);
border-radius: 999rpx;
color: #ffffff;
box-shadow:
0 4rpx 10rpx rgba(79, 70, 229, 0.3),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.12);
.cur {
font-size: 16rpx;
font-weight: 800;
}
.divider {
font-size: 11rpx;
opacity: 0.6;
}
.total {
font-size: 13rpx;
opacity: 0.8;
}
}
.top-clock {
display: flex;
align-items: center;
gap: 4rpx;
padding: 5rpx 10rpx;
background: #ffffff;
border-radius: 999rpx;
box-shadow:
0 4rpx 10rpx rgba(99, 102, 241, 0.15),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.5);
.clock-icon {
width: 17rpx;
height: 17rpx;
flex-shrink: 0;
}
text {
font-size: 15rpx;
font-weight: 700;
color: #4f46e5;
font-variant-numeric: tabular-nums;
line-height: 20rpx;
vertical-align: middle;
}
}
/* 主体 */
.main {
flex: 1;
height: 0;
position: relative;
z-index: 1;
padding: 0 10rpx;
display: flex;
flex-direction: column;
}
.book-box {
flex: 1;
overflow: hidden;
position: relative;
}
.bottom {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4rpx;
padding: 3rpx 0 calc(6rpx + constant(safe-area-inset-bottom));
padding: 3rpx 0 calc(6rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.indicator {
flex: 1;
width: 0;
height: 40rpx;
white-space: nowrap;
}
.indicator-track {
display: inline-flex;
align-items: center;
gap: 5rpx;
padding: 4rpx 0;
}
.indicator-item {
flex-shrink: 0;
width: 31rpx;
height: 31rpx;
background: #ffffff;
border-radius: 9rpx;
border: 2rpx solid transparent;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4rpx 8rpx rgba(99, 102, 241, 0.12),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.5);
transition: all 150ms ease;
text {
font-size: 15rpx;
font-weight: 700;
color: #6366f1;
}
&.answered {
background: linear-gradient(180deg, #c7d2fe, #818cf8);
text {
color: #ffffff;
}
}
&.empty {
background: #f1f5f9;
box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
text {
color: #94a3b8;
}
}
&.success {
background: linear-gradient(180deg, #4ade80, #16a34a);
box-shadow:
0 6rpx 12rpx rgba(22, 163, 74, 0.3),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
text {
color: #ffffff;
}
}
&.error {
background: linear-gradient(180deg, #f87171, #dc2626);
box-shadow:
0 6rpx 12rpx rgba(220, 38, 38, 0.3),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
text {
color: #ffffff;
}
}
&.active {
z-index: 1;
transform: translateY(-2rpx);
background: linear-gradient(180deg, #818cf8, #4f46e5);
border-color: rgba(255, 255, 255, 0.9);
box-shadow:
0 8rpx 16rpx rgba(79, 70, 229, 0.45),
0 0 0 3rpx rgba(129, 140, 248, 0.18),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
text {
color: #ffffff;
}
}
}
.btn-next {
flex-shrink: 0;
height: 32rpx;
padding: 0 14rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
background: linear-gradient(180deg, #4ade80, #16a34a);
color: #ffffff;
box-shadow:
0 6rpx 14rpx rgba(22, 163, 74, 0.35),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
transition: all 150ms ease;
text {
font-size: 15rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.btn-arrow {
width: 15rpx;
height: 15rpx;
svg {
width: 100%;
height: 100%;
}
}
&.submit {
background: linear-gradient(180deg, #818cf8, #4f46e5);
box-shadow:
0 6rpx 14rpx rgba(79, 70, 229, 0.4),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
}
&.disabled {
opacity: 0.6;
}
&:active {
transform: translateY(2rpx);
}
}
/* 加载 */
.loading-page {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
</style>