feat: 答题页面

This commit is contained in:
阿梦 2026-04-30 18:41:30 +08:00
parent 9d61f4ee8a
commit a14d38b6f5
14 changed files with 2267 additions and 72 deletions

8
.components.d.ts vendored
View File

@ -9,10 +9,17 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default'] ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default']
AppHeader: typeof import('./src/components/layout/AppHeader.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'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] 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'] LoadingState: typeof import('./src/components/loading/LoadingState.vue')['default']
PageFrame: typeof import('./src/components/layout/PageFrame.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'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SubjectCard: typeof import('./src/components/cards/SubjectCard.vue')['default'] 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'] TabBar: typeof import('./src/components/layout/TabBar.vue')['default']
TaskCard: typeof import('./src/components/cards/TaskCard.vue')['default'] TaskCard: typeof import('./src/components/cards/TaskCard.vue')['default']
TaskSidebar: typeof import('./src/components/tasks/TaskSidebar.vue')['default'] TaskSidebar: typeof import('./src/components/tasks/TaskSidebar.vue')['default']
UploadAnswerPanel: typeof import('./src/components/answerQuestions/UploadAnswerPanel.vue')['default']
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

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

View File

@ -21,12 +21,24 @@ const router = createRouter({
component: () => import('@/views/Subjects/TextbookDetail.vue'), component: () => import('@/views/Subjects/TextbookDetail.vue'),
meta: { hideLayout: true }, 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', path: '/subjects/:subject/wrong-questions',
name: 'wrong-questions', name: 'wrong-questions',
component: () => import('@/views/Subjects/WrongQuestions.vue'), component: () => import('@/views/Subjects/WrongQuestions.vue'),
meta: { hideLayout: true }, meta: { hideLayout: true },
}, },
{
path: '/answer-questions/wrong-retrain',
name: 'wrong-retrain-answer',
component: () => import('@/views/AnswerQuestions/WrongRetrain.vue'),
meta: { hideLayout: true },
},
{ {
path: '/tasks', path: '/tasks',
name: 'tasks', name: 'tasks',

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

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

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

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

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

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

View File

@ -51,14 +51,11 @@ const tools = [
"单词闯关", "单词闯关",
"单词测评", "单词测评",
"词典", "词典",
"单词书", "生词本",
"单词闯关", "乐读",
"单词测评", "乐训语感",
"词典", "句子箱",
"单词书", "零基础",
"单词闯关",
"单词测评",
"词典",
]; ];
const pageSize = 9; const pageSize = 9;
@ -108,9 +105,7 @@ function onMouseUp(e: MouseEvent) {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
border-radius: 16px; border-radius: 16px;
background: #d8e1f0; gap: 16px;
// padding: 16px 16px 14px;
gap: 14px;
position: relative; position: relative;
} }
@ -156,12 +151,12 @@ function onMouseUp(e: MouseEvent) {
align-items: flex-start; align-items: flex-start;
border-radius: 12px; border-radius: 12px;
background: #fff; background: #fff;
padding: 28px; padding: 52px 64px;
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08); box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
cursor: pointer; cursor: pointer;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
width: 554px; min-width: 0;
height: 238px; min-height: 0;
&:hover { &:hover {
box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15); box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15);
@ -180,23 +175,23 @@ function onMouseUp(e: MouseEvent) {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
position: absolute; position: absolute;
bottom: -14px; bottom: -22px;
left: 0; left: 0;
right: 0; right: 0;
} }
.dot { .dot {
width: 12px; width: 12px;
height: 6px; height: 6px;
border-radius: 4px; border-radius: 4px;
background: #D6DEEB; background: #d6deeb;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
&.active { &.active {
width: 32px; width: 32px;
height: 6px; height: 6px;
background: #ADC9FF; background: #adc9ff;
} }
} }
</style> </style>

View File

@ -62,7 +62,7 @@
v-for="book in activeBooks" v-for="book in activeBooks"
:key="book.id" :key="book.id"
:book="book" :book="book"
@click="goToBook(book)" @click="goToActiveBook(book)"
/> />
</div> </div>
</div> </div>
@ -125,6 +125,15 @@ const englishBooks: Book[] = [
{ id: 13, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" }, { 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[]> = { const subjectAllBooksMap: Record<string, Book[]> = {
英语: englishBooks, 英语: englishBooks,
语文: [], 语文: [],
@ -132,8 +141,16 @@ const subjectAllBooksMap: Record<string, Book[]> = {
科学: [], 科学: [],
}; };
const subjectVideoBooksMap: Record<string, Book[]> = {
英语: englishVideoBooks,
语文: [],
数学: [],
科学: [],
};
/** 当前科目下全部可选书本 */ /** 当前科目下全部可选书本 */
const allBooks = computed<Book[]>(() => subjectAllBooksMap[subjectName.value] ?? []); const allBooks = computed<Book[]>(() => subjectAllBooksMap[subjectName.value] ?? []);
const videoBooks = computed<Book[]>(() => subjectVideoBooksMap[subjectName.value] ?? []);
/** 已添加的书本 id默认全部添加模拟初始状态 */ /** 已添加的书本 id默认全部添加模拟初始状态 */
const addedBookIds = ref<number[]>(englishBooks.map(b => b.id)); const addedBookIds = ref<number[]>(englishBooks.map(b => b.id));
@ -147,7 +164,7 @@ function handleConfirm(selectedIds: number[]) {
} }
const activeBooks = computed<Book[]>(() => { 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)); 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() { function goToWrongQuestions() {
router.push({ router.push({
name: 'wrong-questions', name: 'wrong-questions',

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

View File

@ -19,39 +19,24 @@
<div class="filter-groups"> <div class="filter-groups">
<!-- 学科筛选 --> <!-- 学科筛选 -->
<div class="filter-group"> <div class="filter-group">
<button <button v-for="item in subjectFilters" :key="item.key" class="filter-btn"
v-for="item in subjectFilters" :class="{ active: activeSubject === item.key }" @click="activeSubject = item.key">
:key="item.key"
class="filter-btn"
:class="{ active: activeSubject === item.key }"
@click="activeSubject = item.key"
>
{{ item.label }} {{ item.label }}
</button> </button>
</div> </div>
<!-- 重练状态筛选 --> <!-- 重练状态筛选 -->
<div class="filter-group"> <div class="filter-group">
<button <button v-for="item in practiceFilters" :key="item.key" class="filter-btn"
v-for="item in practiceFilters" :class="{ active: activePractice === item.key }" @click="activePractice = item.key">
:key="item.key"
class="filter-btn"
:class="{ active: activePractice === item.key }"
@click="activePractice = item.key"
>
{{ item.label }} {{ item.label }}
</button> </button>
</div> </div>
<!-- 时间筛选 --> <!-- 时间筛选 -->
<div class="filter-group"> <div class="filter-group">
<button <button v-for="item in timeFilters" :key="item.key" class="filter-btn"
v-for="item in timeFilters" :class="{ active: activeTime === item.key }" @click="activeTime = item.key">
:key="item.key"
class="filter-btn"
:class="{ active: activeTime === item.key }"
@click="activeTime = item.key"
>
{{ item.label }} {{ item.label }}
</button> </button>
</div> </div>
@ -61,12 +46,8 @@
<!-- 题目列表网格 --> <!-- 题目列表网格 -->
<div class="card-body"> <div class="card-body">
<div class="question-grid"> <div class="question-grid">
<div <div v-for="q in filteredQuestions" :key="q.id" class="question-card"
v-for="q in filteredQuestions" :class="{ 'card-poor': q.accuracy === 0 }">
:key="q.id"
class="question-card"
:class="{ 'card-poor': q.accuracy === 0 }"
>
<!-- 题目文本 --> <!-- 题目文本 -->
<p class="question-text">{{ q.content }}</p> <p class="question-text">{{ q.content }}</p>
@ -80,10 +61,7 @@
</span> </span>
<span class="stat-item"> <span class="stat-item">
<span class="stat-label">正确率</span> <span class="stat-label">正确率</span>
<span <span class="stat-accuracy" :class="q.accuracy > 0 ? 'high' : 'zero'">
class="stat-accuracy"
:class="q.accuracy > 0 ? 'high' : 'zero'"
>
{{ q.accuracy }}% {{ q.accuracy }}%
</span> </span>
</span> </span>
@ -91,11 +69,7 @@
<!-- 标签行 --> <!-- 标签行 -->
<div class="tag-row"> <div class="tag-row">
<span <span class="tag subject-tag" :class="'subject-' + q.subject">{{ q.subject }}</span>
class="tag subject-tag"
:class="'subject-' + q.subject"
>{{ q.subject }}</span
>
<span class="tag type-tag">{{ q.type }}</span> <span class="tag type-tag">{{ q.type }}</span>
<span class="tag level-tag">{{ q.level }}</span> <span class="tag level-tag">{{ q.level }}</span>
<span class="tag-time">{{ q.time }}</span> <span class="tag-time">{{ q.time }}</span>
@ -106,10 +80,7 @@
<button class="action-btn detail-btn" @click="viewDetail(q)"> <button class="action-btn detail-btn" @click="viewDetail(q)">
查看详情 查看详情
</button> </button>
<button <button class="action-btn extend-btn" @click="extendPractice(q)">
class="action-btn extend-btn"
@click="extendPractice(q)"
>
举一反三 举一反三
</button> </button>
</div> </div>
@ -119,12 +90,60 @@
</div> </div>
</div> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import SubPageHeader from "@/components/layout/SubPageHeader.vue"; import SubPageHeader from "@/components/layout/SubPageHeader.vue";
import LmSelect from "@/components/lm/LmSelect.vue";
import cuotichonglianIcon from "@/assets/subjects/cuotichonglian.svg"; import cuotichonglianIcon from "@/assets/subjects/cuotichonglian.svg";
/* ---- 筛选选项 ---- */ /* ---- 筛选选项 ---- */
@ -150,6 +169,59 @@ const timeFilters = [
const activeSubject = ref("all"); const activeSubject = ref("all");
const activePractice = ref("all"); const activePractice = ref("all");
const activeTime = 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 { interface Question {
@ -277,14 +349,46 @@ const filteredQuestions = computed(() => {
}); });
function handleRetrain() { 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) { function viewDetail(q: Question) {
// TODO: router.push({
name: "wrong-retrain-answer",
query: {
mode: "detail",
questionId: String(q.id),
count: "10",
},
});
} }
function extendPractice(q: Question) { function extendPractice(q: Question) {
void q;
// TODO: // TODO:
} }
</script> </script>
@ -426,11 +530,11 @@ function extendPractice(q: Question) {
.question-card { .question-card {
background: #f2fbff; background: #f2fbff;
border-radius: 20px; border-radius: 20px;
// padding: 28px 28px 20px; // padding: 28px 28px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1.5px solid #e1f2fa; border: 1.5px solid #e1f2fa;
// gap: 16px; // gap: 16px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
&.card-poor { &.card-poor {
@ -440,17 +544,18 @@ function extendPractice(q: Question) {
.question-actions { .question-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #FFF; background: #FFF;
border-radius: 20px; border-radius: 20px;
padding: 22px 24px; padding: 22px 24px;
gap: 16px; gap: 16px;
} }
.question-text { .question-text {
font-size: 28px; font-size: 28px;
color: #121212; color: #121212;
// line-height: 1.5; // line-height: 1.5;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -524,14 +629,17 @@ function extendPractice(q: Question) {
color: #2d6fe0; color: #2d6fe0;
background: rgba(45, 111, 224, 0.1); background: rgba(45, 111, 224, 0.1);
} }
&.subject-语文 { &.subject-语文 {
color: #27ae60; color: #27ae60;
background: rgba(39, 174, 96, 0.1); background: rgba(39, 174, 96, 0.1);
} }
&.subject-数学 { &.subject-数学 {
color: #2d6fe0; color: #2d6fe0;
background: rgba(45, 111, 224, 0.1); background: rgba(45, 111, 224, 0.1);
} }
&.subject-科学 { &.subject-科学 {
color: #9b59b6; color: #9b59b6;
background: rgba(155, 89, 182, 0.1); background: rgba(155, 89, 182, 0.1);
@ -589,4 +697,137 @@ function extendPractice(q: Question) {
border: 1.5px solid #2d6fe0; border: 1.5px solid #2d6fe0;
color: #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> </style>