feat: 排行榜、学员管理
This commit is contained in:
parent
a14d38b6f5
commit
69069f278c
3
.components.d.ts
vendored
3
.components.d.ts
vendored
@ -15,7 +15,10 @@ declare module 'vue' {
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
LmFilterTabs: typeof import('./src/components/lm/LmFilterTabs.vue')['default']
|
||||
LmSelect: typeof import('./src/components/lm/LmSelect.vue')['default']
|
||||
LmStudentSummaryTable: typeof import('./src/components/lm/LmStudentSummaryTable.vue')['default']
|
||||
LmStudentTable: typeof import('./src/components/lm/LmStudentTable.vue')['default']
|
||||
LoadingState: typeof import('./src/components/loading/LoadingState.vue')['default']
|
||||
PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default']
|
||||
QuestionNumberNav: typeof import('./src/components/answerQuestions/QuestionNumberNav.vue')['default']
|
||||
|
||||
@ -18,7 +18,13 @@
|
||||
</button>
|
||||
<div class="divider" />
|
||||
<div class="action-list">
|
||||
<div v-for="item in actions" :key="item.label" class="action-item">
|
||||
<button
|
||||
v-for="item in actions"
|
||||
:key="item.label"
|
||||
class="action-item"
|
||||
type="button"
|
||||
@click="handleActionClick(item)"
|
||||
>
|
||||
<span class="action-icon">
|
||||
<img
|
||||
v-if="item.iconSrc"
|
||||
@ -32,23 +38,33 @@
|
||||
<span v-if="item.badge > 0" class="action-badge">
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import xinxiangIcon from '@/assets/header/xinxiang.png'
|
||||
import huodongIcon from '@/assets/header/huodong.png'
|
||||
import setupIcon from '@/assets/header/setup.png'
|
||||
|
||||
const actions = [
|
||||
{ label: '排行榜', icon: '□', iconSrc: xinxiangIcon, badge: 1, routeName: 'ranking-list' },
|
||||
{ label: '学员管理', icon: '□', iconSrc: xinxiangIcon, badge: 1, routeName: 'student-management' },
|
||||
{ label: '作业', icon: '□', iconSrc: xinxiangIcon, badge: 1 },
|
||||
{ label: '信箱', icon: '✉', iconSrc: xinxiangIcon, badge: 1 },
|
||||
{ label: '活动', icon: '▣', iconSrc: huodongIcon, badge: 1 },
|
||||
{ label: '设置', icon: '⚙', iconSrc: setupIcon, badge: 0 },
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function handleActionClick(item: (typeof actions)[number]) {
|
||||
if (!item.routeName) return
|
||||
router.push({ name: item.routeName })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -131,7 +147,12 @@ const actions = [
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
// margin-left: 16px;
|
||||
}
|
||||
|
||||
|
||||
85
src/components/lm/LmFilterTabs.vue
Normal file
85
src/components/lm/LmFilterTabs.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="lm-filter-tabs" :class="[`mode-${mode}`]">
|
||||
<button
|
||||
v-for="item in options"
|
||||
:key="item.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: item.key === modelValue }"
|
||||
type="button"
|
||||
@click="handleSelect(item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, defineProps } from "vue";
|
||||
|
||||
export interface FilterTabOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
options: FilterTabOption[];
|
||||
mode?: "default" | "compact";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const mode = props.mode ?? "default";
|
||||
|
||||
function handleSelect(key: string) {
|
||||
if (key === props.modelValue) return;
|
||||
emit("update:modelValue", key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lm-filter-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 99px;
|
||||
padding: 6px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mode-compact {
|
||||
background: rgba(230, 235, 245, 0.9);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.mode-compact .filter-btn {
|
||||
font-size: 22px;
|
||||
color: #6f7786;
|
||||
padding: 5px 14px;
|
||||
}
|
||||
</style>
|
||||
260
src/components/lm/LmStudentSummaryTable.vue
Normal file
260
src/components/lm/LmStudentSummaryTable.vue
Normal file
@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="summary-table-card">
|
||||
<div class="summary-table-scroll">
|
||||
<table class="summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="index-col">序号</th>
|
||||
<th>姓名</th>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
<button
|
||||
v-if="column.sortable !== false"
|
||||
class="sort-btn"
|
||||
type="button"
|
||||
@click="toggleSort(column.key)"
|
||||
>
|
||||
{{ column.label }}
|
||||
<span class="sort-icon" :class="{ active: sortKey === column.key }">
|
||||
{{ getSortIcon(column.key) }}
|
||||
</span>
|
||||
</button>
|
||||
<template v-else>{{ column.label }}</template>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(student, index) in sortedRows" :key="student.id">
|
||||
<td class="index-col">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div class="student-info">
|
||||
<img class="student-avatar" :src="student.avatar" :alt="student.name" />
|
||||
<span class="student-name">{{ student.name }}</span>
|
||||
<span class="gender-icon" :class="student.gender">
|
||||
{{ student.gender === 'boy' ? '♂' : '♀' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-for="column in columns" :key="column.key">
|
||||
{{ formatMetric(student.metrics[column.key], column) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table v-if="averageRow" class="summary-table average-table">
|
||||
<tbody>
|
||||
<tr class="average-row">
|
||||
<td class="index-col">平均</td>
|
||||
<td></td>
|
||||
<td v-for="column in columns" :key="column.key">
|
||||
{{ formatMetric(averageRow[column.key], column) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type Gender = 'boy' | 'girl'
|
||||
|
||||
export type StudentSummaryRow = {
|
||||
id: number
|
||||
name: string
|
||||
gender: Gender
|
||||
avatar: string
|
||||
metrics: Record<string, number>
|
||||
}
|
||||
|
||||
export type StudentSummaryColumn = {
|
||||
key: string
|
||||
label: string
|
||||
type?: 'number' | 'duration'
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
type SortOrder = 'asc' | 'desc'
|
||||
|
||||
const props = defineProps<{
|
||||
columns: StudentSummaryColumn[]
|
||||
rows: StudentSummaryRow[]
|
||||
averageRow?: Record<string, number>
|
||||
}>()
|
||||
|
||||
const sortKey = ref(props.columns[0]?.key ?? '')
|
||||
const sortOrder = ref<SortOrder>('desc')
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
const orderFactor = sortOrder.value === 'asc' ? 1 : -1
|
||||
|
||||
return [...props.rows].sort((a, b) => {
|
||||
return (getMetricValue(a, sortKey.value) - getMetricValue(b, sortKey.value)) * orderFactor
|
||||
})
|
||||
})
|
||||
|
||||
function getMetricValue(row: StudentSummaryRow, key: string) {
|
||||
return row.metrics[key] ?? 0
|
||||
}
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
return
|
||||
}
|
||||
|
||||
sortKey.value = key
|
||||
sortOrder.value = 'desc'
|
||||
}
|
||||
|
||||
function getSortIcon(key: string) {
|
||||
if (sortKey.value !== key) return '◆'
|
||||
return sortOrder.value === 'asc' ? '▲' : '▼'
|
||||
}
|
||||
|
||||
function formatMetric(value: number | undefined, column: StudentSummaryColumn) {
|
||||
const safeValue = value ?? 0
|
||||
if (column.type === 'duration') return formatDuration(safeValue)
|
||||
return safeValue
|
||||
}
|
||||
|
||||
function formatDuration(totalSeconds: number) {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
return [hours, minutes, seconds].map(value => String(value).padStart(2, '0')).join(':')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.summary-table-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.summary-table-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.average-table {
|
||||
flex-shrink: 0;
|
||||
background: rgba(242, 247, 255, 0.96);
|
||||
}
|
||||
|
||||
.summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 82px;
|
||||
border-bottom: 1px solid rgba(225, 230, 239, 0.52);
|
||||
color: #1f2937;
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
height: 64px;
|
||||
color: #7d8796;
|
||||
font-size: 22px;
|
||||
background: rgba(247, 249, 253, 0.86);
|
||||
}
|
||||
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
th:nth-child(n + 3),
|
||||
td:nth-child(n + 3) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.index-col {
|
||||
width: 86px;
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
margin-left: 6px;
|
||||
color: #c6ccd6;
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
|
||||
&.active {
|
||||
color: #7d8796;
|
||||
}
|
||||
}
|
||||
|
||||
.student-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.student-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.student-name {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.gender-icon {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
|
||||
&.boy {
|
||||
color: #27a9e8;
|
||||
}
|
||||
|
||||
&.girl {
|
||||
color: #ef7aa9;
|
||||
}
|
||||
}
|
||||
|
||||
.average-row {
|
||||
td {
|
||||
border-bottom: none;
|
||||
color: #4f8bd3;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
254
src/components/lm/LmStudentTable.vue
Normal file
254
src/components/lm/LmStudentTable.vue
Normal file
@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="table-card">
|
||||
<table class="student-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-col">
|
||||
<span class="fake-checkbox header-check"></span>
|
||||
</th>
|
||||
<th>姓名</th>
|
||||
<th>
|
||||
<button class="sort-btn" type="button" @click="toggleSort('account')">
|
||||
账号
|
||||
<span class="sort-icon" :class="{ active: sortKey === 'account' }">
|
||||
{{ getSortIcon('account') }}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button class="sort-btn" type="button" @click="toggleSort('coins')">
|
||||
乐贝
|
||||
<span class="sort-icon" :class="{ active: sortKey === 'coins' }">
|
||||
{{ getSortIcon('coins') }}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button class="sort-btn" type="button" @click="toggleSort('diamonds')">
|
||||
钻石
|
||||
<span class="sort-icon" :class="{ active: sortKey === 'diamonds' }">
|
||||
{{ getSortIcon('diamonds') }}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button class="sort-btn" type="button" @click="toggleSort('checkInDays')">
|
||||
累计签到(天)
|
||||
<span class="sort-icon" :class="{ active: sortKey === 'checkInDays' }">
|
||||
{{ getSortIcon('checkInDays') }}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="student in sortedStudents" :key="student.id">
|
||||
<td class="select-col">
|
||||
<span class="fake-checkbox"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="student-info">
|
||||
<img class="student-avatar" :src="student.avatar" :alt="student.name" />
|
||||
<span class="student-name">{{ student.name }}</span>
|
||||
<span class="gender-icon" :class="student.gender">
|
||||
{{ student.gender === 'boy' ? '♂' : '♀' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ student.account }}</td>
|
||||
<td>{{ student.coins }}</td>
|
||||
<td>{{ student.diamonds }}</td>
|
||||
<td>{{ student.checkInDays }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type Gender = 'boy' | 'girl'
|
||||
|
||||
type Student = {
|
||||
id: number
|
||||
name: string
|
||||
gender: Gender
|
||||
avatar: string
|
||||
account: string
|
||||
coins: number
|
||||
diamonds: number
|
||||
checkInDays: number
|
||||
}
|
||||
|
||||
type SortKey = 'account' | 'coins' | 'diamonds' | 'checkInDays'
|
||||
type SortOrder = 'asc' | 'desc'
|
||||
|
||||
const props = defineProps<{
|
||||
students: Student[]
|
||||
}>()
|
||||
|
||||
const sortKey = ref<SortKey>('account')
|
||||
const sortOrder = ref<SortOrder>('asc')
|
||||
|
||||
const sortedStudents = computed(() => {
|
||||
const orderFactor = sortOrder.value === 'asc' ? 1 : -1
|
||||
|
||||
return [...props.students].sort((a, b) => {
|
||||
return compareStudents(a, b, sortKey.value) * orderFactor
|
||||
})
|
||||
})
|
||||
|
||||
function compareStudents(a: Student, b: Student, key: SortKey) {
|
||||
if (key === 'account') return a.account.localeCompare(b.account, 'zh-CN')
|
||||
return a[key] - b[key]
|
||||
}
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
return
|
||||
}
|
||||
|
||||
sortKey.value = key
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
|
||||
function getSortIcon(key: SortKey) {
|
||||
if (sortKey.value !== key) return '◆'
|
||||
return sortOrder.value === 'asc' ? '▲' : '▼'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.table-card {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.student-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 90px;
|
||||
border-bottom: 1px solid rgba(225, 230, 239, 0.52);
|
||||
color: #1f2937;
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
height: 70px;
|
||||
color: #7d8796;
|
||||
font-size: 22px;
|
||||
background: rgba(247, 249, 253, 0.72);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
th:nth-child(3),
|
||||
td:nth-child(3),
|
||||
th:nth-child(4),
|
||||
td:nth-child(4),
|
||||
th:nth-child(5),
|
||||
td:nth-child(5),
|
||||
th:nth-child(6),
|
||||
td:nth-child(6) {
|
||||
width: 18.5%;
|
||||
}
|
||||
}
|
||||
|
||||
.select-col {
|
||||
width: 88px;
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.fake-checkbox {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(218, 226, 238, 0.72);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.header-check {
|
||||
background: rgba(242, 245, 249, 0.92);
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
margin-left: 6px;
|
||||
color: #c6ccd6;
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
|
||||
&.active {
|
||||
color: #7d8796;
|
||||
}
|
||||
}
|
||||
|
||||
.student-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.student-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.student-name {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.gender-icon {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
|
||||
&.boy {
|
||||
color: #27a9e8;
|
||||
}
|
||||
|
||||
&.girl {
|
||||
color: #ef7aa9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -9,6 +9,18 @@ const router = createRouter({
|
||||
name: 'subjects',
|
||||
component: () => import('@/views/Subjects/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subjects/student-management',
|
||||
name: 'student-management',
|
||||
component: () => import('@/views/Subjects/StudentManagement.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/subjects/ranking-list',
|
||||
name: 'ranking-list',
|
||||
component: () => import('@/views/Subjects/RankingList.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject',
|
||||
name: 'subject-detail',
|
||||
@ -33,6 +45,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Subjects/WrongQuestions.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject/analysis',
|
||||
name: 'subject-analysis',
|
||||
component: () => import('@/views/Subjects/SubjectAnalysis.vue'),
|
||||
meta: { hideLayout: true },
|
||||
},
|
||||
{
|
||||
path: '/answer-questions/wrong-retrain',
|
||||
name: 'wrong-retrain-answer',
|
||||
|
||||
421
src/views/Subjects/RankingList.vue
Normal file
421
src/views/Subjects/RankingList.vue
Normal file
@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="ranking-list">
|
||||
<SubPageHeader>
|
||||
<template #center>
|
||||
<nav class="top-tabs">
|
||||
<button
|
||||
v-for="tab in topTabs"
|
||||
:key="tab"
|
||||
class="top-tab"
|
||||
:class="{ active: tab === activeTab }"
|
||||
type="button"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<main class="ranking-body">
|
||||
<section class="ranking-panel">
|
||||
<div class="ranking-toolbar">
|
||||
<LmFilterTabs v-model="metricType" :options="metricOptions" mode="compact" />
|
||||
<LmFilterTabs v-model="scopeType" :options="scopeOptions" mode="compact" />
|
||||
</div>
|
||||
|
||||
<div class="ranking-table-card">
|
||||
<div class="ranking-scroll">
|
||||
<table class="ranking-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rank-col">排名</th>
|
||||
<th>姓名</th>
|
||||
<th class="metric-col">{{ metricLabel }}</th>
|
||||
<th class="check-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="student in rankingRows" :key="student.rank" :class="`rank-${student.rank}`">
|
||||
<td class="rank-col">
|
||||
<span v-if="student.rank <= 3" class="rank-medal" :class="`medal-${student.rank}`">
|
||||
{{ student.rank }}
|
||||
</span>
|
||||
<span v-else class="rank-number">{{ student.rank }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="student-info">
|
||||
<span class="animal-avatar">{{ student.animal }}</span>
|
||||
<span class="name-card">
|
||||
<span class="student-name">{{ student.name }}</span>
|
||||
</span>
|
||||
<span class="gender-icon">♀</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ student.value }}</td>
|
||||
<td class="check-col">
|
||||
<span class="fake-checkbox"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table class="ranking-table current-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="rank-col">100</td>
|
||||
<td>
|
||||
<div class="student-info">
|
||||
<span class="animal-avatar">{{ currentStudent.animal }}</span>
|
||||
<span class="name-card">
|
||||
<span class="student-name">{{ currentStudent.name }}</span>
|
||||
</span>
|
||||
<span class="gender-icon">♀</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="metric-col">{{ currentStudent.value }}</td>
|
||||
<td class="check-col"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import SubPageHeader from '@/components/layout/SubPageHeader.vue'
|
||||
import LmFilterTabs, { type FilterTabOption } from '@/components/lm/LmFilterTabs.vue'
|
||||
|
||||
type RankingRow = {
|
||||
rank: number
|
||||
name: string
|
||||
animal: string
|
||||
value: number
|
||||
}
|
||||
|
||||
const topTabs = ['综合', '语感', '单词', '乐贝']
|
||||
const activeTab = ref('语感')
|
||||
const metricType = ref('sentence')
|
||||
const scopeType = ref('global')
|
||||
|
||||
const metricOptions: FilterTabOption[] = [
|
||||
{ key: 'sentence', label: '句子数' },
|
||||
{ key: 'vocabulary', label: '阅读词汇量' },
|
||||
]
|
||||
|
||||
const scopeOptions: FilterTabOption[] = [
|
||||
{ key: 'global', label: '全服' },
|
||||
{ key: 'region', label: '地区' },
|
||||
{ key: 'school', label: '本校' },
|
||||
]
|
||||
|
||||
const metricLabel = computed(() => {
|
||||
return metricOptions.find(option => option.key === metricType.value)?.label ?? '句子数'
|
||||
})
|
||||
|
||||
const animalIcons = ['🐵', '🐨', '🐻', '🦁', '🐯', '🦊', '🐒']
|
||||
|
||||
const rankingRows: RankingRow[] = animalIcons.map((animal, index) => ({
|
||||
rank: index + 1,
|
||||
name: '张小小',
|
||||
animal,
|
||||
value: 581,
|
||||
}))
|
||||
|
||||
const currentStudent: RankingRow = {
|
||||
rank: 100,
|
||||
name: '张小小',
|
||||
animal: '🐶',
|
||||
value: 581,
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ranking-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.top-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 56px;
|
||||
}
|
||||
|
||||
.top-tab {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 8px 0 12px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.active {
|
||||
color: #2d6fe0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 34px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #3d78f2;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 64px 36px;
|
||||
}
|
||||
|
||||
.ranking-panel {
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, 0.62);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.ranking-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 52px;
|
||||
height: 98px;
|
||||
padding: 0 34px;
|
||||
|
||||
:deep(.lm-filter-tabs) {
|
||||
background: rgba(230, 235, 245, 0.78);
|
||||
}
|
||||
|
||||
:deep(.filter-btn) {
|
||||
font-size: 20px;
|
||||
padding: 5px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-table-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 98px);
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.ranking-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 104px;
|
||||
border-bottom: 1px solid rgba(225, 230, 239, 0.52);
|
||||
color: #1f2937;
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
height: 58px;
|
||||
color: #7d8796;
|
||||
font-size: 22px;
|
||||
background: rgba(247, 249, 253, 0.86);
|
||||
}
|
||||
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
th:nth-child(3),
|
||||
td:nth-child(3) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-col{
|
||||
width: 820px;
|
||||
}
|
||||
|
||||
.rank-col {
|
||||
width: 410px;
|
||||
padding-left: 58px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th.rank-col {
|
||||
padding-left: 62px;
|
||||
}
|
||||
|
||||
.check-col {
|
||||
width: 104px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rank-1 {
|
||||
background: linear-gradient(90deg, rgba(255, 246, 166, 0.5), rgba(255, 255, 255, 0.8) 62%);
|
||||
}
|
||||
|
||||
.rank-2 {
|
||||
background: linear-gradient(90deg, rgba(224, 231, 255, 0.6), rgba(255, 255, 255, 0.8) 62%);
|
||||
}
|
||||
|
||||
.rank-3 {
|
||||
background: linear-gradient(90deg, rgba(255, 229, 203, 0.5), rgba(255, 255, 255, 0.8) 62%);
|
||||
}
|
||||
|
||||
.rank-number {
|
||||
color: #333;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.rank-medal {
|
||||
display: inline-flex;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #8a6d13;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.medal-1 {
|
||||
background: linear-gradient(180deg, #ffef7d, #f6c844);
|
||||
}
|
||||
|
||||
.medal-2 {
|
||||
background: linear-gradient(180deg, #e8ebf8, #bdc5df);
|
||||
}
|
||||
|
||||
.medal-3 {
|
||||
background: linear-gradient(180deg, #f5dbad, #d3aa67);
|
||||
}
|
||||
|
||||
.student-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.animal-avatar {
|
||||
display: inline-flex;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.name-card {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 250px;
|
||||
height: 58px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px solid rgba(246, 151, 196, 0.78);
|
||||
border-radius: 999px;
|
||||
background: #fff8fb;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(255, 255, 255, 0.95),
|
||||
0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 16px;
|
||||
background: #9be7ff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 16px;
|
||||
background: #ffe68a;
|
||||
}
|
||||
}
|
||||
|
||||
.student-name {
|
||||
color: #172033;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gender-icon {
|
||||
color: #ef7aa9;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fake-checkbox {
|
||||
display: inline-block;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 2px solid rgba(218, 226, 238, 0.72);
|
||||
background: rgba(245, 247, 250, 0.88);
|
||||
}
|
||||
|
||||
.current-table {
|
||||
flex-shrink: 0;
|
||||
background: rgba(242, 247, 255, 0.9);
|
||||
box-shadow: 0 -18px 26px rgba(236, 244, 255, 0.9);
|
||||
|
||||
td {
|
||||
height: 104px;
|
||||
border-bottom: none;
|
||||
color: #4f8bd3;
|
||||
}
|
||||
|
||||
.rank-col {
|
||||
color: #4f8bd3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
455
src/views/Subjects/StudentManagement.vue
Normal file
455
src/views/Subjects/StudentManagement.vue
Normal file
@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<div class="student-management">
|
||||
<SubPageHeader>
|
||||
<template #center>
|
||||
<nav class="top-tabs">
|
||||
<button
|
||||
v-for="tab in topTabs"
|
||||
:key="tab"
|
||||
class="top-tab"
|
||||
:class="{ active: tab === activeTab }"
|
||||
type="button"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<div class="header-filters">
|
||||
<LmSelect v-model="selectedSchool" :options="schoolOptions" width="240px" />
|
||||
<LmSelect v-model="selectedClass" :options="classOptions" width="150px" />
|
||||
</div>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<main class="management-body">
|
||||
<section class="student-panel">
|
||||
<template v-if="activeTab === '管理'">
|
||||
<div class="batch-toolbar">
|
||||
<label v-for="action in batchActions" :key="action" class="check-action">
|
||||
<span class="fake-checkbox"></span>
|
||||
<span>{{ action }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<LmStudentTable class="student-table-block" :students="students" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="summary-toolbar">
|
||||
<div class="summary-filter-left">
|
||||
<LmFilterTabs v-model="summaryScope" :options="summaryScopeOptions" mode="compact" />
|
||||
<LmFilterTabs v-model="summaryRange" :options="summaryRangeOptions" mode="compact" />
|
||||
<label v-if="activeTab === '单词句子'" class="auto-update">
|
||||
<span class="fake-checkbox"></span>
|
||||
单词测评
|
||||
</label>
|
||||
<template v-if="summaryRange === 'daily'">
|
||||
<button class="daily-date" type="button">
|
||||
<span class="fake-checkbox"></span>
|
||||
2026/04/23
|
||||
</button>
|
||||
<label class="auto-update">
|
||||
<span class="fake-checkbox"></span>
|
||||
自动更新
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span v-if="summaryRange === 'daily'" class="update-time">更新时间 11:40:42</span>
|
||||
</div>
|
||||
|
||||
<LmStudentSummaryTable
|
||||
class="summary-table-block"
|
||||
:columns="currentSummaryColumns"
|
||||
:rows="currentSummaryRows"
|
||||
:average-row="currentSummaryAverageRow"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import SubPageHeader from '@/components/layout/SubPageHeader.vue'
|
||||
import LmSelect, { type LmSelectOption } from '@/components/lm/LmSelect.vue'
|
||||
import LmFilterTabs, { type FilterTabOption } from '@/components/lm/LmFilterTabs.vue'
|
||||
import LmStudentTable from '@/components/lm/LmStudentTable.vue'
|
||||
import LmStudentSummaryTable, {
|
||||
type StudentSummaryColumn,
|
||||
type StudentSummaryRow,
|
||||
} from '@/components/lm/LmStudentSummaryTable.vue'
|
||||
|
||||
type Gender = 'boy' | 'girl'
|
||||
|
||||
type Student = {
|
||||
id: number
|
||||
name: string
|
||||
gender: Gender
|
||||
avatar: string
|
||||
account: string
|
||||
coins: number
|
||||
diamonds: number
|
||||
checkInDays: number
|
||||
}
|
||||
|
||||
const topTabs = ['管理', '综合', '单词句子', '英语', '数学', '语文']
|
||||
const batchActions = ['批量操作', '批量奖惩', '教材设置', '任务设置', '乐贝明细']
|
||||
const activeTab = ref('管理')
|
||||
const selectedSchool = ref('guanling-huajiang')
|
||||
const selectedClass = ref('grade5-class2')
|
||||
const summaryScope = ref('personal')
|
||||
const summaryRange = ref('total')
|
||||
|
||||
const summaryScopeOptions: FilterTabOption[] = [
|
||||
{ key: 'personal', label: '个人' },
|
||||
{ key: 'group', label: '小组' },
|
||||
]
|
||||
|
||||
const summaryRangeOptions: FilterTabOption[] = [
|
||||
{ key: 'total', label: '总计' },
|
||||
{ key: 'daily', label: '每日数据' },
|
||||
]
|
||||
|
||||
const comprehensiveColumns: StudentSummaryColumn[] = [
|
||||
{ key: 'durationSeconds', label: '有效时长', type: 'duration' },
|
||||
{ key: 'knowledgePoints', label: '知识点' },
|
||||
{ key: 'wrongQuestions', label: '错题' },
|
||||
{ key: 'corrections', label: '订正' },
|
||||
{ key: 'coins', label: '乐贝' },
|
||||
]
|
||||
|
||||
const wordSentenceColumns: StudentSummaryColumn[] = [
|
||||
{ key: 'durationSeconds', label: '有效时长', type: 'duration' },
|
||||
{ key: 'unknown', label: '陌生', sortable: false },
|
||||
{ key: 'known', label: '认识', sortable: false },
|
||||
{ key: 'familiar', label: '熟悉', sortable: false },
|
||||
{ key: 'mastered', label: '掌握' },
|
||||
{ key: 'total', label: '总数' },
|
||||
{ key: 'stars', label: '星星' },
|
||||
]
|
||||
|
||||
const subjectColumns: StudentSummaryColumn[] = [
|
||||
{ key: 'durationSeconds', label: '有效时长', type: 'duration' },
|
||||
{ key: 'correctAnswers', label: '答题正确' },
|
||||
{ key: 'wrongAnswers', label: '答题错误' },
|
||||
{ key: 'knowledgeTotal', label: '知识点总数' },
|
||||
{ key: 'knowledgeMastered', label: '知识点掌握' },
|
||||
{ key: 'correctionTotal', label: '订正总数' },
|
||||
{ key: 'correctionCorrect', label: '订正正确' },
|
||||
]
|
||||
|
||||
const schoolOptions: LmSelectOption[] = [
|
||||
{ label: '关岭自治县花江...', value: 'guanling-huajiang' },
|
||||
{ label: '关岭自治县第一小学', value: 'guanling-no1' },
|
||||
]
|
||||
|
||||
const classOptions: LmSelectOption[] = [
|
||||
{ label: '五年2班', value: 'grade5-class2' },
|
||||
{ label: '五年1班', value: 'grade5-class1' },
|
||||
]
|
||||
|
||||
const students: Student[] = Array.from({ length: 12 }, (_, index) => {
|
||||
const gender: Gender = index === 0 ? 'boy' : 'girl'
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
name: '张小小',
|
||||
gender,
|
||||
avatar: `https://api.dicebear.com/9.x/adventurer/svg?seed=student-${index + 1}`,
|
||||
account: 'chan**',
|
||||
coins: 0,
|
||||
diamonds: 510,
|
||||
checkInDays: 58,
|
||||
}
|
||||
})
|
||||
|
||||
const comprehensiveRows: StudentSummaryRow[] = Array.from({ length: 9 }, (_, index) => {
|
||||
const gender: Gender = index === 0 ? 'boy' : 'girl'
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
name: '张小小',
|
||||
gender,
|
||||
avatar: `https://api.dicebear.com/9.x/adventurer/svg?seed=summary-${index + 1}`,
|
||||
metrics: {
|
||||
durationSeconds: 68 * 3600 + 33 * 60 + 46,
|
||||
knowledgePoints: 968,
|
||||
wrongQuestions: 423,
|
||||
corrections: 581,
|
||||
coins: 59,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const comprehensiveAverageRow = {
|
||||
durationSeconds: 68 * 3600 + 33 * 60 + 46,
|
||||
knowledgePoints: 968,
|
||||
wrongQuestions: 423,
|
||||
corrections: 581,
|
||||
coins: 59,
|
||||
}
|
||||
|
||||
const wordSentenceRows = createMetricRows('word', {
|
||||
durationSeconds: 68 * 3600 + 33 * 60 + 46,
|
||||
unknown: 968,
|
||||
known: 968,
|
||||
familiar: 968,
|
||||
mastered: 968,
|
||||
total: 968,
|
||||
stars: 968,
|
||||
})
|
||||
|
||||
const wordSentenceAverageRow = {
|
||||
durationSeconds: 68 * 3600 + 33 * 60 + 46,
|
||||
unknown: 968,
|
||||
known: 968,
|
||||
familiar: 968,
|
||||
mastered: 968,
|
||||
total: 968,
|
||||
stars: 968,
|
||||
}
|
||||
|
||||
const subjectMetricMap: Record<string, Record<string, number>> = {
|
||||
英语: {
|
||||
durationSeconds: 68 * 3600 + 33 * 60 + 46,
|
||||
correctAnswers: 868,
|
||||
wrongAnswers: 868,
|
||||
knowledgeTotal: 666,
|
||||
knowledgeMastered: 666,
|
||||
correctionTotal: 868,
|
||||
correctionCorrect: 868,
|
||||
},
|
||||
数学: {
|
||||
durationSeconds: 52 * 3600 + 18 * 60 + 25,
|
||||
correctAnswers: 726,
|
||||
wrongAnswers: 139,
|
||||
knowledgeTotal: 580,
|
||||
knowledgeMastered: 492,
|
||||
correctionTotal: 318,
|
||||
correctionCorrect: 286,
|
||||
},
|
||||
语文: {
|
||||
durationSeconds: 45 * 3600 + 9 * 60 + 12,
|
||||
correctAnswers: 642,
|
||||
wrongAnswers: 96,
|
||||
knowledgeTotal: 520,
|
||||
knowledgeMastered: 438,
|
||||
correctionTotal: 260,
|
||||
correctionCorrect: 231,
|
||||
},
|
||||
}
|
||||
|
||||
const currentSummaryColumns = computed(() => {
|
||||
if (activeTab.value === '单词句子') return wordSentenceColumns
|
||||
if (activeTab.value === '综合') return comprehensiveColumns
|
||||
return subjectColumns
|
||||
})
|
||||
|
||||
const currentSummaryRows = computed(() => {
|
||||
if (activeTab.value === '单词句子') return wordSentenceRows
|
||||
if (activeTab.value === '综合') return comprehensiveRows
|
||||
return createMetricRows(activeTab.value, currentSubjectMetrics.value)
|
||||
})
|
||||
|
||||
const currentSummaryAverageRow = computed(() => {
|
||||
if (activeTab.value === '单词句子') return wordSentenceAverageRow
|
||||
if (activeTab.value === '综合') return comprehensiveAverageRow
|
||||
return currentSubjectMetrics.value
|
||||
})
|
||||
|
||||
const currentSubjectMetrics = computed(() => subjectMetricMap[activeTab.value] ?? subjectMetricMap.英语)
|
||||
|
||||
function createMetricRows(seedPrefix: string, metrics: Record<string, number>): StudentSummaryRow[] {
|
||||
return Array.from({ length: 9 }, (_, index) => {
|
||||
const gender: Gender = index === 0 ? 'boy' : 'girl'
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
name: '张小小',
|
||||
gender,
|
||||
avatar: `https://api.dicebear.com/9.x/adventurer/svg?seed=${seedPrefix}-${index + 1}`,
|
||||
metrics: { ...metrics },
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.student-management {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.top-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 46px;
|
||||
}
|
||||
|
||||
.top-tab {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 8px 0 12px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.active {
|
||||
color: #2d6fe0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 34px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #3d78f2;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
:deep(.el-select__wrapper) {
|
||||
min-height: 48px;
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper.is-focused),
|
||||
:deep(.el-select__wrapper.is-hovering) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-select__selected-item) {
|
||||
color: #4b5563;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-select__caret) {
|
||||
color: #8b95a7;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.management-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 64px 36px;
|
||||
}
|
||||
|
||||
.student-panel {
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, 0.62);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.batch-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 42px;
|
||||
height: 98px;
|
||||
padding: 0 34px;
|
||||
}
|
||||
|
||||
.check-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #555f70;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.fake-checkbox {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(218, 226, 238, 0.72);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.student-table-block {
|
||||
height: calc(100% - 98px);
|
||||
}
|
||||
|
||||
.summary-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
height: 98px;
|
||||
padding: 0 34px;
|
||||
|
||||
:deep(.lm-filter-tabs) {
|
||||
background: rgba(230, 235, 245, 0.78);
|
||||
}
|
||||
|
||||
:deep(.filter-btn) {
|
||||
font-size: 20px;
|
||||
padding: 5px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-filter-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 52px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.daily-date,
|
||||
.auto-update {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #445064;
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-date {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-update {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.update-time {
|
||||
color: #7d8796;
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-table-block {
|
||||
height: calc(100% - 98px);
|
||||
}
|
||||
</style>
|
||||
315
src/views/Subjects/SubjectAnalysis.vue
Normal file
315
src/views/Subjects/SubjectAnalysis.vue
Normal file
@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div class="subject-analysis">
|
||||
<SubPageHeader>
|
||||
<template #center>
|
||||
<div class="subject-tabs">
|
||||
<button
|
||||
v-for="item in subjectTabs"
|
||||
:key="item"
|
||||
class="subject-tab"
|
||||
:class="{ active: item === activeSubject }"
|
||||
@click="switchSubject(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</SubPageHeader>
|
||||
|
||||
<main class="analysis-body">
|
||||
<section class="analysis-panel">
|
||||
<header class="panel-header">
|
||||
<div class="dimension-group">
|
||||
<LmFilterTabs v-model="activeDimension" :options="dimensionTabs" mode="compact" />
|
||||
</div>
|
||||
|
||||
<div class="time-filters">
|
||||
<LmFilterTabs v-model="activeTime" :options="timeFilters" mode="compact" />
|
||||
<span class="update-tip">今日数据,每天0点更新</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="panel-content">
|
||||
<h2 class="section-title">单词统计</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<article v-for="item in statCards" :key="item.title" class="stat-card" :class="item.tone">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>
|
||||
<span class="value">{{ item.value }}</span>
|
||||
<span class="unit">{{ item.unit }}</span>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="carousel-indicator">
|
||||
<span v-for="idx in 8" :key="idx" class="dot" :class="{ active: idx === 1 }"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</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 LmFilterTabs from "@/components/lm/LmFilterTabs.vue";
|
||||
|
||||
type Subject = "英语" | "数学" | "语文" | "科学";
|
||||
type DimensionKey = "word" | "read" | "language" | "duration" | "video" | "map" | "practice" | "drill";
|
||||
type TimeKey = "today" | "week" | "total";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const subjectTabs: Subject[] = ["英语", "数学", "语文", "科学"];
|
||||
|
||||
const activeSubject = computed<Subject>(() => {
|
||||
const raw = String(route.params.subject || "英语");
|
||||
return subjectTabs.includes(raw as Subject) ? (raw as Subject) : "英语";
|
||||
});
|
||||
|
||||
const dimensionTabs: Array<{ key: DimensionKey; label: string }> = [
|
||||
{ key: "word", label: "单词" },
|
||||
{ key: "read", label: "阅读" },
|
||||
{ key: "language", label: "语感" },
|
||||
{ key: "duration", label: "学习时长" },
|
||||
{ key: "video", label: "视频学习" },
|
||||
{ key: "map", label: "知识图谱" },
|
||||
{ key: "practice", label: "练习" },
|
||||
{ key: "drill", label: "刷题" },
|
||||
];
|
||||
|
||||
const timeFilters: Array<{ key: TimeKey; label: string }> = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "week", label: "近一周" },
|
||||
{ key: "total", label: "总计" },
|
||||
];
|
||||
|
||||
const activeDimension = ref<DimensionKey>("word");
|
||||
const activeTime = ref<TimeKey>("today");
|
||||
|
||||
const statCards = [
|
||||
{ title: "陌生", value: 0, unit: "词", tone: "tone-red" },
|
||||
{ title: "认识", value: 0, unit: "词", tone: "tone-orange" },
|
||||
{ title: "熟悉", value: 0, unit: "词", tone: "tone-yellow" },
|
||||
{ title: "熟悉", value: 0, unit: "词", tone: "tone-green" },
|
||||
{ title: "总计", value: 0, unit: "词", tone: "tone-blue" },
|
||||
{ title: "新词数", value: 0, unit: "词", tone: "tone-blue" },
|
||||
{ title: "复习数", value: 0, unit: "词", tone: "tone-blue" },
|
||||
{ title: "有效学习时长", value: 0, unit: "s", tone: "tone-blue" },
|
||||
];
|
||||
|
||||
function switchSubject(subject: Subject) {
|
||||
if (subject === activeSubject.value) return;
|
||||
router.replace({
|
||||
name: "subject-analysis",
|
||||
params: { subject },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subject-analysis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.subject-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 52px;
|
||||
}
|
||||
|
||||
.subject-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #323946;
|
||||
font-size: 40px;
|
||||
line-height: 1.1;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.active {
|
||||
color: #2d6fe0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 64px 32px;
|
||||
}
|
||||
|
||||
.analysis-panel {
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 2px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dimension-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.time-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.update-tip {
|
||||
font-size: 20px;
|
||||
color: #9aa3b5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 34px 24px 20px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-top: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
min-height: 178px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px 16px;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(145deg, transparent 0 56%, rgba(255, 255, 255, 0.38) 57%, transparent 61%),
|
||||
linear-gradient(145deg, transparent 0 78%, rgba(255, 255, 255, 0.28) 79%, transparent 82%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 42px;
|
||||
color: #121212;
|
||||
}
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: #2f76eb;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
font-size: 40px;
|
||||
color: #121212;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-red {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 238, 241, 0.92), rgba(255, 247, 247, 0.92));
|
||||
.value {
|
||||
color: #df4545;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-orange {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 245, 233, 0.92), rgba(255, 249, 241, 0.92));
|
||||
.value {
|
||||
color: #e0873c;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-yellow {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 251, 234, 0.92), rgba(255, 253, 243, 0.92));
|
||||
.value {
|
||||
color: #d7a734;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-green {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(235, 255, 248, 0.92), rgba(243, 255, 252, 0.92));
|
||||
.value {
|
||||
color: #2bbf89;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-blue {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(237, 248, 255, 0.92), rgba(245, 251, 255, 0.92));
|
||||
}
|
||||
|
||||
.carousel-indicator {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #d7dded;
|
||||
|
||||
&.active {
|
||||
width: 28px;
|
||||
background: #4f86ef;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -24,7 +24,7 @@
|
||||
<img class="action-icon" :src="xuexishenqiIcon" alt="学习神器" />
|
||||
<span>学习神器</span>
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<button class="action-btn" @click="goToSubjectAnalysis">
|
||||
<img class="action-icon" :src="xueqinfenxiIcon" alt="学情分析" />
|
||||
<span>学情分析</span>
|
||||
</button>
|
||||
@ -212,6 +212,13 @@ function goToWrongQuestions() {
|
||||
params: { subject: subjectName.value },
|
||||
});
|
||||
}
|
||||
|
||||
function goToSubjectAnalysis() {
|
||||
router.push({
|
||||
name: "subject-analysis",
|
||||
params: { subject: subjectName.value },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@ -18,28 +18,13 @@
|
||||
<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>
|
||||
<LmFilterTabs v-model="activeSubject" :options="subjectFilters" />
|
||||
|
||||
<!-- 重练状态筛选 -->
|
||||
<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>
|
||||
<LmFilterTabs v-model="activePractice" :options="practiceFilters" />
|
||||
|
||||
<!-- 时间筛选 -->
|
||||
<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>
|
||||
<LmFilterTabs v-model="activeTime" :options="timeFilters" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -143,6 +128,7 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import SubPageHeader from "@/components/layout/SubPageHeader.vue";
|
||||
import LmFilterTabs from "@/components/lm/LmFilterTabs.vue";
|
||||
import LmSelect from "@/components/lm/LmSelect.vue";
|
||||
import cuotichonglianIcon from "@/assets/subjects/cuotichonglian.svg";
|
||||
|
||||
@ -473,38 +459,6 @@ function extendPractice(q: Question) {
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user