feat: 学科里面的页面开发
This commit is contained in:
parent
8527a42331
commit
9d61f4ee8a
3
.components.d.ts
vendored
3
.components.d.ts
vendored
@ -10,10 +10,13 @@ declare module 'vue' {
|
||||
ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default']
|
||||
AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
LoadingState: typeof import('./src/components/loading/LoadingState.vue')['default']
|
||||
PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SubjectCard: typeof import('./src/components/cards/SubjectCard.vue')['default']
|
||||
SubPageHeader: typeof import('./src/components/layout/SubPageHeader.vue')['default']
|
||||
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']
|
||||
|
||||
15
src/App.vue
15
src/App.vue
@ -2,20 +2,25 @@
|
||||
<el-config-provider :locale="zhCn">
|
||||
<div class="app-shell">
|
||||
<main class="app-stage">
|
||||
<AppHeader />
|
||||
<section class="app-content">
|
||||
<AppHeader v-if="!hideLayout" />
|
||||
<section class="app-content" :class="{ 'no-tabbar': hideLayout }">
|
||||
<RouterView />
|
||||
</section>
|
||||
<TabBar />
|
||||
<TabBar v-if="!hideLayout" />
|
||||
</main>
|
||||
</div>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import TabBar from '@/components/layout/TabBar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const hideLayout = computed(() => !!route.meta.hideLayout)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -47,5 +52,9 @@ import TabBar from '@/components/layout/TabBar.vue'
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: 140px;
|
||||
|
||||
&.no-tabbar {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
13
src/assets/loading/empty.svg
Normal file
13
src/assets/loading/empty.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg width="240" height="152" viewBox="0 0 240 152" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_155_182918)">
|
||||
<path d="M120.013 151.891C186.295 151.891 240.027 140.138 240.027 125.639C240.027 111.14 186.295 99.3867 120.013 99.3867C78.4206 99.3867 41.7698 104.015 20.2373 111.046C7.45459 115.229 0 120.238 0 125.639C0 140.138 53.732 151.891 120.013 151.891Z" fill="#F5F5F5"/>
|
||||
<path d="M208.15 86.2694H31.8789V49.0233L70.2995 5.46491C72.5091 1.97895 75.7135 0 79.111 0H160.918C164.318 0 167.52 1.98968 169.73 5.46491L208.15 49.0233V86.2694ZM35.6303 82.5153H204.399V50.4418L166.713 7.71469L166.63 7.58062C165.118 5.14849 163.037 3.75143 160.918 3.75143H79.111C76.9926 3.75143 74.9117 5.14849 73.3993 7.58062L73.3162 7.71469L35.6303 50.4418V82.5153Z" fill="#D9D9D9"/>
|
||||
<path d="M75.6095 50.6328C80.0555 50.6328 83.6701 55.2182 83.9437 60.9244L83.9597 61.694C83.9597 67.4834 87.4457 72.2002 91.8219 72.5541L92.3582 72.5756H147.675C152.118 72.5756 155.776 68.0411 156.049 62.3403L156.065 61.6484C156.065 55.8376 159.511 51.0163 163.887 50.6543L164.424 50.6328H206.271V118.655C206.271 126.345 201.643 132.703 195.813 133.119L195.207 133.14H44.8151C38.9158 133.14 34.0891 127.04 33.77 119.443L33.7539 118.655V50.6328H75.6095Z" fill="#FAFAFA"/>
|
||||
<path d="M195.242 135.017H44.8094C37.9259 135.017 32.2519 128.211 31.8899 119.521V119.483L31.8711 118.654V48.7556H75.6091C81.0097 48.7556 85.4932 54.0623 85.8149 60.8224V60.8706L85.831 61.6751C85.831 66.424 88.5125 70.3632 91.9368 70.6635L92.3873 70.6823H147.675C151.029 70.6823 153.944 66.9013 154.175 62.2516L154.191 61.6081C154.191 54.848 158.387 49.2088 163.731 48.7663H163.809L164.415 48.7422H208.148V118.654C208.148 127.326 202.785 134.502 195.95 134.99H195.883L195.242 135.017ZM35.644 119.386C35.9121 125.937 40.0336 131.266 44.8147 131.266H195.175L195.711 131.247C200.5 130.882 204.394 125.243 204.394 118.644V52.507H164.439L163.992 52.5258C160.642 52.8288 157.926 56.9127 157.926 61.6429V61.6885L157.91 62.4232C157.585 69.1645 153.083 74.4471 147.661 74.4471H92.3122L91.6714 74.4176C86.3083 73.9832 82.093 68.403 82.0823 61.71L82.0689 60.9859C81.8356 56.304 78.9422 52.5017 75.6091 52.5017H35.6279V118.636L35.644 119.386Z" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_155_182918">
|
||||
<rect width="240" height="151.891" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
9
src/assets/subjects/cuotiben.svg
Normal file
9
src/assets/subjects/cuotiben.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.66699 6.5H33.333C33.4251 6.5 33.5 6.57494 33.5 6.66699V36.667C33.4998 36.7589 33.425 36.833 33.333 36.833H6.66699C6.57505 36.833 6.50018 36.7589 6.5 36.667V6.66699C6.5 6.57495 6.57494 6.5 6.66699 6.5Z" stroke="#333333" stroke-width="3"/>
|
||||
<rect x="13.334" y="16.666" width="13.3333" height="3.33333" fill="#333333"/>
|
||||
<rect x="13.334" y="23.332" width="8.33333" height="3.33333" fill="#333333"/>
|
||||
<mask id="path-4-inside-1_155_180357" fill="white">
|
||||
<rect x="11.666" y="1.66602" width="16.6667" height="10" rx="1.66667"/>
|
||||
</mask>
|
||||
<rect x="11.666" y="1.66602" width="16.6667" height="10" rx="1.66667" fill="#E9F1FF" stroke="#333333" stroke-width="6" mask="url(#path-4-inside-1_155_180357)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 802 B |
8
src/assets/subjects/cuotichonglian.svg
Normal file
8
src/assets/subjects/cuotichonglian.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 3.75H36C36.1381 3.75 36.25 3.86193 36.25 4V44C36.25 44.1381 36.1381 44.25 36 44.25H4C3.86193 44.25 3.75 44.1381 3.75 44V4C3.75 3.86193 3.86193 3.75 4 3.75Z" stroke="black" stroke-width="3.5"/>
|
||||
<path d="M37 27.75H44C44.1381 27.75 44.25 27.8619 44.25 28V40C44.25 42.3472 42.3472 44.25 40 44.25H37C36.8619 44.25 36.75 44.1381 36.75 44V28C36.75 27.8619 36.8619 27.75 37 27.75Z" stroke="black" stroke-width="3.5"/>
|
||||
<mask id="path-3-inside-1_162_91500" fill="white">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29 21.2002L29 17.6002L20 17.6002V14L11 21.2L18.2 21.2V21.2002L29 21.2002ZM11 28.3998L11 24.7998L21.8 24.7998V24.8L29 24.8L20 32V28.3998L11 28.3998Z"/>
|
||||
</mask>
|
||||
<path d="M29 17.6002H32.6V14.0002L29 14.0002V17.6002ZM29 21.2002V24.8002H32.6L32.6 21.2002H29ZM20 17.6002L16.4 17.6002L16.4 21.2002L20 21.2002L20 17.6002ZM20 14L23.6 14L23.6 6.50975L17.7511 11.1889L20 14ZM11 21.2L8.7511 18.3889L0.737188 24.8L11 24.8V21.2ZM18.2 21.2H21.8L21.8 17.6H18.2L18.2 21.2ZM18.2 21.2002L14.6 21.2002L14.6 24.8002H18.2L18.2 21.2002ZM11 24.7998V21.1998H7.4V24.7998H11ZM11 28.3998H7.4V31.9998L11 31.9998V28.3998ZM21.8 24.7998L25.4 24.7998V21.1998L21.8 21.1998V24.7998ZM21.8 24.8L18.2 24.8V28.4L21.8 28.4L21.8 24.8ZM29 24.8L31.2489 27.6111L39.2628 21.2L29 21.2V24.8ZM20 32L16.4 32L16.4 39.4902L22.2489 34.8111L20 32ZM20 28.3998L23.6 28.3998L23.6 24.7998L20 24.7998V28.3998ZM29 17.6002H25.4L25.4 21.2002H29H32.6V17.6002H29ZM20 17.6002L20 21.2002L29 21.2002L29 17.6002V14.0002L20 14.0002V17.6002ZM20 14L16.4 14V17.6002L20 17.6002H23.6V14L20 14ZM11 21.2L13.2489 24.0111L22.2489 16.8111L20 14L17.7511 11.1889L8.7511 18.3889L11 21.2ZM18.2 21.2L18.2 17.6L11 17.6L11 21.2V24.8H18.2L18.2 21.2ZM18.2 21.2002H21.8V21.2H18.2L14.6 21.2V21.2002L18.2 21.2002ZM29 21.2002L29 17.6002L18.2 17.6002L18.2 21.2002L18.2 24.8002L29 24.8002V21.2002ZM11 24.7998H7.4L7.4 28.3998H11H14.6V24.7998H11ZM21.8 24.7998V21.1998L11 21.1998V24.7998L11 28.3998L21.8 28.3998L21.8 24.7998ZM21.8 24.8L25.4 24.8V24.7998L21.8 24.7998L18.2 24.7998V24.8L21.8 24.8ZM21.8 24.8L21.8 28.4L29 28.4L29 24.8V21.2L21.8 21.2V24.8ZM29 24.8L26.7511 21.9889L17.7511 29.1889L20 32L22.2489 34.8111L31.2489 27.6111L29 24.8ZM20 32H23.6V28.3998L20 28.3998L16.4 28.3998V32L20 32ZM11 28.3998V31.9998L20 31.9998V28.3998V24.7998L11 24.7998L11 28.3998Z" fill="#2D6FE0" mask="url(#path-3-inside-1_162_91500)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/subjects/xueqinfenxi.png
Normal file
BIN
src/assets/subjects/xueqinfenxi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 B |
BIN
src/assets/subjects/xuexishenqi.png
Normal file
BIN
src/assets/subjects/xuexishenqi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 908 B |
4
src/assets/subjects/zhankai.svg
Normal file
4
src/assets/subjects/zhankai.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.9075 15.3933C10.2967 15.7047 10.2967 16.2966 9.9075 16.608L5.26363 20.3231C4.75438 20.7305 4 20.3679 4 19.7157L4 12.2856C4 11.6334 4.75438 11.2708 5.26363 11.6782L9.9075 15.3933Z" fill="#666666"/>
|
||||
<path d="M5.33301 22.667C4.59678 22.6672 4 23.2647 4 24.001C4.00018 24.7371 4.59689 25.3338 5.33301 25.334H26.667C27.4031 25.3338 27.9998 24.7371 28 24.001C28 23.2647 27.4032 22.6672 26.667 22.667H5.33301ZM16 14.001C15.2636 14.001 14.667 14.5976 14.667 15.334C14.667 16.0704 15.2636 16.667 16 16.667H26.667C27.4032 16.6668 28 16.0703 28 15.334C28 14.5977 27.4032 14.0012 26.667 14.001H16ZM5.33301 5.33398C4.59689 5.33416 4.00018 5.93087 4 6.66699C4 7.40326 4.59678 8.0008 5.33301 8.00098H26.667C27.4032 8.0008 28 7.40326 28 6.66699C27.9998 5.93087 27.4031 5.33416 26.667 5.33398H5.33301Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 918 B |
@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div class="subject-card">
|
||||
<div class="subject-card" @click="handleClick">
|
||||
<h3 class="subject-title">{{ title }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string }>()
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{ title: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const handleClick = () => {
|
||||
router.push({ name: 'subject-detail', params: { subject: props.title } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -16,6 +23,17 @@ defineProps<{ title: string }>()
|
||||
box-sizing: border-box;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s, box-shadow 0.18s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(61, 120, 242, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.subject-title {
|
||||
|
||||
91
src/components/layout/SubPageHeader.vue
Normal file
91
src/components/layout/SubPageHeader.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<header class="sub-page-header">
|
||||
<!-- 左区域:默认返回按钮,可自定义 -->
|
||||
<div class="sph-left">
|
||||
<slot name="left">
|
||||
<button class="back-btn" @click="handleBack">
|
||||
<svg class="back-icon" viewBox="0 0 32 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M24 4L6 26L24 48"
|
||||
stroke="#333"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- 中间区域:自定义 -->
|
||||
<div class="sph-center">
|
||||
<slot name="center" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧区域:自定义 -->
|
||||
<div class="sph-right">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sub-page-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 20px 64px;
|
||||
min-height: 88px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sph-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sph-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sph-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 28px;
|
||||
height: 44px;
|
||||
}
|
||||
</style>
|
||||
145
src/components/loading/LoadingState.vue
Normal file
145
src/components/loading/LoadingState.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="loading-state">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="status === 'loading'" class="loading-state__content">
|
||||
<div class="loading-state__spinner">
|
||||
<div class="spinner-ring"></div>
|
||||
</div>
|
||||
<p class="loading-state__text">{{ loadingText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载出错 -->
|
||||
<div v-else-if="status === 'error'" class="loading-state__content">
|
||||
<img :src="emptySvg" class="loading-state__img" alt="error" />
|
||||
<p class="loading-state__text loading-state__text--error">{{ errorText }}</p>
|
||||
<slot name="error-action">
|
||||
<button v-if="showRetry" class="loading-state__retry-btn" @click="$emit('retry')">
|
||||
重新加载
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- 暂无数据 -->
|
||||
<div v-else-if="status === 'empty'" class="loading-state__content">
|
||||
<img :src="emptySvg" class="loading-state__img" alt="empty" />
|
||||
<p class="loading-state__text">{{ emptyText }}</p>
|
||||
<slot name="empty-action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import emptySvg from '../../assets/loading/empty.svg'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 当前状态:loading | error | empty */
|
||||
status: 'loading' | 'error' | 'empty'
|
||||
/** 加载中文字 */
|
||||
loadingText?: string
|
||||
/** 出错文字 */
|
||||
errorText?: string
|
||||
/** 空数据文字 */
|
||||
emptyText?: string
|
||||
/** 出错时是否显示重试按钮 */
|
||||
showRetry?: boolean
|
||||
}>(),
|
||||
{
|
||||
loadingText: '加载中...',
|
||||
errorText: '加载失败,请稍后重试',
|
||||
emptyText: '暂无数据',
|
||||
showRetry: true,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'retry'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variate.scss' as *;
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 160px;
|
||||
height: auto;
|
||||
opacity: 0.85;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 14px;
|
||||
color: $font-minor-color;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
|
||||
&--error {
|
||||
color: $error-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__retry-btn {
|
||||
margin-top: 4px;
|
||||
padding: 8px 24px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
background: $theme-color;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: $hover-theme-fill;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载中旋转动画
|
||||
&__spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #f0f0f0;
|
||||
border-top-color: $theme-color;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -9,6 +9,24 @@ const router = createRouter({
|
||||
name: 'subjects',
|
||||
component: () => import('@/views/Subjects/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject',
|
||||
name: 'subject-detail',
|
||||
component: () => import('@/views/Subjects/SubjectDetail.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject/book/:bookId',
|
||||
name: 'textbook-detail',
|
||||
component: () => import('@/views/Subjects/TextbookDetail.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject/wrong-questions',
|
||||
name: 'wrong-questions',
|
||||
component: () => import('@/views/Subjects/WrongQuestions.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: 'tasks',
|
||||
|
||||
@ -39,4 +39,5 @@ body {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
@ -1,33 +1,171 @@
|
||||
<template>
|
||||
<PageFrame>
|
||||
<div class="tools-container">
|
||||
<!-- 滑动区域 -->
|
||||
<div
|
||||
class="tools-viewport"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchend.passive="onTouchEnd"
|
||||
@mousedown="onMouseDown"
|
||||
@mouseup="onMouseUp"
|
||||
>
|
||||
<div class="tools-track" :style="trackStyle">
|
||||
<div
|
||||
v-for="pageIndex in totalPages"
|
||||
:key="pageIndex"
|
||||
class="tools-page"
|
||||
>
|
||||
<section class="tools-grid">
|
||||
<article v-for="tool in tools" :key="tool" class="tool-card">
|
||||
<article
|
||||
v-for="tool in pageTools(pageIndex - 1)"
|
||||
:key="tool + pageIndex"
|
||||
class="tool-card"
|
||||
>
|
||||
<h3 class="tool-title">{{ tool }}</h3>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页指示器 -->
|
||||
<div class="pagination-dots">
|
||||
<span
|
||||
v-for="(_, i) in totalPages"
|
||||
:key="i"
|
||||
class="dot"
|
||||
:class="{ active: i === currentPage }"
|
||||
@click="currentPage = i"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const tools = ['单词书', '单词闯关', '单词测评', '词典', '生词本', '乐读', '乐训语感', '句子箱', '零基础']
|
||||
import { ref, computed } from "vue";
|
||||
import PageFrame from "@/components/layout/PageFrame.vue";
|
||||
|
||||
const tools = [
|
||||
"单词书",
|
||||
"单词闯关",
|
||||
"单词测评",
|
||||
"词典",
|
||||
"单词书",
|
||||
"单词闯关",
|
||||
"单词测评",
|
||||
"词典",
|
||||
"单词书",
|
||||
"单词闯关",
|
||||
"单词测评",
|
||||
"词典",
|
||||
];
|
||||
|
||||
const pageSize = 9;
|
||||
const currentPage = ref(0);
|
||||
const totalPages = computed(() => Math.ceil(tools.length / pageSize));
|
||||
|
||||
const pageTools = (pageIndex: number) =>
|
||||
tools.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
|
||||
|
||||
const trackStyle = computed(() => ({
|
||||
transform: `translateX(-${currentPage.value * 100}%)`,
|
||||
}));
|
||||
|
||||
// ---- 触摸滑动 ----
|
||||
let touchStartX = 0;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
}
|
||||
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
const diff = touchStartX - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) < 40) return;
|
||||
if (diff > 0 && currentPage.value < totalPages.value - 1) currentPage.value++;
|
||||
else if (diff < 0 && currentPage.value > 0) currentPage.value--;
|
||||
}
|
||||
|
||||
// ---- 鼠标拖拽(桌面端调试用)----
|
||||
let mouseStartX = 0;
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
mouseStartX = e.clientX;
|
||||
}
|
||||
|
||||
function onMouseUp(e: MouseEvent) {
|
||||
const diff = mouseStartX - e.clientX;
|
||||
if (Math.abs(diff) < 40) return;
|
||||
if (diff > 0 && currentPage.value < totalPages.value - 1) currentPage.value++;
|
||||
else if (diff < 0 && currentPage.value > 0) currentPage.value--;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tools-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 16px;
|
||||
background: #d8e1f0;
|
||||
// padding: 16px 16px 14px;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 可视窗口:溢出裁剪 */
|
||||
.tools-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
/* 水平轨道:所有页并排 */
|
||||
.tools-track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 每一页:宽度固定为 100% */
|
||||
.tools-page {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
border-radius: 12px;
|
||||
background: #d8e1f0;
|
||||
padding: 16px;
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
height: 128px;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 0 28px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
width: 554px;
|
||||
height: 238px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
@ -35,4 +173,30 @@ const tools = ['单词书', '单词闯关', '单词测评', '词典', '生词本
|
||||
font-weight: 700;
|
||||
color: #121212;
|
||||
}
|
||||
|
||||
.pagination-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 6px;
|
||||
border-radius: 4px;
|
||||
background: #D6DEEB;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
width: 32px;
|
||||
height: 6px;
|
||||
background: #ADC9FF;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,33 +1,63 @@
|
||||
<template>
|
||||
<PageFrame section-class="insights-wrapper">
|
||||
<div class="insights-container">
|
||||
<section class="insights-grid">
|
||||
<article v-for="item in items" :key="item" class="insight-card">
|
||||
<h3 class="insight-title">{{ item }}</h3>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PageFrame from '@/components/layout/PageFrame.vue'
|
||||
|
||||
const items = ['学员管理', '排行榜', '学情分析', '错题本']
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.insights-wrapper) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.insights-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 16px;
|
||||
background: #d8e1f0;
|
||||
// padding: 16px;
|
||||
}
|
||||
|
||||
.insights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
border-radius: 12px;
|
||||
background: #d8e1f0;
|
||||
padding: 16px;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
display: flex;
|
||||
height: 176px;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 0 36px;
|
||||
padding: 36px;
|
||||
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
width: 848px;
|
||||
height: 373px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.insight-title {
|
||||
|
||||
356
src/views/Subjects/SubjectDetail.vue
Normal file
356
src/views/Subjects/SubjectDetail.vue
Normal file
@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="subject-detail">
|
||||
<!-- 顶部导航栏 -->
|
||||
<SubPageHeader>
|
||||
<!-- 中间:标签切换 -->
|
||||
<template #center>
|
||||
<div class="header-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 右侧:功能按钮 -->
|
||||
<template #right>
|
||||
<div class="header-actions">
|
||||
<button v-if="hasBooksForSubject" class="action-btn">
|
||||
<img class="action-icon" :src="xuexishenqiIcon" alt="学习神器" />
|
||||
<span>学习神器</span>
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<img class="action-icon" :src="xueqinfenxiIcon" alt="学情分析" />
|
||||
<span>学情分析</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="detail-body">
|
||||
<div class="content-card">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-header">
|
||||
<h2 class="subject-label">{{ subjectName }}教材</h2>
|
||||
<div class="card-btns">
|
||||
<button class="wrong-book-btn" @click="goToWrongQuestions">
|
||||
<img class="btn-icon" :src="cuotibenIcon" alt="错题本" />
|
||||
错题本
|
||||
</button>
|
||||
<button class="add-book-btn" @click="showDrawer = true">+ 添加书本</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片内容区 -->
|
||||
<div class="card-content">
|
||||
<!-- 空状态 -->
|
||||
<LoadingState
|
||||
v-if="activeBooks.length === 0"
|
||||
status="empty"
|
||||
empty-text="请先添加书本后再学习"
|
||||
/>
|
||||
|
||||
<!-- 书本网格 -->
|
||||
<div v-else class="book-grid">
|
||||
<BookCoverCard
|
||||
v-for="book in activeBooks"
|
||||
:key="book.id"
|
||||
:book="book"
|
||||
@click="goToBook(book)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选择教材底部弹框 -->
|
||||
<SelectBookDrawer
|
||||
v-model="showDrawer"
|
||||
:books="allBooks"
|
||||
:initial-selected="addedBookIds"
|
||||
@confirm="handleConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import SubPageHeader from "@/components/layout/SubPageHeader.vue";
|
||||
import BookCoverCard from "./components/BookCoverCard.vue";
|
||||
import SelectBookDrawer from "./components/SelectBookDrawer.vue";
|
||||
import xuexishenqiIcon from "@/assets/subjects/xuexishenqi.png";
|
||||
import xueqinfenxiIcon from "@/assets/subjects/xueqinfenxi.png";
|
||||
import cuotibenIcon from "@/assets/subjects/cuotiben.svg";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const subjectName = computed(() => String(route.params.subject || ""));
|
||||
|
||||
const activeTab = ref<"free" | "video">("free");
|
||||
|
||||
const tabs = [
|
||||
{ key: "free" as const, label: "自由练习" },
|
||||
{ key: "video" as const, label: "视频学习" },
|
||||
];
|
||||
|
||||
interface Book {
|
||||
id: number;
|
||||
year: string | null;
|
||||
level: string | null;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const englishBooks: Book[] = [
|
||||
{ id: 1, year: "2012", level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 2, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 3, year: "2012", level: "入门", name: "北师大版", subtitle: "(三年级上册)" },
|
||||
{ id: 4, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 5, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 6, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 7, year: "2012", level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 8, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 9, year: "2012", level: "入门", name: "北师大版", subtitle: "(三年级上册)" },
|
||||
{ id: 10, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 11, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 12, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
{ id: 13, year: null, level: null, name: "Join in 外研剑桥", subtitle: "(三年级上册)" },
|
||||
];
|
||||
|
||||
const subjectAllBooksMap: Record<string, Book[]> = {
|
||||
英语: englishBooks,
|
||||
语文: [],
|
||||
数学: [],
|
||||
科学: [],
|
||||
};
|
||||
|
||||
/** 当前科目下全部可选书本 */
|
||||
const allBooks = computed<Book[]>(() => subjectAllBooksMap[subjectName.value] ?? []);
|
||||
|
||||
/** 已添加的书本 id(默认全部添加,模拟初始状态) */
|
||||
const addedBookIds = ref<number[]>(englishBooks.map(b => b.id));
|
||||
|
||||
/** 弹框开关 */
|
||||
const showDrawer = ref(false);
|
||||
|
||||
/** 确认选择后更新已添加书本 */
|
||||
function handleConfirm(selectedIds: number[]) {
|
||||
addedBookIds.value = selectedIds;
|
||||
}
|
||||
|
||||
const activeBooks = computed<Book[]>(() => {
|
||||
if (activeTab.value === "video") return [];
|
||||
return allBooks.value.filter((b: Book) => addedBookIds.value.includes(b.id));
|
||||
});
|
||||
|
||||
const hasBooksForSubject = computed(() => allBooks.value.length > 0);
|
||||
|
||||
function goToBook(book: Book) {
|
||||
router.push({
|
||||
name: 'textbook-detail',
|
||||
params: {
|
||||
subject: subjectName.value,
|
||||
bookId: book.id,
|
||||
},
|
||||
query: {
|
||||
name: book.name,
|
||||
subtitle: book.subtitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function goToWrongQuestions() {
|
||||
router.push({
|
||||
name: 'wrong-questions',
|
||||
params: { subject: subjectName.value },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subject-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- 顶部导航 ---- */
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
position: relative;
|
||||
font-size: 36px;
|
||||
// font-weight: 600;
|
||||
color: #666;
|
||||
background: transparent;
|
||||
border: none;
|
||||
// cursor: pointer;
|
||||
padding: 8px 0;
|
||||
transition: color 0.2s;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
|
||||
|
||||
&.active {
|
||||
color: #2d6fe0;
|
||||
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 32px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
background: #3d78f2;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 主体内容 ---- */
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 64px 32px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.30);
|
||||
border: 2px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.subject-label {
|
||||
font-size: 34px;
|
||||
// font-weight: 700;
|
||||
color: #121212;
|
||||
}
|
||||
|
||||
.card-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.wrong-book-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
color: #555;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.add-book-btn {
|
||||
background: #3d78f2;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
// font-weight: 600;
|
||||
padding: 14px 32px;
|
||||
border-radius: 99px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #2d64d8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片内容 */
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 32px 40px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
height: 940px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 书本网格 */
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 32px;
|
||||
}
|
||||
</style>
|
||||
519
src/views/Subjects/TextbookDetail.vue
Normal file
519
src/views/Subjects/TextbookDetail.vue
Normal file
@ -0,0 +1,519 @@
|
||||
<template>
|
||||
<div class="textbook-detail">
|
||||
<!-- 顶部导航栏 -->
|
||||
<SubPageHeader>
|
||||
<template #center>
|
||||
<span class="header-title">{{ bookTitle }}</span>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="detail-body">
|
||||
<div class="content-panel">
|
||||
|
||||
<!-- 左侧:目录 -->
|
||||
<div class="toc-panel" :class="{ 'toc-panel--collapsed': tocCollapsed }">
|
||||
<div class="panel-header">
|
||||
<span v-if="!tocCollapsed" class="panel-title">目录</span>
|
||||
<button class="toc-collapse-btn" :title="tocCollapsed ? '展开目录' : '折叠目录'" @click="toggleToc">
|
||||
<img :src="zhankaiIcon" alt="展开目录" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toc-list">
|
||||
<div v-for="unit in units" :key="unit.id" class="unit-item">
|
||||
<!-- Unit 标题行 -->
|
||||
<button
|
||||
class="unit-row"
|
||||
:class="{ expanded: unit.expanded }"
|
||||
@click="toggleUnit(unit)"
|
||||
>
|
||||
<span class="unit-name">{{ tocCollapsed ? getShortUnitName(unit.name) : unit.name }}</span>
|
||||
<svg
|
||||
class="unit-arrow"
|
||||
:class="{ 'arrow-up': unit.expanded }"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" stroke="#999" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Lessons 列表 -->
|
||||
<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)"
|
||||
>
|
||||
{{ tocCollapsed ? getShortLessonName(lesson.name) : lesson.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:练习记录 -->
|
||||
<div class="record-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">练习记录</span>
|
||||
<div class="sort-btns">
|
||||
<button
|
||||
class="sort-btn"
|
||||
:class="{ active: sortBy === 'time' }"
|
||||
@click="sortBy = 'time'"
|
||||
>按时间</button>
|
||||
<button
|
||||
class="sort-btn"
|
||||
:class="{ active: sortBy === 'accuracy' }"
|
||||
@click="sortBy = 'accuracy'"
|
||||
>按正确率</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="record-content">
|
||||
<!-- 暂无练习记录 -->
|
||||
<LoadingState
|
||||
v-if="records.length === 0"
|
||||
status="empty"
|
||||
empty-text="暂无练习记录"
|
||||
/>
|
||||
|
||||
<!-- 练习记录列表(有数据时显示) -->
|
||||
<div v-else class="record-list" :class="{ 'record-list--wide': tocCollapsed }">
|
||||
<PracticeRecordCard
|
||||
v-for="record in sortedRecords"
|
||||
:key="record.id"
|
||||
:record="record"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="record-footer">
|
||||
<button class="start-btn" @click="handleStartPractice">开始练习</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import SubPageHeader from '@/components/layout/SubPageHeader.vue'
|
||||
import LoadingState from '@/components/loading/LoadingState.vue'
|
||||
import zhankaiIcon from '@/assets/subjects/zhankai.svg'
|
||||
import PracticeRecordCard, { type PracticeRecord } from './components/PracticeRecordCard.vue'
|
||||
|
||||
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 || '教材详情',
|
||||
)
|
||||
|
||||
// ---- 目录数据 ----
|
||||
interface Lesson {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
interface Unit {
|
||||
id: number
|
||||
name: string
|
||||
expanded: boolean
|
||||
lessons: Lesson[]
|
||||
}
|
||||
|
||||
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 activeLessonId = ref<number>(101)
|
||||
|
||||
// ---- 目录折叠 ----
|
||||
const tocCollapsed = ref(false)
|
||||
|
||||
function toggleToc() {
|
||||
tocCollapsed.value = !tocCollapsed.value
|
||||
}
|
||||
|
||||
/** 缩写 Unit 名称:取前两个单词,如 "Unit 1 New school..." → "Unit 1" */
|
||||
function getShortUnitName(name: string): string {
|
||||
const match = name.match(/^(\S+\s+\d+)/)
|
||||
return match ? match[1] : name
|
||||
}
|
||||
|
||||
/** 缩写 Lesson 名称:如 "Lesson 1 Hello!" → "Les.1" */
|
||||
function getShortLessonName(name: string): string {
|
||||
const match = name.match(/^Lesson\s+(\d+)/i)
|
||||
return match ? `Les.${match[1]}` : name
|
||||
}
|
||||
|
||||
function toggleUnit(unit: Unit) {
|
||||
unit.expanded = !unit.expanded
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
units.value.forEach((u: Unit) => (u.expanded = false))
|
||||
}
|
||||
|
||||
function selectLesson(lesson: Lesson) {
|
||||
activeLessonId.value = lesson.id
|
||||
}
|
||||
|
||||
// ---- 练习记录 ----
|
||||
const commonTags = [
|
||||
{ label: '英语', type: 'blue' },
|
||||
{ label: '单选题', type: 'blue' },
|
||||
{ label: '中等', type: 'orange' },
|
||||
]
|
||||
|
||||
const records = ref<PracticeRecord[]>([
|
||||
{ id: 1, title: 'There is no ______ trying to pers...', count: 1, accuracy: 100, tags: commonTags, time: '今天 10:30' },
|
||||
{ id: 2, title: 'There is no ______ trying to pers...', count: 5, accuracy: 60, tags: commonTags, time: '昨天 10:30' },
|
||||
{ id: 3, title: 'There is no ______ trying to pers...', count: 1, accuracy: 0, tags: commonTags, time: '04/24 10:30' },
|
||||
{ id: 4, title: 'There is no ______ trying to pers...', count: 2, accuracy: 50, tags: commonTags, time: '04/24 10:30' },
|
||||
{ id: 5, title: 'There is no ______ trying to pers...', count: 1, accuracy: 100, tags: commonTags, time: '今天 10:30' },
|
||||
{ id: 6, title: 'There is no ______ trying to pers...', count: 5, accuracy: 60, tags: commonTags, time: '昨天 10:30' },
|
||||
{ id: 7, title: 'There is no ______ trying to pers...', count: 3, accuracy: 33, tags: commonTags, time: '04/23 09:00' },
|
||||
{ id: 8, title: 'There is no ______ trying to pers...', count: 4, accuracy: 75, tags: commonTags, time: '04/22 15:00' },
|
||||
])
|
||||
|
||||
const sortBy = ref<'time' | 'accuracy'>('time')
|
||||
|
||||
const sortedRecords = computed(() => {
|
||||
const list = [...records.value]
|
||||
if (sortBy.value === 'accuracy') {
|
||||
list.sort((a, b) => b.accuracy - a.accuracy)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function handleStartPractice() {
|
||||
// TODO: 跳转到练习页
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variate.scss' as *;
|
||||
|
||||
.textbook-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// 顶部标题
|
||||
.header-title {
|
||||
font-size: 32px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
// 主体
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 64px 32px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
border: 2px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
|
||||
}
|
||||
|
||||
// ---- 公共面板头部 ----
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28px 32px 20px;
|
||||
flex-shrink: 0;
|
||||
// border-bottom: 1.5px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
// ---- 左侧目录 ----
|
||||
.toc-panel {
|
||||
width: 700px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1.5px solid #fff;
|
||||
overflow: hidden;
|
||||
background: #F5F7FA;
|
||||
border-radius: 20px;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&--collapsed {
|
||||
width: 174px;
|
||||
|
||||
.panel-header {
|
||||
justify-content: center;
|
||||
padding: 28px 16px 20px;
|
||||
}
|
||||
|
||||
.unit-name {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.unit-row {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.lesson-row {
|
||||
padding: 12px 20px 12px 28px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toc-collapse-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.unit-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.unit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.unit-name {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.unit-arrow {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.arrow-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.lesson-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lesson-row {
|
||||
padding: 14px 32px 14px 40px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 22px;
|
||||
color: #888;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(61, 120, 242, 0.05);
|
||||
color: #555;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #3d78f2;
|
||||
border-left-color: #3d78f2;
|
||||
background: rgba(61, 120, 242, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 右侧练习记录 ----
|
||||
.record-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sort-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #EBEBEB;
|
||||
border-radius: 99px;
|
||||
padding: 4px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
padding: 8px 28px;
|
||||
border-radius: 99px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.record-list {
|
||||
padding: 24px 28px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
align-content: start;
|
||||
|
||||
&--wide {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
padding: 24px 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
// border-top: 1.5px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
padding: 18px 80px;
|
||||
border-radius: 99px;
|
||||
border: none;
|
||||
background: #3d78f2;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #2d64d8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
592
src/views/Subjects/WrongQuestions.vue
Normal file
592
src/views/Subjects/WrongQuestions.vue
Normal file
@ -0,0 +1,592 @@
|
||||
<template>
|
||||
<div class="wrong-questions">
|
||||
<!-- 顶部导航栏 -->
|
||||
<SubPageHeader>
|
||||
<template #right>
|
||||
<button class="retrain-btn" @click="handleRetrain">
|
||||
<img class="retrain-icon" :src="cuotichonglianIcon" alt="错题重练" />
|
||||
<span>错题重练</span>
|
||||
</button>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="body-wrap">
|
||||
<div class="content-card">
|
||||
<!-- 标题 + 筛选栏 -->
|
||||
<div class="card-header">
|
||||
<h2 class="page-title">错题记录</h2>
|
||||
<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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目列表网格 -->
|
||||
<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 }"
|
||||
>
|
||||
<!-- 题目文本 -->
|
||||
<p class="question-text">{{ q.content }}</p>
|
||||
|
||||
<div class="question-actions">
|
||||
<!-- 统计行 -->
|
||||
<div class="stat-row">
|
||||
<span class="stat-item">
|
||||
<span class="stat-label">做题数:</span>
|
||||
<span class="stat-count">{{ q.count }}</span>
|
||||
<span class="stat-unit">次</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-label">正确率:</span>
|
||||
<span
|
||||
class="stat-accuracy"
|
||||
:class="q.accuracy > 0 ? 'high' : 'zero'"
|
||||
>
|
||||
{{ q.accuracy }}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签行 -->
|
||||
<div class="tag-row">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-row">
|
||||
<button class="action-btn detail-btn" @click="viewDetail(q)">
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
class="action-btn extend-btn"
|
||||
@click="extendPractice(q)"
|
||||
>
|
||||
举一反三
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import SubPageHeader from "@/components/layout/SubPageHeader.vue";
|
||||
import cuotichonglianIcon from "@/assets/subjects/cuotichonglian.svg";
|
||||
|
||||
/* ---- 筛选选项 ---- */
|
||||
const subjectFilters = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "英语", label: "英语" },
|
||||
{ key: "数学", label: "数学" },
|
||||
{ key: "语文", label: "语文" },
|
||||
{ key: "科学", label: "科学" },
|
||||
];
|
||||
const practiceFilters = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "done", label: "已重练" },
|
||||
{ key: "todo", label: "未重练" },
|
||||
];
|
||||
const timeFilters = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "today", label: "今天" },
|
||||
{ key: "week", label: "近一周" },
|
||||
{ key: "month", label: "近一月" },
|
||||
];
|
||||
|
||||
const activeSubject = ref("all");
|
||||
const activePractice = ref("all");
|
||||
const activeTime = ref("all");
|
||||
|
||||
/* ---- 模拟数据 ---- */
|
||||
interface Question {
|
||||
id: number;
|
||||
content: string;
|
||||
count: number;
|
||||
accuracy: number;
|
||||
subject: string;
|
||||
type: string;
|
||||
level: string;
|
||||
time: string;
|
||||
practiced: boolean;
|
||||
}
|
||||
|
||||
const questions: Question[] = [
|
||||
{
|
||||
id: 1,
|
||||
content: "There is no ______ trying to persuad...",
|
||||
count: 3,
|
||||
accuracy: 60,
|
||||
subject: "英语",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "今天 10:30",
|
||||
practiced: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: "2024年国庆,全国从1日到7日放假七...",
|
||||
count: 3,
|
||||
accuracy: 60,
|
||||
subject: "语文",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "今天 10:30",
|
||||
practiced: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: "已知A= 2y − xy,B = −xy + 3y。求 2...",
|
||||
count: 3,
|
||||
accuracy: 60,
|
||||
subject: "数学",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "今天 10:30",
|
||||
practiced: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
content: "There is no ______ trying to persuad...",
|
||||
count: 1,
|
||||
accuracy: 0,
|
||||
subject: "英语",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "04/24 10:30",
|
||||
practiced: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
content: "2024年国庆,全国从1日到7日放假七...",
|
||||
count: 1,
|
||||
accuracy: 0,
|
||||
subject: "语文",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "04/24 10:30",
|
||||
practiced: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
content: "已知A= 2y − xy,B = −xy + 3y。求 2...",
|
||||
count: 1,
|
||||
accuracy: 0,
|
||||
subject: "数学",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "2025/04/24",
|
||||
practiced: false,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
content: "There is no ______ trying to persuad...",
|
||||
count: 1,
|
||||
accuracy: 0,
|
||||
subject: "英语",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "04/24 10:30",
|
||||
practiced: false,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
content: "There is no ______ trying to persuad...",
|
||||
count: 1,
|
||||
accuracy: 0,
|
||||
subject: "英语",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "04/24 10:30",
|
||||
practiced: false,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
content: "There is no ______ trying to persuad...",
|
||||
count: 1,
|
||||
accuracy: 0,
|
||||
subject: "英语",
|
||||
type: "单选题",
|
||||
level: "中等",
|
||||
time: "2025/04/24",
|
||||
practiced: false,
|
||||
},
|
||||
];
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
return questions.filter((q) => {
|
||||
if (activeSubject.value !== "all" && q.subject !== activeSubject.value)
|
||||
return false;
|
||||
if (activePractice.value === "done" && !q.practiced) return false;
|
||||
if (activePractice.value === "todo" && q.practiced) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
function handleRetrain() {
|
||||
// TODO: 跳转错题重练
|
||||
}
|
||||
|
||||
function viewDetail(q: Question) {
|
||||
// TODO: 查看详情
|
||||
}
|
||||
|
||||
function extendPractice(q: Question) {
|
||||
// TODO: 举一反三
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrong-questions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- 顶部右侧按钮 ---- */
|
||||
.retrain-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.retrain-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 主体 ---- */
|
||||
.body-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 64px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- 卡片头部(标题 + 筛选) ---- */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28px 40px 24px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #121212;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.filter-groups {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 99px;
|
||||
padding: 6px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
font-size: 24px;
|
||||
color: #555;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 16px;
|
||||
border-radius: 99px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:not(.active):hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 卡片内容 ---- */
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 32px;
|
||||
background: #fff;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 题目网格 ---- */
|
||||
.question-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* ---- 题目卡片 ---- */
|
||||
.question-card {
|
||||
background: #f2fbff;
|
||||
border-radius: 20px;
|
||||
// padding: 28px 28px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1.5px solid #e1f2fa;
|
||||
// gap: 16px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&.card-poor {
|
||||
background: #fff5f5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.question-actions {
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 统计行 */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-count {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #121212;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.stat-accuracy {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
|
||||
&.high {
|
||||
color: #2d6fe0;
|
||||
}
|
||||
|
||||
&.zero {
|
||||
color: #e84040;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.tag-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 20px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.subject-tag {
|
||||
font-weight: 500;
|
||||
|
||||
&.subject-英语 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
color: #2d6fe0;
|
||||
border: 1.5px solid #2d6fe0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.level-tag {
|
||||
color: #e07d2d;
|
||||
background: rgba(224, 125, 45, 0.12);
|
||||
}
|
||||
|
||||
.tag-time {
|
||||
margin-left: auto;
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 12px 0;
|
||||
border-radius: 99px;
|
||||
text-align: center;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
background: #fff;
|
||||
border: 1.5px solid #2d6fe0;
|
||||
color: #2d6fe0;
|
||||
}
|
||||
|
||||
.extend-btn {
|
||||
background: #fff;
|
||||
border: 1.5px solid #2d6fe0;
|
||||
color: #2d6fe0;
|
||||
}
|
||||
</style>
|
||||
166
src/views/Subjects/components/BookCoverCard.vue
Normal file
166
src/views/Subjects/components/BookCoverCard.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="book-cover-card" @click="emit('click', book)">
|
||||
<div class="book-cover" :class="{ 'is-selected': selected }">
|
||||
<!-- 已选角标 -->
|
||||
<div v-if="selected" class="selected-check">
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polyline
|
||||
points="4,10 8.5,15 16,6"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 年份 / 级别徽标(未选中时展示) -->
|
||||
<div v-else class="cover-badges">
|
||||
<span v-if="book.year" class="badge">{{ book.year }}</span>
|
||||
<span v-if="book.level" class="badge">{{ book.level }}</span>
|
||||
</div>
|
||||
<div class="cover-title">ENGLISH</div>
|
||||
</div>
|
||||
<p class="book-name">{{ book.name }}</p>
|
||||
<p class="book-subtitle">{{ book.subtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface BookItem {
|
||||
id: number
|
||||
year: string | null
|
||||
level: string | null
|
||||
name: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
book: BookItem
|
||||
selected?: boolean
|
||||
}>(),
|
||||
{ selected: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', book: BookItem): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.book-cover-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .book-cover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(160deg, #56d6f0 0%, #3bbce0 40%, #6ee7f7 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.15s;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 3px solid transparent;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 50% 50% 0 0;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: #3d78f2;
|
||||
box-shadow: 0 0 0 3px rgba(61, 120, 242, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* 已选角标 */
|
||||
.selected-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #3d78f2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 年份 / 级别徽标 */
|
||||
.cover-badges {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #3bbce0;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 26px;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#ff6bb5 0%,
|
||||
#ffcc00 30%,
|
||||
#3ddb7d 60%,
|
||||
#3d78f2 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.book-name {
|
||||
margin-top: 12px;
|
||||
font-size: 22px;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.book-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 20px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
180
src/views/Subjects/components/PracticeRecordCard.vue
Normal file
180
src/views/Subjects/components/PracticeRecordCard.vue
Normal file
@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="practice-record-card">
|
||||
<!-- 题目标题 -->
|
||||
<p class="card-title">{{ record.title }}</p>
|
||||
|
||||
<!-- 做题数 & 正确率 -->
|
||||
<div class="box">
|
||||
<div class="card-stats">
|
||||
<span class="stat-count"
|
||||
>做题数:<strong>{{ record.count }}次</strong></span
|
||||
>
|
||||
<span class="stat-accuracy">
|
||||
正确率:
|
||||
<strong :class="accuracyClass">{{ record.accuracy }}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签 & 时间 -->
|
||||
<div class="card-footer">
|
||||
<div class="card-tags">
|
||||
<span
|
||||
v-for="tag in record.tags"
|
||||
:key="tag.label"
|
||||
class="tag"
|
||||
:class="tag.type"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="card-time">{{ record.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
export interface RecordTag {
|
||||
label: string;
|
||||
/** 'blue' | 'orange' | 'green' */
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PracticeRecord {
|
||||
id: number;
|
||||
title: string;
|
||||
count: number;
|
||||
accuracy: number;
|
||||
tags: RecordTag[];
|
||||
time: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
record: PracticeRecord;
|
||||
}>();
|
||||
|
||||
const accuracyClass = computed(() => {
|
||||
if (props.record.accuracy >= 80) return "accuracy--high";
|
||||
if (props.record.accuracy >= 60) return "accuracy--mid";
|
||||
return "accuracy--low";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variate.scss" as *;
|
||||
|
||||
.practice-record-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// gap: 18px;
|
||||
// padding: 24px 28px;
|
||||
background: #f2fbff;
|
||||
border: 1.5px solid #ebebeb;
|
||||
border-radius: 16px;
|
||||
// width: 502px;
|
||||
// height: 220px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
width: 502px;
|
||||
height: 138px;
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 19px;
|
||||
}
|
||||
|
||||
// 题目标题
|
||||
.card-title {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
// 做题数 & 正确率行
|
||||
.card-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 22px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-count strong {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-accuracy strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accuracy--high {
|
||||
color: #3d78f2;
|
||||
}
|
||||
|
||||
.accuracy--mid {
|
||||
color: #3d78f2;
|
||||
}
|
||||
|
||||
.accuracy--low {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
// 标签 & 时间行
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 4px 14px;
|
||||
border-radius: 99px;
|
||||
font-size: 20px;
|
||||
|
||||
&.blue {
|
||||
background: #e8effd;
|
||||
color: #3d78f2;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background: #fff0e0;
|
||||
color: #f5820d;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background: #e6faf3;
|
||||
color: #07cc89;
|
||||
}
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 20px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
326
src/views/Subjects/components/SelectBookDrawer.vue
Normal file
326
src/views/Subjects/components/SelectBookDrawer.vue
Normal file
@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
direction="btt"
|
||||
:with-header="false"
|
||||
size="88%"
|
||||
class="select-book-drawer"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="drawer-inner">
|
||||
<!-- ===== 顶部操作栏 ===== -->
|
||||
<div class="drawer-header">
|
||||
<button class="cancel-btn" @click="handleCancel">取消</button>
|
||||
<span class="drawer-title">选择教材</span>
|
||||
<button class="confirm-btn" @click="handleConfirm">确认</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== 筛选区域 ===== -->
|
||||
<div class="filter-section">
|
||||
<!-- 出版社行 -->
|
||||
<div class="filter-row">
|
||||
<div class="filter-scroll">
|
||||
<span
|
||||
v-for="p in publishers"
|
||||
:key="p"
|
||||
class="filter-chip"
|
||||
:class="{ active: activePublisher === p }"
|
||||
@click="activePublisher = p"
|
||||
>{{ p }}</span>
|
||||
</div>
|
||||
<button v-if="!expanded" class="toggle-btn" @click="expanded = true">
|
||||
展示<span class="chevron">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 年级行(展开时) -->
|
||||
<div v-if="expanded" class="filter-row">
|
||||
<div class="filter-scroll">
|
||||
<span
|
||||
v-for="g in grades"
|
||||
:key="g"
|
||||
class="filter-chip"
|
||||
:class="{ active: activeGrade === g }"
|
||||
@click="activeGrade = g"
|
||||
>{{ g }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 年份行 + 收起按钮(展开时) -->
|
||||
<div v-if="expanded" class="filter-row">
|
||||
<div class="filter-scroll">
|
||||
<span
|
||||
v-for="y in years"
|
||||
:key="y"
|
||||
class="filter-chip"
|
||||
:class="{ active: activeYear === y }"
|
||||
@click="activeYear = y"
|
||||
>{{ y }}</span>
|
||||
</div>
|
||||
<button class="toggle-btn" @click="expanded = false">
|
||||
收起<span class="chevron up">▲</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 书本网格区域 ===== -->
|
||||
<div class="book-grid-area">
|
||||
<div class="book-grid">
|
||||
<BookCoverCard
|
||||
v-for="book in filteredBooks"
|
||||
:key="book.id"
|
||||
:book="book"
|
||||
:selected="selectedIds.has(book.id)"
|
||||
@click="toggleSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /drawer-inner -->
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import BookCoverCard from './BookCoverCard.vue'
|
||||
|
||||
interface BookItem {
|
||||
id: number
|
||||
year: string | null
|
||||
level: string | null
|
||||
name: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
books: BookItem[]
|
||||
/** 初始选中的 id 列表(对应已添加的书本) */
|
||||
initialSelected?: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'confirm', selectedIds: number[]): void
|
||||
}>()
|
||||
|
||||
/* ---- 筛选数据 ---- */
|
||||
const grades = ['全部', '一年级', '二年级', '三年级', '四年级', '五年级', '六年级']
|
||||
|
||||
const publishers = computed(() => ['全部', ...new Set(props.books.map((b: BookItem) => b.name))])
|
||||
const years = computed(() => {
|
||||
const ys = props.books.filter((b: BookItem) => b.year).map((b: BookItem) => b.year as string)
|
||||
return ['全部', ...new Set(ys)]
|
||||
})
|
||||
|
||||
const activePublisher = ref('全部')
|
||||
const activeGrade = ref('全部')
|
||||
const activeYear = ref('全部')
|
||||
const expanded = ref(false)
|
||||
|
||||
/* ---- 过滤后的书本列表 ---- */
|
||||
const filteredBooks = computed(() => {
|
||||
return props.books.filter((book: BookItem) => {
|
||||
const matchPublisher = activePublisher.value === '全部' || book.name === activePublisher.value
|
||||
const matchYear = activeYear.value === '全部' || book.year === activeYear.value
|
||||
return matchPublisher && matchYear
|
||||
})
|
||||
})
|
||||
|
||||
/* ---- 已选书本 id ---- */
|
||||
const selectedIds = ref<Set<number>>(new Set())
|
||||
|
||||
// 每次打开弹框时,用初始选中值重置
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
selectedIds.value = new Set(props.initialSelected ?? [])
|
||||
activePublisher.value = '全部'
|
||||
activeGrade.value = '全部'
|
||||
activeYear.value = '全部'
|
||||
expanded.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function toggleSelect(book: BookItem) {
|
||||
const ids = new Set(selectedIds.value)
|
||||
if (ids.has(book.id)) {
|
||||
ids.delete(book.id)
|
||||
} else {
|
||||
ids.add(book.id)
|
||||
}
|
||||
selectedIds.value = ids
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', [...selectedIds.value])
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ---- Drawer 面板样式覆盖(teleport 到 body,必须用 :global 脱离 scoped) ---- */
|
||||
:global(.select-book-drawer) {
|
||||
border-radius: 26px 26px 0 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
:global(.select-book-drawer .el-drawer__body) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ---- 内部布局容器(接管高度与滚动) ---- */
|
||||
.drawer-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 顶部操作栏 ---- */
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28px 40px 20px;
|
||||
flex-shrink: 0;
|
||||
// border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 34px;
|
||||
// font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
font-size: 26px;
|
||||
color: #555;
|
||||
background: transparent;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 99px;
|
||||
padding: 12px 36px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #999;
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
background: #3d78f2;
|
||||
border: none;
|
||||
border-radius: 99px;
|
||||
padding: 12px 36px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #2d64d8;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 筛选区域 ---- */
|
||||
.filter-section {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 40px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
// border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
flex-shrink: 0;
|
||||
font-size: 26px;
|
||||
color: #555;
|
||||
padding: 8px 20px;
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: rgba(51, 126, 255, 0.10);
|
||||
color: #2d64d8;
|
||||
}
|
||||
|
||||
&:not(.active):hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 24px;
|
||||
color: #3d78f2;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 20px;
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 书本网格区域 ---- */
|
||||
.book-grid-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 28px 40px 32px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 28px 24px;
|
||||
}
|
||||
</style>
|
||||
@ -16,8 +16,9 @@ const subjects = ['英语', '语文', '数学', '科学']
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 33px;
|
||||
gap: 32px;
|
||||
border-radius: 12px;
|
||||
padding: 33px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -73,7 +73,7 @@ const currentTasks = computed(() => {
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
// padding: 16px;
|
||||
}
|
||||
|
||||
.tasks-panel {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user