feat: 答题页面
This commit is contained in:
parent
9d61f4ee8a
commit
a14d38b6f5
8
.components.d.ts
vendored
8
.components.d.ts
vendored
@ -9,10 +9,17 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default']
|
||||
AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
|
||||
ChoiceAnswerPanel: typeof import('./src/components/answerQuestions/ChoiceAnswerPanel.vue')['default']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
LmSelect: typeof import('./src/components/lm/LmSelect.vue')['default']
|
||||
LoadingState: typeof import('./src/components/loading/LoadingState.vue')['default']
|
||||
PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default']
|
||||
QuestionNumberNav: typeof import('./src/components/answerQuestions/QuestionNumberNav.vue')['default']
|
||||
QuestionStemPanel: typeof import('./src/components/answerQuestions/QuestionStemPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SubjectCard: typeof import('./src/components/cards/SubjectCard.vue')['default']
|
||||
@ -20,5 +27,6 @@ declare module 'vue' {
|
||||
TabBar: typeof import('./src/components/layout/TabBar.vue')['default']
|
||||
TaskCard: typeof import('./src/components/cards/TaskCard.vue')['default']
|
||||
TaskSidebar: typeof import('./src/components/tasks/TaskSidebar.vue')['default']
|
||||
UploadAnswerPanel: typeof import('./src/components/answerQuestions/UploadAnswerPanel.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
BIN
src/assets/AnswerQuestions/time.png
Normal file
BIN
src/assets/AnswerQuestions/time.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
121
src/components/lm/LmSelect.vue
Normal file
121
src/components/lm/LmSelect.vue
Normal file
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<el-select
|
||||
v-model="selectedValue"
|
||||
class="lm-select"
|
||||
:style="{ width }"
|
||||
:placeholder="placeholder"
|
||||
:popper-class="popperClass"
|
||||
:teleported="teleported"
|
||||
:fit-input-width="false"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
type SelectValue = string | number;
|
||||
|
||||
export interface LmSelectOption {
|
||||
label: string;
|
||||
value: SelectValue;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: SelectValue;
|
||||
options: LmSelectOption[];
|
||||
placeholder?: string;
|
||||
width?: string;
|
||||
popperClass?: string;
|
||||
teleported?: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: "请选择",
|
||||
width: "132px",
|
||||
popperClass: "lm-select-popper",
|
||||
teleported: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: SelectValue];
|
||||
}>();
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: SelectValue) => emit("update:modelValue", value),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lm-select {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper) {
|
||||
min-height: 46px;
|
||||
padding: 0 14px 0 20px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 0 1px #edf0f5 inset,
|
||||
0 4px 12px rgba(20, 40, 80, 0.06);
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper.is-focused),
|
||||
:deep(.el-select__wrapper.is-hovering) {
|
||||
box-shadow:
|
||||
0 0 0 1px #edf0f5 inset,
|
||||
0 4px 12px rgba(20, 40, 80, 0.06);
|
||||
}
|
||||
|
||||
:deep(.el-select__selection) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-select__selected-item) {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.el-select__placeholder) {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
:deep(.el-select__suffix) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-select__caret) {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #666;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
:global(.lm-select-popper.el-popper) {
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(20, 40, 80, 0.12);
|
||||
}
|
||||
|
||||
:global(.lm-select-popper .el-select-dropdown__item) {
|
||||
height: 42px;
|
||||
color: #303133;
|
||||
font-size: 20px;
|
||||
line-height: 42px;
|
||||
}
|
||||
|
||||
:global(.lm-select-popper .el-select-dropdown__item.is-selected) {
|
||||
color: #2f80ed;
|
||||
}
|
||||
</style>
|
||||
@ -21,12 +21,24 @@ const router = createRouter({
|
||||
component: () => import('@/views/Subjects/TextbookDetail.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject/video/:bookId',
|
||||
name: 'video-learning',
|
||||
component: () => import('@/views/Subjects/VideoLearning.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject/wrong-questions',
|
||||
name: 'wrong-questions',
|
||||
component: () => import('@/views/Subjects/WrongQuestions.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/answer-questions/wrong-retrain',
|
||||
name: 'wrong-retrain-answer',
|
||||
component: () => import('@/views/AnswerQuestions/WrongRetrain.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: 'tasks',
|
||||
|
||||
658
src/views/AnswerQuestions/WrongRetrain.vue
Normal file
658
src/views/AnswerQuestions/WrongRetrain.vue
Normal file
@ -0,0 +1,658 @@
|
||||
<template>
|
||||
<div class="wrong-retrain-page">
|
||||
<SubPageHeader class="retrain-header titled-header">
|
||||
<template #left>
|
||||
<button class="detail-back" type="button" @click="handleBack">
|
||||
<svg class="detail-back-icon" viewBox="0 0 32 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M24 4L6 26L24 48"
|
||||
stroke="#111827"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ pageTitle }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #center>
|
||||
<QuestionNumberNav
|
||||
:total="questionList.length"
|
||||
:current-index="currentQuestionIndex"
|
||||
@update:current-index="currentQuestionIndex = $event"
|
||||
/>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<div class="workspace-card">
|
||||
<div class="workspace-header">
|
||||
<div class="question-base-info">
|
||||
<span class="question-progress">
|
||||
{{ currentQuestionIndex + 1 }}/{{ questionList.length }}
|
||||
</span>
|
||||
<span class="question-score">[{{ currentQuestion.score }}分]</span>
|
||||
<span class="small-tag blue">{{ currentQuestion.subject }}</span>
|
||||
<span class="small-tag blue-light">{{ currentQuestion.questionType }}</span>
|
||||
<span class="small-tag orange">{{ currentQuestion.difficulty }}</span>
|
||||
</div>
|
||||
<div class="timer-chip">用时: <span class="time">{{ currentQuestion.duration }}s</span></div>
|
||||
<div class="header-actions">
|
||||
<template v-if="isDetailMode">
|
||||
<button class="light-action-btn" type="button">错题反馈</button>
|
||||
<button class="light-action-btn" type="button">举一反三</button>
|
||||
</template>
|
||||
<button v-else class="next-btn" type="button" @click="goNextQuestion">下一题</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content" :class="{ 'answer-collapsed': isAnswerCollapsed }">
|
||||
<QuestionStemPanel
|
||||
:question="currentQuestion"
|
||||
:current-index="currentQuestionIndex"
|
||||
:total="questionList.length"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="collapse-answer-btn"
|
||||
:class="{ collapsed: isAnswerCollapsed }"
|
||||
type="button"
|
||||
:aria-label="isAnswerCollapsed ? '展开答题区域' : '折叠答题区域'"
|
||||
@click="isAnswerCollapsed = !isAnswerCollapsed"
|
||||
>
|
||||
<span class="collapse-arrow">{{ isAnswerCollapsed ? "‹" : "›" }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="!isAnswerCollapsed" class="right-panel">
|
||||
<template v-if="!isDetailMode">
|
||||
<ChoiceAnswerPanel
|
||||
v-if="currentQuestion.answerType === 'choice'"
|
||||
:model-value="choiceAnswers[currentQuestion.id]"
|
||||
:options="currentQuestion.options ?? []"
|
||||
@update:model-value="choiceAnswers[currentQuestion.id] = $event"
|
||||
/>
|
||||
<UploadAnswerPanel
|
||||
v-else
|
||||
:model-value="uploadAnswers[currentQuestion.id]"
|
||||
@update:model-value="uploadAnswers[currentQuestion.id] = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="detail-answer-card">
|
||||
<div class="answer-title-row">
|
||||
<h3 class="answer-title">我的答案</h3>
|
||||
<div
|
||||
class="score-result"
|
||||
:class="currentQuestion.isCorrect ? 'correct' : 'wrong'"
|
||||
>
|
||||
答{{ currentQuestion.isCorrect ? "对啦" : "错啦" }}!得{{ currentQuestion.isCorrect ? currentQuestion.score : 0 }}分
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentQuestion.answerType === 'choice'" class="review-option-list">
|
||||
<div
|
||||
v-for="option in currentQuestion.options ?? []"
|
||||
:key="option.value"
|
||||
class="review-option-item"
|
||||
:class="getReviewOptionClass(option.value)"
|
||||
>
|
||||
<span class="review-option-key">{{ option.value }}</span>
|
||||
<span class="review-option-text">{{ option.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="image-answer-preview">
|
||||
<div class="paper-answer">
|
||||
<div class="paper-text">
|
||||
There is no <span>point</span> trying<br />
|
||||
to persuade him.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="answer-section">
|
||||
<h4>答案</h4>
|
||||
<p>{{ currentQuestion.answerText }}</p>
|
||||
</div>
|
||||
|
||||
<div class="answer-section">
|
||||
<h4>解析</h4>
|
||||
<p>{{ currentQuestion.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import SubPageHeader from "@/components/layout/SubPageHeader.vue";
|
||||
import QuestionNumberNav from "@/views/AnswerQuestions/components/QuestionNumberNav.vue";
|
||||
import QuestionStemPanel from "@/views/AnswerQuestions/components/QuestionStemPanel.vue";
|
||||
import ChoiceAnswerPanel from "@/views/AnswerQuestions/components/ChoiceAnswerPanel.vue";
|
||||
import UploadAnswerPanel from "@/views/AnswerQuestions/components/UploadAnswerPanel.vue";
|
||||
import type {
|
||||
AnswerQuestion,
|
||||
UploadAnswerValue,
|
||||
} from "@/views/AnswerQuestions/components/types";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isDetailMode = computed(() => route.query.mode === "detail");
|
||||
const pageTitle = computed(() => (isDetailMode.value ? "查看详情" : "错题重练"));
|
||||
const requestedCount = Number(route.query.count ?? 10);
|
||||
const answerQuestionCount = Number.isNaN(requestedCount)
|
||||
? 10
|
||||
: Math.min(50, Math.max(1, requestedCount));
|
||||
const questionCount = computed(() => answerQuestionCount);
|
||||
const detailQuestionId = Number(route.query.questionId ?? 1);
|
||||
|
||||
// 页面题目列表,后续可替换成接口返回数据
|
||||
const questionList = computed<AnswerQuestion[]>(() =>
|
||||
Array.from({ length: questionCount.value }, (_, index) => {
|
||||
const detailIsUpload = isDetailMode.value && detailQuestionId % 2 === 0;
|
||||
const isChoice = index % 2 === 0;
|
||||
const answerType: AnswerQuestion["answerType"] =
|
||||
detailIsUpload || !isChoice ? "upload" : "choice";
|
||||
const isCorrect = detailIsUpload;
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
subject: "英语",
|
||||
questionType: answerType === "choice" ? "单选题" : "填空题",
|
||||
difficulty: "中等",
|
||||
score: 10,
|
||||
duration: 30,
|
||||
typeLabel: answerType === "choice" ? "单选题" : "填空题",
|
||||
answerType,
|
||||
stem: answerType === "choice"
|
||||
? "下列全是元音字母的一组是 ______ 。"
|
||||
: "There is no ______ trying to persuade him.",
|
||||
options: answerType === "choice"
|
||||
? [
|
||||
{ value: "A", label: "b;f;k" },
|
||||
{ value: "B", label: "a;e;o" },
|
||||
{ value: "C", label: "h;0;m" },
|
||||
{ value: "D", label: "a;u;f" },
|
||||
]
|
||||
: undefined,
|
||||
userAnswer: answerType === "choice" ? "C" : undefined,
|
||||
correctAnswer: answerType === "choice" ? "B" : "There is no point trying to persuade him.",
|
||||
isCorrect,
|
||||
answerText:
|
||||
answerType === "choice"
|
||||
? "正确答案 B。"
|
||||
: "正确答案:There is no point trying to persuade him.",
|
||||
explanation:
|
||||
answerType === "choice"
|
||||
? "元音字母为 a、e、i、o、u,本题中只有 B 选项全部为元音字母。"
|
||||
: "There is no point doing sth. 表示“做某事没有意义”,后面接动名词 trying。",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const currentQuestionIndex = ref(0);
|
||||
// 以题目 id 为 key 存储答案,便于题号切换后保留作答状态
|
||||
const choiceAnswers = ref<Record<number, string>>({});
|
||||
const uploadAnswers = ref<Record<number, UploadAnswerValue[]>>({});
|
||||
const isAnswerCollapsed = ref(false);
|
||||
|
||||
const currentQuestion = computed(() => questionList.value[currentQuestionIndex.value]);
|
||||
|
||||
function getReviewOptionClass(value: string) {
|
||||
return {
|
||||
correct: value === currentQuestion.value.correctAnswer,
|
||||
wrong:
|
||||
value === currentQuestion.value.userAnswer &&
|
||||
value !== currentQuestion.value.correctAnswer,
|
||||
};
|
||||
}
|
||||
|
||||
function goNextQuestion() {
|
||||
currentQuestionIndex.value =
|
||||
(currentQuestionIndex.value + 1) % questionList.value.length;
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
router.back();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrong-retrain-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #dce6f7;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
:deep(.retrain-header.sub-page-header) {
|
||||
grid-template-columns: 120px 1fr 0;
|
||||
padding: 14px 36px;
|
||||
min-height: 74px;
|
||||
}
|
||||
|
||||
:deep(.retrain-header.titled-header.sub-page-header) {
|
||||
grid-template-columns: max-content minmax(0, 1fr) 0;
|
||||
}
|
||||
|
||||
:deep(.retrain-header.titled-header .question-nav) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.retrain-header.titled-header .sph-center) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.retrain-header .sph-center) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.retrain-header .sph-right) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #151821;
|
||||
cursor: pointer;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-back-icon {
|
||||
width: 24px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 34px 22px;
|
||||
background: rgba(255, 255, 255, 0.30);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
border: 2px solid rgba(255, 255, 255);
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
// min-height: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
// align-items: start;
|
||||
gap: 14px;
|
||||
padding: 26px 32px;
|
||||
position: relative;
|
||||
// background: #eef3fc;
|
||||
}
|
||||
|
||||
.question-base-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.question-progress,
|
||||
.question-score {
|
||||
font-size: 32px;
|
||||
color: #151821;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.small-tag {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
|
||||
&.blue {
|
||||
color: #3193F5;
|
||||
background: rgba(49, 147, 245, 0.10);
|
||||
}
|
||||
|
||||
&.blue-light {
|
||||
color: #4e97d1;
|
||||
background: #edf6ff;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
color: #EB5E00;
|
||||
background: rgba(255, 117, 26, 0.10);
|
||||
}
|
||||
}
|
||||
|
||||
.timer-chip {
|
||||
position: absolute;
|
||||
height: 72px;
|
||||
min-width: 300px;
|
||||
padding: 18px 0;
|
||||
background: url("@/assets/AnswerQuestions/time.png") center / 100% 100% no-repeat;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #2e3442;
|
||||
font-size: 24px;
|
||||
// font-weight: 600;
|
||||
// line-height: 1;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
|
||||
.time{
|
||||
font-weight: 600;
|
||||
margin-left: 12px;
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
grid-column: 3;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
border-radius: 90px;
|
||||
border: none;
|
||||
background: #2f80ed;
|
||||
color: #fff;
|
||||
padding: 12px 32px;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.light-action-btn {
|
||||
min-width: 150px;
|
||||
border: none;
|
||||
border-radius: 90px;
|
||||
background: rgba(51, 126, 255, 0.10);
|
||||
color: #2d6fe0;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
padding: 12px 26px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) 0 minmax(0, 1fr);
|
||||
background: #fff;
|
||||
border-radius: 32px;
|
||||
padding: 32px 0px;
|
||||
position: relative;
|
||||
transition: grid-template-columns 0.24s ease;
|
||||
|
||||
&.answer-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr) 0 0;
|
||||
|
||||
:deep(.left-panel) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
grid-column: 3;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
// padding: 12px 14px 16px;
|
||||
}
|
||||
|
||||
.collapse-answer-btn {
|
||||
grid-column: 2;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: 34px;
|
||||
height: 64px;
|
||||
border: 1px solid rgba(170, 202, 238, 0.72);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #ddecff 100%);
|
||||
color: #2d6fe0;
|
||||
cursor: pointer;
|
||||
|
||||
box-shadow:
|
||||
0 8px 20px rgba(45, 111, 224, 0.16),
|
||||
0 1px 4px rgba(45, 111, 224, 0.12);
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
transform 0.24s ease;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(45, 111, 224, 0.22);
|
||||
transform: translateX(-50%);
|
||||
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 12px;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
justify-self: end;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(45, 111, 224, 0.42);
|
||||
background: linear-gradient(180deg, #fff 0%, #d7e9ff 100%);
|
||||
box-shadow:
|
||||
0 10px 24px rgba(45, 111, 224, 0.22),
|
||||
0 2px 6px rgba(45, 111, 224, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.page-content.answer-collapsed {
|
||||
.collapse-arrow {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-answer-card {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0 32px;
|
||||
background: #fff;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.answer-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 42px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.answer-title {
|
||||
color: #151821;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-result {
|
||||
min-width: 180px;
|
||||
border-radius: 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 8px 14px;
|
||||
text-align: right;
|
||||
|
||||
&.correct {
|
||||
background: linear-gradient(90deg, rgba(44, 180, 132, 0), rgba(44, 180, 132, 0.14));
|
||||
color: #16936b;
|
||||
}
|
||||
|
||||
&.wrong {
|
||||
background: linear-gradient(90deg, rgba(226, 68, 68, 0), rgba(226, 68, 68, 0.12));
|
||||
color: #c72535;
|
||||
}
|
||||
}
|
||||
|
||||
.review-option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.review-option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
min-height: 92px;
|
||||
border: 2px solid #dbe7f5;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #171b24;
|
||||
|
||||
&.correct {
|
||||
border-color: #31aa85;
|
||||
background: rgba(49, 170, 133, 0.07);
|
||||
color: #18936d;
|
||||
|
||||
.review-option-key {
|
||||
background: rgba(49, 170, 133, 0.08);
|
||||
border-right-color: rgba(49, 170, 133, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
&.wrong {
|
||||
border-color: #d92535;
|
||||
background: rgba(217, 37, 53, 0.06);
|
||||
color: #c72535;
|
||||
|
||||
.review-option-key {
|
||||
background: rgba(217, 37, 53, 0.06);
|
||||
border-right-color: rgba(217, 37, 53, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.review-option-key {
|
||||
width: 88px;
|
||||
min-height: 92px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1px solid #dce8f6;
|
||||
background: #edf4fd;
|
||||
font-size: 40px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.review-option-text {
|
||||
padding: 0 24px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.image-answer-preview {
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #dbe7f5;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.paper-answer {
|
||||
position: relative;
|
||||
height: 220px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
#f7f2e8 0,
|
||||
#f7f2e8 42px,
|
||||
#9bb8d2 44px,
|
||||
#f7f2e8 46px
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.paper-text {
|
||||
color: #111;
|
||||
font-family: "Comic Sans MS", "Segoe Print", cursive;
|
||||
font-size: 46px;
|
||||
font-style: italic;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
transform: rotate(-1deg);
|
||||
|
||||
span {
|
||||
color: #2d6fe0;
|
||||
}
|
||||
}
|
||||
|
||||
.answer-section {
|
||||
margin-top: 34px;
|
||||
color: #151821;
|
||||
|
||||
h4 {
|
||||
margin-bottom: 18px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #2e3138;
|
||||
font-size: 22px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
91
src/views/AnswerQuestions/components/ChoiceAnswerPanel.vue
Normal file
91
src/views/AnswerQuestions/components/ChoiceAnswerPanel.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="answer-card">
|
||||
<h3 class="answer-title">我的答案</h3>
|
||||
<div class="option-list">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="option-item"
|
||||
:class="{ selected: modelValue === option.value }"
|
||||
type="button"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
<span class="option-key">{{ option.value }}</span>
|
||||
<span class="option-text">{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue?: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.answer-card {
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.answer-title {
|
||||
font-size: 32px;
|
||||
color: #2f333a;
|
||||
margin-bottom: 24px;
|
||||
// padding-left: 8px;
|
||||
}
|
||||
|
||||
.option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
text-align: left;
|
||||
border: 2px solid #dbe7f5;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
min-height: 92px;
|
||||
|
||||
&.selected {
|
||||
border-color: #2b80ea;
|
||||
color: #2D6FE0;
|
||||
}
|
||||
}
|
||||
|
||||
.option-key {
|
||||
width: 88px;
|
||||
min-height: 92px;
|
||||
background: #edf4fd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
font-size: 44px;
|
||||
// color: #1f2a3a;
|
||||
border-right: 1px solid #dce8f6;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 32px;
|
||||
// color: #2f333a;
|
||||
padding: 0 22px;
|
||||
}
|
||||
</style>
|
||||
75
src/views/AnswerQuestions/components/QuestionNumberNav.vue
Normal file
75
src/views/AnswerQuestions/components/QuestionNumberNav.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="question-nav-wrap">
|
||||
<div ref="questionNavRef" class="question-nav">
|
||||
<button
|
||||
v-for="index in total"
|
||||
:key="index"
|
||||
class="question-nav-item"
|
||||
:class="{ active: currentIndex === index - 1 }"
|
||||
type="button"
|
||||
@click="emit('update:currentIndex', index - 1)"
|
||||
>
|
||||
{{ index }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
total: number;
|
||||
currentIndex: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:currentIndex", value: number): void;
|
||||
}>();
|
||||
|
||||
const questionNavRef = ref<HTMLDivElement>();
|
||||
|
||||
void props;
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.question-nav-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.question-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
max-width: 780px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.question-nav-item {
|
||||
width: 56px;
|
||||
min-width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: #9aa8bf;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #fff9ea;
|
||||
color: #2D6FE0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
src/views/AnswerQuestions/components/QuestionStemPanel.vue
Normal file
40
src/views/AnswerQuestions/components/QuestionStemPanel.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="left-panel">
|
||||
<div class="question-scroll">
|
||||
<p class="question-text">{{ question.stem }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AnswerQuestion } from "@/views/AnswerQuestions/components/types";
|
||||
|
||||
defineProps<{
|
||||
question: AnswerQuestion;
|
||||
currentIndex: number;
|
||||
total: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.left-panel {
|
||||
min-height: 0;
|
||||
padding: 0 32px;
|
||||
border-right: 1px solid #e9edf5;
|
||||
}
|
||||
|
||||
.question-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 14px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d0dbec transparent;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 32px;
|
||||
line-height: 1.6;
|
||||
color: #2e3138;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
292
src/views/AnswerQuestions/components/UploadAnswerPanel.vue
Normal file
292
src/views/AnswerQuestions/components/UploadAnswerPanel.vue
Normal file
@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="answer-card">
|
||||
<h3 class="answer-title">我的答案</h3>
|
||||
<div class="upload-box">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<div v-if="images.length === 0" class="upload-placeholder">
|
||||
<button class="upload-trigger" type="button" @click="openFilePicker">
|
||||
<div class="upload-btn">相机/上传</div>
|
||||
<p>请上传答案,最多3张图片</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="main-preview">
|
||||
<img :src="activeImage.url" :alt="activeImage.name" />
|
||||
<button class="delete-btn" type="button" @click="removeImage(activeIndex)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="thumb-list">
|
||||
<button
|
||||
v-for="(image, index) in images"
|
||||
:key="image.url"
|
||||
class="thumb-item"
|
||||
:class="{ active: activeIndex === index }"
|
||||
type="button"
|
||||
@click="activeIndex = index"
|
||||
>
|
||||
<img :src="image.url" :alt="image.name" />
|
||||
<span class="thumb-delete" @click.stop="removeImage(index)">×</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="images.length < maxImageCount"
|
||||
class="add-thumb"
|
||||
type="button"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="upload-tip">
|
||||
最多上传3张图片(已上传{{ images.length }}/3张)
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type { UploadAnswerValue } from "@/views/AnswerQuestions/components/types";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: UploadAnswerValue[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: UploadAnswerValue[]): void;
|
||||
}>();
|
||||
|
||||
const maxImageCount = 3;
|
||||
const activeIndex = ref(0);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
|
||||
const images = computed(() => props.modelValue ?? []);
|
||||
const activeImage = computed(() => images.value[activeIndex.value] ?? images.value[0]);
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
watch(
|
||||
images,
|
||||
(list: UploadAnswerValue[]) => {
|
||||
// 删除图片后,保证当前预览下标始终落在已有图片范围内
|
||||
if (activeIndex.value > list.length - 1) {
|
||||
activeIndex.value = Math.max(0, list.length - 1);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const restCount = maxImageCount - images.value.length;
|
||||
const nextImages = files.slice(0, restCount).map((file) => ({
|
||||
name: file.name,
|
||||
// 这里先用本地预览 URL 模拟上传成功态,后续可替换接口返回地址
|
||||
url: URL.createObjectURL(file),
|
||||
}));
|
||||
|
||||
emit("update:modelValue", [...images.value, ...nextImages]);
|
||||
activeIndex.value = images.value.length;
|
||||
target.value = "";
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
const nextImages = images.value.filter(
|
||||
(_image: UploadAnswerValue, currentIndex: number) => currentIndex !== index,
|
||||
);
|
||||
emit("update:modelValue", nextImages);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.answer-card {
|
||||
flex: 1;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.answer-title {
|
||||
font-size: 40px;
|
||||
color: #2f333a;
|
||||
margin-bottom: 20px;
|
||||
// padding-left: 8px;
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
margin-top: 2px;
|
||||
flex: 1;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #2d6fe0;
|
||||
font-size: 24px;
|
||||
gap: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 520px;
|
||||
border: 2px solid #2b9be5;
|
||||
border-radius: 14px;
|
||||
|
||||
p {
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
height: 64px;
|
||||
min-width: 190px;
|
||||
border-radius: 32px;
|
||||
padding: 0 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e9f3ff;
|
||||
color: #2f80ed;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
border: 2px solid #b9d6f4;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn,
|
||||
.thumb-delete {
|
||||
position: absolute;
|
||||
border: 2px solid #6d7485;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #4d5568;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.thumb-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.thumb-item,
|
||||
.add-thumb {
|
||||
position: relative;
|
||||
width: 144px;
|
||||
height: 96px;
|
||||
border-radius: 10px;
|
||||
background: #f6f8fb;
|
||||
border: 1px solid #e1e8f2;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border: 2px solid #2b80ea;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb-delete {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.add-thumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #aac8ec;
|
||||
color: #2d6fe0;
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
height: 46px;
|
||||
border-radius: 8px;
|
||||
background: #fff9e8;
|
||||
color: #d99427;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
22
src/views/AnswerQuestions/components/types.ts
Normal file
22
src/views/AnswerQuestions/components/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type AnswerQuestion = {
|
||||
id: number;
|
||||
subject: string;
|
||||
questionType: string;
|
||||
difficulty: string;
|
||||
score: number;
|
||||
duration: number;
|
||||
typeLabel: string;
|
||||
answerType: "choice" | "upload";
|
||||
stem: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
userAnswer?: string;
|
||||
correctAnswer?: string;
|
||||
isCorrect?: boolean;
|
||||
answerText?: string;
|
||||
explanation?: string;
|
||||
};
|
||||
|
||||
export type UploadAnswerValue = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
@ -51,14 +51,11 @@ const tools = [
|
||||
"单词闯关",
|
||||
"单词测评",
|
||||
"词典",
|
||||
"单词书",
|
||||
"单词闯关",
|
||||
"单词测评",
|
||||
"词典",
|
||||
"单词书",
|
||||
"单词闯关",
|
||||
"单词测评",
|
||||
"词典",
|
||||
"生词本",
|
||||
"乐读",
|
||||
"乐训语感",
|
||||
"句子箱",
|
||||
"零基础",
|
||||
];
|
||||
|
||||
const pageSize = 9;
|
||||
@ -108,9 +105,7 @@ function onMouseUp(e: MouseEvent) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 16px;
|
||||
background: #d8e1f0;
|
||||
// padding: 16px 16px 14px;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@ -156,12 +151,12 @@ function onMouseUp(e: MouseEvent) {
|
||||
align-items: flex-start;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 28px;
|
||||
padding: 52px 64px;
|
||||
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
width: 554px;
|
||||
height: 238px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15);
|
||||
@ -180,23 +175,23 @@ function onMouseUp(e: MouseEvent) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
bottom: -22px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 6px;
|
||||
height: 6px;
|
||||
border-radius: 4px;
|
||||
background: #D6DEEB;
|
||||
background: #d6deeb;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
width: 32px;
|
||||
height: 6px;
|
||||
background: #ADC9FF;
|
||||
height: 6px;
|
||||
background: #adc9ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
v-for="book in activeBooks"
|
||||
:key="book.id"
|
||||
:book="book"
|
||||
@click="goToBook(book)"
|
||||
@click="goToActiveBook(book)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -125,6 +125,15 @@ const englishBooks: Book[] = [
|
||||
{ id: 13, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
];
|
||||
|
||||
const englishVideoBooks: Book[] = [
|
||||
{ id: 101, year: "2012", level: null, name: "北师大版", subtitle: "(三年级上册)" },
|
||||
{ id: 102, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 103, year: "2012", level: "入门", name: "北师大版", subtitle: "(三年级上册)" },
|
||||
{ id: 104, year: null, level: null, name: "人教版 PEP", subtitle: "(三年级上册)" },
|
||||
{ id: 105, year: null, level: null, name: "译林版", subtitle: "(三年级上册)" },
|
||||
{ id: 106, year: "2024", level: null, name: "外研版", subtitle: "(三年级上册)" },
|
||||
];
|
||||
|
||||
const subjectAllBooksMap: Record<string, Book[]> = {
|
||||
英语: englishBooks,
|
||||
语文: [],
|
||||
@ -132,8 +141,16 @@ const subjectAllBooksMap: Record<string, Book[]> = {
|
||||
科学: [],
|
||||
};
|
||||
|
||||
const subjectVideoBooksMap: Record<string, Book[]> = {
|
||||
英语: englishVideoBooks,
|
||||
语文: [],
|
||||
数学: [],
|
||||
科学: [],
|
||||
};
|
||||
|
||||
/** 当前科目下全部可选书本 */
|
||||
const allBooks = computed<Book[]>(() => subjectAllBooksMap[subjectName.value] ?? []);
|
||||
const videoBooks = computed<Book[]>(() => subjectVideoBooksMap[subjectName.value] ?? []);
|
||||
|
||||
/** 已添加的书本 id(默认全部添加,模拟初始状态) */
|
||||
const addedBookIds = ref<number[]>(englishBooks.map(b => b.id));
|
||||
@ -147,7 +164,7 @@ function handleConfirm(selectedIds: number[]) {
|
||||
}
|
||||
|
||||
const activeBooks = computed<Book[]>(() => {
|
||||
if (activeTab.value === "video") return [];
|
||||
if (activeTab.value === "video") return videoBooks.value;
|
||||
return allBooks.value.filter((b: Book) => addedBookIds.value.includes(b.id));
|
||||
});
|
||||
|
||||
@ -167,6 +184,28 @@ function goToBook(book: Book) {
|
||||
});
|
||||
}
|
||||
|
||||
function goToVideoBook(book: Book) {
|
||||
router.push({
|
||||
name: 'video-learning',
|
||||
params: {
|
||||
subject: subjectName.value,
|
||||
bookId: book.id,
|
||||
},
|
||||
query: {
|
||||
name: book.name,
|
||||
subtitle: book.subtitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function goToActiveBook(book: Book) {
|
||||
if (activeTab.value === "video") {
|
||||
goToVideoBook(book);
|
||||
return;
|
||||
}
|
||||
goToBook(book);
|
||||
}
|
||||
|
||||
function goToWrongQuestions() {
|
||||
router.push({
|
||||
name: 'wrong-questions',
|
||||
|
||||
601
src/views/Subjects/VideoLearning.vue
Normal file
601
src/views/Subjects/VideoLearning.vue
Normal file
@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<div class="video-learning">
|
||||
<SubPageHeader>
|
||||
<template #center>
|
||||
<span class="header-title">{{ bookTitle }}</span>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<main class="video-body">
|
||||
<section class="learning-panel">
|
||||
<aside class="catalog-panel">
|
||||
<div class="catalog-header">
|
||||
<span class="catalog-title">目录</span>
|
||||
<button class="catalog-action" aria-label="目录设置">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="catalog-list">
|
||||
<div v-for="unit in units" :key="unit.id" class="unit-block">
|
||||
<button class="unit-row" @click="toggleUnit(unit)">
|
||||
<span>{{ unit.name }}</span>
|
||||
<svg
|
||||
class="unit-arrow"
|
||||
:class="{ expanded: unit.expanded }"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M7 10l5 5 5-5" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="unit.expanded" class="lesson-list">
|
||||
<button
|
||||
v-for="lesson in unit.lessons"
|
||||
:key="lesson.id"
|
||||
class="lesson-row"
|
||||
:class="{ active: activeLessonId === lesson.id }"
|
||||
@click="selectLesson(lesson)"
|
||||
>
|
||||
{{ lesson.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="content-panel">
|
||||
<div class="content-header">
|
||||
<h1>章节视频</h1>
|
||||
<div class="segment-tabs">
|
||||
<button
|
||||
v-for="tab in videoTabs"
|
||||
:key="tab.key"
|
||||
class="segment-tab"
|
||||
:class="{ active: activeVideoTab === tab.key }"
|
||||
@click="activeVideoTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="video-title">{{ selectedVideo.title }}</p>
|
||||
|
||||
<div class="hero-video">
|
||||
<div class="board-pattern"></div>
|
||||
<div class="teacher-card">
|
||||
<div class="teacher-avatar"></div>
|
||||
</div>
|
||||
<button class="play-btn" aria-label="播放视频">
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="episode-section">
|
||||
<h2>选集</h2>
|
||||
<div class="episode-list">
|
||||
<button
|
||||
v-for="episode in selectedVideo.episodes"
|
||||
:key="episode.id"
|
||||
class="episode-card"
|
||||
:class="{ active: activeEpisodeId === episode.id }"
|
||||
@click="activeEpisodeId = episode.id"
|
||||
>
|
||||
<div class="episode-thumb" :class="`episode-thumb--${episode.tone}`">
|
||||
<span v-if="episode.playing" class="mini-pause"></span>
|
||||
</div>
|
||||
<span class="episode-name">{{ episode.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import SubPageHeader from '@/components/layout/SubPageHeader.vue'
|
||||
|
||||
interface Lesson {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Unit {
|
||||
id: number
|
||||
name: string
|
||||
expanded: boolean
|
||||
lessons: Lesson[]
|
||||
}
|
||||
|
||||
interface Episode {
|
||||
id: number
|
||||
title: string
|
||||
tone: 'dark' | 'green' | 'blue'
|
||||
playing?: boolean
|
||||
}
|
||||
|
||||
interface VideoLesson {
|
||||
id: number
|
||||
title: string
|
||||
episodes: Episode[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const bookName = computed(() => String(route.query.name || ''))
|
||||
const bookSubtitle = computed(() => String(route.query.subtitle || ''))
|
||||
const bookTitle = computed(() =>
|
||||
bookName.value && bookSubtitle.value
|
||||
? `${bookName.value}${bookSubtitle.value}`
|
||||
: bookName.value || '视频学习',
|
||||
)
|
||||
|
||||
const units = ref<Unit[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Unit 1 New school, new beginning!',
|
||||
expanded: true,
|
||||
lessons: [
|
||||
{ id: 101, name: 'Lesson 1 Hello!' },
|
||||
{ id: 102, name: 'Lesson 2 Let me show you around!' },
|
||||
{ id: 103, name: 'Lesson 3 Look at the photo!' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Unit 2 Learning English is fun!',
|
||||
expanded: false,
|
||||
lessons: [
|
||||
{ id: 201, name: 'Lesson 4 I love English!' },
|
||||
{ id: 202, name: 'Lesson 5 My English book' },
|
||||
{ id: 203, name: 'Lesson 6 Lets sing a song!' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Unit 3 All about me',
|
||||
expanded: false,
|
||||
lessons: [
|
||||
{ id: 301, name: 'Lesson 7 My family' },
|
||||
{ id: 302, name: 'Lesson 8 I have a pet' },
|
||||
{ id: 303, name: 'Lesson 9 Review' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const videoTabs = [
|
||||
{ key: 'chapter' as const, label: '章节' },
|
||||
{ key: 'knowledge' as const, label: '知识点' },
|
||||
]
|
||||
|
||||
const activeLessonId = ref(101)
|
||||
const activeVideoTab = ref<'chapter' | 'knowledge'>('chapter')
|
||||
const activeEpisodeId = ref(1)
|
||||
|
||||
const videoLessons: VideoLesson[] = [
|
||||
{
|
||||
id: 101,
|
||||
title: '视频标题xxxx',
|
||||
episodes: [
|
||||
{ id: 1, title: '视频标题xxx...', tone: 'dark', playing: true },
|
||||
{ id: 2, title: '视频标题xxx...', tone: 'green' },
|
||||
{ id: 3, title: '视频标题xxx...', tone: 'green' },
|
||||
{ id: 4, title: '视频标题xxx...', tone: 'blue' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: '校园介绍重点句型',
|
||||
episodes: [
|
||||
{ id: 5, title: 'Let me show...', tone: 'green', playing: true },
|
||||
{ id: 6, title: 'Around school...', tone: 'blue' },
|
||||
{ id: 7, title: 'Practice time...', tone: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
title: '看图表达与单词复习',
|
||||
episodes: [
|
||||
{ id: 8, title: 'Look at photo...', tone: 'blue', playing: true },
|
||||
{ id: 9, title: 'Words review...', tone: 'dark' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const selectedVideo = computed(() =>
|
||||
videoLessons.find(video => video.id === activeLessonId.value) ?? videoLessons[0],
|
||||
)
|
||||
|
||||
function toggleUnit(unit: Unit) {
|
||||
unit.expanded = !unit.expanded
|
||||
}
|
||||
|
||||
function selectLesson(lesson: Lesson) {
|
||||
activeLessonId.value = lesson.id
|
||||
activeEpisodeId.value = selectedVideo.value.episodes[0]?.id ?? 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-learning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #292929;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 30px;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
:deep(.back-icon path) {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.video-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 64px 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.learning-panel {
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 690px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 22px;
|
||||
background: #2e2e2f;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.catalog-panel {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #454545;
|
||||
}
|
||||
|
||||
.catalog-header {
|
||||
height: 86px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.catalog-title {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.catalog-action {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
place-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.catalog-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.unit-row,
|
||||
.lesson-row {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unit-row {
|
||||
min-height: 78px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background: transparent;
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unit-arrow {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.lesson-list {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.lesson-row {
|
||||
position: relative;
|
||||
height: 78px;
|
||||
padding: 0 34px 0 42px;
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: #2f80ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 34px 34px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.segment-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 99px;
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.segment-tab {
|
||||
min-width: 78px;
|
||||
height: 42px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 99px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #252525;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.video-title {
|
||||
margin-top: 36px;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.hero-video {
|
||||
position: relative;
|
||||
margin-top: 28px;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 572px;
|
||||
min-height: 360px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 70% 30%, rgba(255, 255, 255, 0.18), transparent 22%),
|
||||
linear-gradient(155deg, #0d5a50 0%, #176f61 48%, #0c4a43 100%);
|
||||
}
|
||||
|
||||
.board-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.58;
|
||||
background-image:
|
||||
linear-gradient(25deg, transparent 0 45%, rgba(255, 255, 255, 0.45) 46%, transparent 48%),
|
||||
linear-gradient(115deg, transparent 0 56%, rgba(255, 255, 255, 0.28) 57%, transparent 59%);
|
||||
background-size: 210px 140px, 260px 160px;
|
||||
}
|
||||
|
||||
.teacher-card {
|
||||
position: absolute;
|
||||
right: 12%;
|
||||
bottom: 0;
|
||||
width: 230px;
|
||||
height: 78%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.teacher-avatar {
|
||||
width: 165px;
|
||||
height: 270px;
|
||||
border-radius: 90px 90px 18px 18px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 48px, #ffe2c5 0 42px, transparent 43px),
|
||||
linear-gradient(#f4f1e9 0 92px, #6c6f75 93px 100%);
|
||||
box-shadow: 0 0 0 12px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: 6px;
|
||||
border-top: 17px solid transparent;
|
||||
border-bottom: 17px solid transparent;
|
||||
border-left: 25px solid #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-section {
|
||||
margin-top: 36px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 22px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.episode-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
&.active {
|
||||
outline-color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
.episode-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 7;
|
||||
background:
|
||||
linear-gradient(155deg, rgba(5, 48, 43, 0.85), rgba(19, 108, 94, 0.88)),
|
||||
repeating-linear-gradient(25deg, transparent 0 34px, rgba(255, 255, 255, 0.22) 35px 37px);
|
||||
|
||||
&--green {
|
||||
background:
|
||||
linear-gradient(155deg, rgba(18, 93, 80, 0.72), rgba(30, 120, 100, 0.88)),
|
||||
repeating-linear-gradient(25deg, transparent 0 34px, rgba(255, 255, 255, 0.22) 35px 37px);
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background:
|
||||
linear-gradient(155deg, rgba(17, 75, 105, 0.72), rgba(45, 115, 145, 0.86)),
|
||||
repeating-linear-gradient(25deg, transparent 0 34px, rgba(255, 255, 255, 0.2) 35px 37px);
|
||||
}
|
||||
}
|
||||
|
||||
.mini-pause {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
width: 6px;
|
||||
height: 22px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-name {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 22px 12px 8px;
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
||||
}
|
||||
</style>
|
||||
@ -19,39 +19,24 @@
|
||||
<div class="filter-groups">
|
||||
<!-- 学科筛选 -->
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="item in subjectFilters"
|
||||
:key="item.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeSubject === item.key }"
|
||||
@click="activeSubject = item.key"
|
||||
>
|
||||
<button v-for="item in subjectFilters" :key="item.key" class="filter-btn"
|
||||
:class="{ active: activeSubject === item.key }" @click="activeSubject = item.key">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 重练状态筛选 -->
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="item in practiceFilters"
|
||||
:key="item.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: activePractice === item.key }"
|
||||
@click="activePractice = item.key"
|
||||
>
|
||||
<button v-for="item in practiceFilters" :key="item.key" class="filter-btn"
|
||||
:class="{ active: activePractice === item.key }" @click="activePractice = item.key">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 时间筛选 -->
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="item in timeFilters"
|
||||
:key="item.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeTime === item.key }"
|
||||
@click="activeTime = item.key"
|
||||
>
|
||||
<button v-for="item in timeFilters" :key="item.key" class="filter-btn"
|
||||
:class="{ active: activeTime === item.key }" @click="activeTime = item.key">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
@ -61,12 +46,8 @@
|
||||
<!-- 题目列表网格 -->
|
||||
<div class="card-body">
|
||||
<div class="question-grid">
|
||||
<div
|
||||
v-for="q in filteredQuestions"
|
||||
:key="q.id"
|
||||
class="question-card"
|
||||
:class="{ 'card-poor': q.accuracy === 0 }"
|
||||
>
|
||||
<div v-for="q in filteredQuestions" :key="q.id" class="question-card"
|
||||
:class="{ 'card-poor': q.accuracy === 0 }">
|
||||
<!-- 题目文本 -->
|
||||
<p class="question-text">{{ q.content }}</p>
|
||||
|
||||
@ -80,10 +61,7 @@
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-label">正确率:</span>
|
||||
<span
|
||||
class="stat-accuracy"
|
||||
:class="q.accuracy > 0 ? 'high' : 'zero'"
|
||||
>
|
||||
<span class="stat-accuracy" :class="q.accuracy > 0 ? 'high' : 'zero'">
|
||||
{{ q.accuracy }}%
|
||||
</span>
|
||||
</span>
|
||||
@ -91,11 +69,7 @@
|
||||
|
||||
<!-- 标签行 -->
|
||||
<div class="tag-row">
|
||||
<span
|
||||
class="tag subject-tag"
|
||||
:class="'subject-' + q.subject"
|
||||
>{{ q.subject }}</span
|
||||
>
|
||||
<span class="tag subject-tag" :class="'subject-' + q.subject">{{ q.subject }}</span>
|
||||
<span class="tag type-tag">{{ q.type }}</span>
|
||||
<span class="tag level-tag">{{ q.level }}</span>
|
||||
<span class="tag-time">{{ q.time }}</span>
|
||||
@ -106,10 +80,7 @@
|
||||
<button class="action-btn detail-btn" @click="viewDetail(q)">
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
class="action-btn extend-btn"
|
||||
@click="extendPractice(q)"
|
||||
>
|
||||
<button class="action-btn extend-btn" @click="extendPractice(q)">
|
||||
举一反三
|
||||
</button>
|
||||
</div>
|
||||
@ -119,12 +90,60 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 错题重练 -->
|
||||
<el-dialog v-model="showRetrainDialog" class="retrain-dialog" width="880px" :show-close="false"
|
||||
:close-on-click-modal="true" modal-class="retrain-dialog-overlay" align-center>
|
||||
<div class="retrain-panel">
|
||||
<h3 class="dialog-title">错题重练</h3>
|
||||
|
||||
<div class="dialog-section">
|
||||
<div class="section-title">选择条件</div>
|
||||
<div class="condition-row">
|
||||
<LmSelect
|
||||
v-for="item in retrainConditions"
|
||||
:key="item.key"
|
||||
v-model="retrainForm[item.key]"
|
||||
:options="item.options"
|
||||
width="180px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section question-count-section">
|
||||
<div class="section-title">
|
||||
题目抽取数量 <span class="section-tip">(1-50题)</span>
|
||||
</div>
|
||||
<div class="counter-row">
|
||||
<button class="counter-btn" type="button" :disabled="retrainCount <= minRetrainCount"
|
||||
@click="decreaseRetrainCount">
|
||||
-
|
||||
</button>
|
||||
<div class="counter-value">{{ retrainCount }}</div>
|
||||
<button class="counter-btn" type="button" :disabled="retrainCount >= maxRetrainCount"
|
||||
@click="increaseRetrainCount">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-btn cancel-btn" type="button" @click="showRetrainDialog = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="dialog-btn confirm-btn" type="button" @click="confirmRetrain">
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import SubPageHeader from "@/components/layout/SubPageHeader.vue";
|
||||
import LmSelect from "@/components/lm/LmSelect.vue";
|
||||
import cuotichonglianIcon from "@/assets/subjects/cuotichonglian.svg";
|
||||
|
||||
/* ---- 筛选选项 ---- */
|
||||
@ -150,6 +169,59 @@ const timeFilters = [
|
||||
const activeSubject = ref("all");
|
||||
const activePractice = ref("all");
|
||||
const activeTime = ref("all");
|
||||
const showRetrainDialog = ref(false);
|
||||
const minRetrainCount = 1;
|
||||
const maxRetrainCount = 50;
|
||||
const retrainCount = ref(10);
|
||||
const router = useRouter();
|
||||
|
||||
type RetrainConditionKey = "subject" | "book" | "chapter" | "time";
|
||||
|
||||
const retrainForm = ref<Record<RetrainConditionKey, string>>({
|
||||
subject: "all-subject",
|
||||
book: "all-book",
|
||||
chapter: "all-chapter",
|
||||
time: "all-time",
|
||||
});
|
||||
|
||||
const retrainConditions: Array<{
|
||||
key: RetrainConditionKey;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
}> = [
|
||||
{
|
||||
key: "subject",
|
||||
options: [
|
||||
{ label: "全部学科", value: "all-subject" },
|
||||
{ label: "英语", value: "english" },
|
||||
{ label: "数学", value: "math" },
|
||||
{ label: "语文", value: "chinese" },
|
||||
{ label: "科学", value: "science" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "book",
|
||||
options: [
|
||||
{ label: "全部教材", value: "all-book" },
|
||||
{ label: "当前教材", value: "current-book" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "chapter",
|
||||
options: [
|
||||
{ label: "全部章节", value: "all-chapter" },
|
||||
{ label: "当前章节", value: "current-chapter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
options: [
|
||||
{ label: "全部时间", value: "all-time" },
|
||||
{ label: "今天", value: "today" },
|
||||
{ label: "近一周", value: "week" },
|
||||
{ label: "近一月", value: "month" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* ---- 模拟数据 ---- */
|
||||
interface Question {
|
||||
@ -277,14 +349,46 @@ const filteredQuestions = computed(() => {
|
||||
});
|
||||
|
||||
function handleRetrain() {
|
||||
// TODO: 跳转错题重练
|
||||
showRetrainDialog.value = true;
|
||||
}
|
||||
|
||||
function decreaseRetrainCount() {
|
||||
retrainCount.value = Math.max(minRetrainCount, retrainCount.value - 1);
|
||||
}
|
||||
|
||||
function increaseRetrainCount() {
|
||||
retrainCount.value = Math.min(maxRetrainCount, retrainCount.value + 1);
|
||||
}
|
||||
|
||||
function confirmRetrain() {
|
||||
showRetrainDialog.value = false;
|
||||
// 关闭弹框后进入错题重练答题页,并把当前筛选条件传过去
|
||||
router.push({
|
||||
name: "wrong-retrain-answer",
|
||||
query: {
|
||||
mode: "answer",
|
||||
count: String(retrainCount.value),
|
||||
subject: retrainForm.value.subject,
|
||||
book: retrainForm.value.book,
|
||||
chapter: retrainForm.value.chapter,
|
||||
time: retrainForm.value.time,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function viewDetail(q: Question) {
|
||||
// TODO: 查看详情
|
||||
router.push({
|
||||
name: "wrong-retrain-answer",
|
||||
query: {
|
||||
mode: "detail",
|
||||
questionId: String(q.id),
|
||||
count: "10",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function extendPractice(q: Question) {
|
||||
void q;
|
||||
// TODO: 举一反三
|
||||
}
|
||||
</script>
|
||||
@ -426,11 +530,11 @@ function extendPractice(q: Question) {
|
||||
.question-card {
|
||||
background: #f2fbff;
|
||||
border-radius: 20px;
|
||||
// padding: 28px 28px 20px;
|
||||
// padding: 28px 28px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1.5px solid #e1f2fa;
|
||||
// gap: 16px;
|
||||
// gap: 16px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&.card-poor {
|
||||
@ -440,17 +544,18 @@ function extendPractice(q: Question) {
|
||||
|
||||
|
||||
.question-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #FFF;
|
||||
border-radius: 20px;
|
||||
padding: 22px 24px;
|
||||
gap: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #FFF;
|
||||
border-radius: 20px;
|
||||
padding: 22px 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 28px;
|
||||
color: #121212;
|
||||
// line-height: 1.5;
|
||||
// line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@ -524,14 +629,17 @@ function extendPractice(q: Question) {
|
||||
color: #2d6fe0;
|
||||
background: rgba(45, 111, 224, 0.1);
|
||||
}
|
||||
|
||||
&.subject-语文 {
|
||||
color: #27ae60;
|
||||
background: rgba(39, 174, 96, 0.1);
|
||||
}
|
||||
|
||||
&.subject-数学 {
|
||||
color: #2d6fe0;
|
||||
background: rgba(45, 111, 224, 0.1);
|
||||
}
|
||||
|
||||
&.subject-科学 {
|
||||
color: #9b59b6;
|
||||
background: rgba(155, 89, 182, 0.1);
|
||||
@ -589,4 +697,137 @@ function extendPractice(q: Question) {
|
||||
border: 1.5px solid #2d6fe0;
|
||||
color: #2d6fe0;
|
||||
}
|
||||
|
||||
:global(.retrain-dialog) {
|
||||
--el-dialog-padding-primary: 0;
|
||||
border-radius: 26px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.retrain-dialog .el-dialog__header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.retrain-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.retrain-dialog-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.retrain-panel {
|
||||
width: 100%;
|
||||
// min-height: 640px;
|
||||
padding: 48px 56px;
|
||||
border-radius: 26px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin-bottom: 52px;
|
||||
color: #121212;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 24px;
|
||||
color: #121212;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-tip {
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.condition-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.question-count-section {
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.counter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.counter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
|
||||
&:disabled {
|
||||
color: #d9dee7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.counter-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 168px;
|
||||
height: 58px;
|
||||
border-radius: 4px;
|
||||
background: #f5f6f8;
|
||||
color: #333;
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
width: 190px;
|
||||
height: 72px;
|
||||
border-radius: 40px;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.86;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
border: 2px solid #b7d4f2;
|
||||
background: #fff;
|
||||
color: #6ca8e8;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
border: none;
|
||||
background: #2f80ed;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user