feat: 排行榜、学员管理

This commit is contained in:
阿梦 2026-05-01 12:24:23 +09:00
parent a14d38b6f5
commit 69069f278c
11 changed files with 1846 additions and 53 deletions

3
.components.d.ts vendored
View File

@ -15,7 +15,10 @@ declare module 'vue' {
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect'] 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'] 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'] LoadingState: typeof import('./src/components/loading/LoadingState.vue')['default']
PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default'] PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default']
QuestionNumberNav: typeof import('./src/components/answerQuestions/QuestionNumberNav.vue')['default'] QuestionNumberNav: typeof import('./src/components/answerQuestions/QuestionNumberNav.vue')['default']

View File

@ -18,7 +18,13 @@
</button> </button>
<div class="divider" /> <div class="divider" />
<div class="action-list"> <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"> <span class="action-icon">
<img <img
v-if="item.iconSrc" v-if="item.iconSrc"
@ -32,23 +38,33 @@
<span v-if="item.badge > 0" class="action-badge"> <span v-if="item.badge > 0" class="action-badge">
{{ item.badge }} {{ item.badge }}
</span> </span>
</div> </button>
</div> </div>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router'
import xinxiangIcon from '@/assets/header/xinxiang.png' import xinxiangIcon from '@/assets/header/xinxiang.png'
import huodongIcon from '@/assets/header/huodong.png' import huodongIcon from '@/assets/header/huodong.png'
import setupIcon from '@/assets/header/setup.png' import setupIcon from '@/assets/header/setup.png'
const actions = [ 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: xinxiangIcon, badge: 1 }, { label: '信箱', icon: '✉', iconSrc: xinxiangIcon, badge: 1 },
{ label: '活动', icon: '▣', iconSrc: huodongIcon, badge: 1 }, { label: '活动', icon: '▣', iconSrc: huodongIcon, badge: 1 },
{ label: '设置', icon: '⚙', iconSrc: setupIcon, badge: 0 }, { 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -131,7 +147,12 @@ const actions = [
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
border: none;
background: transparent;
color: #1f2937; color: #1f2937;
cursor: pointer;
font: inherit;
padding: 0;
// margin-left: 16px; // margin-left: 16px;
} }

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

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

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

View File

@ -9,6 +9,18 @@ const router = createRouter({
name: 'subjects', name: 'subjects',
component: () => import('@/views/Subjects/index.vue'), 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', path: '/subjects/:subject',
name: 'subject-detail', name: 'subject-detail',
@ -33,6 +45,12 @@ const router = createRouter({
component: () => import('@/views/Subjects/WrongQuestions.vue'), component: () => import('@/views/Subjects/WrongQuestions.vue'),
meta: { hideLayout: true }, meta: { hideLayout: true },
}, },
{
path: '/subjects/:subject/analysis',
name: 'subject-analysis',
component: () => import('@/views/Subjects/SubjectAnalysis.vue'),
meta: { hideLayout: true },
},
{ {
path: '/answer-questions/wrong-retrain', path: '/answer-questions/wrong-retrain',
name: 'wrong-retrain-answer', name: 'wrong-retrain-answer',

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

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

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

View File

@ -24,7 +24,7 @@
<img class="action-icon" :src="xuexishenqiIcon" alt="学习神器" /> <img class="action-icon" :src="xuexishenqiIcon" alt="学习神器" />
<span>学习神器</span> <span>学习神器</span>
</button> </button>
<button class="action-btn"> <button class="action-btn" @click="goToSubjectAnalysis">
<img class="action-icon" :src="xueqinfenxiIcon" alt="学情分析" /> <img class="action-icon" :src="xueqinfenxiIcon" alt="学情分析" />
<span>学情分析</span> <span>学情分析</span>
</button> </button>
@ -212,6 +212,13 @@ function goToWrongQuestions() {
params: { subject: subjectName.value }, params: { subject: subjectName.value },
}); });
} }
function goToSubjectAnalysis() {
router.push({
name: "subject-analysis",
params: { subject: subjectName.value },
});
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -18,28 +18,13 @@
<h2 class="page-title">错题记录</h2> <h2 class="page-title">错题记录</h2>
<div class="filter-groups"> <div class="filter-groups">
<!-- 学科筛选 --> <!-- 学科筛选 -->
<div class="filter-group"> <LmFilterTabs v-model="activeSubject" :options="subjectFilters" />
<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"> <LmFilterTabs v-model="activePractice" :options="practiceFilters" />
<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"> <LmFilterTabs v-model="activeTime" :options="timeFilters" />
<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> </div>
@ -143,6 +128,7 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import SubPageHeader from "@/components/layout/SubPageHeader.vue"; import SubPageHeader from "@/components/layout/SubPageHeader.vue";
import LmFilterTabs from "@/components/lm/LmFilterTabs.vue";
import LmSelect from "@/components/lm/LmSelect.vue"; import LmSelect from "@/components/lm/LmSelect.vue";
import cuotichonglianIcon from "@/assets/subjects/cuotichonglian.svg"; import cuotichonglianIcon from "@/assets/subjects/cuotichonglian.svg";
@ -473,38 +459,6 @@ function extendPractice(q: Question) {
gap: 32px; 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 { .card-body {
flex: 1; flex: 1;