feat: 学情中心
This commit is contained in:
parent
69069f278c
commit
aa8779c1d4
6
.components.d.ts
vendored
6
.components.d.ts
vendored
@ -8,6 +8,10 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default']
|
||||
AnalysisBarChart: typeof import('./src/components/analysis/AnalysisBarChart.vue')['default']
|
||||
AnalysisDonutChart: typeof import('./src/components/analysis/AnalysisDonutChart.vue')['default']
|
||||
AnalysisLineChart: typeof import('./src/components/analysis/AnalysisLineChart.vue')['default']
|
||||
AnalysisStatCard: typeof import('./src/components/analysis/AnalysisStatCard.vue')['default']
|
||||
AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
|
||||
ChoiceAnswerPanel: typeof import('./src/components/answerQuestions/ChoiceAnswerPanel.vue')['default']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
@ -25,6 +29,8 @@ declare module 'vue' {
|
||||
QuestionStemPanel: typeof import('./src/components/answerQuestions/QuestionStemPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SubjectAnalysisContent: typeof import('./src/components/analysis/SubjectAnalysisContent.vue')['default']
|
||||
SubjectAnalysisPanel: typeof import('./src/components/analysis/SubjectAnalysisPanel.vue')['default']
|
||||
SubjectCard: typeof import('./src/components/cards/SubjectCard.vue')['default']
|
||||
SubPageHeader: typeof import('./src/components/layout/SubPageHeader.vue')['default']
|
||||
TabBar: typeof import('./src/components/layout/TabBar.vue')['default']
|
||||
|
||||
168
src/components/analysis/AnalysisBarChart.vue
Normal file
168
src/components/analysis/AnalysisBarChart.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="analysis-bar-chart">
|
||||
<div v-if="showLegend" class="chart-legend">
|
||||
<span v-for="item in items" :key="item.label" class="legend-item">
|
||||
<i :style="{ backgroundColor: item.color }"></i>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="chart-body">
|
||||
<div class="axis-title">{{ axisTitle }}</div>
|
||||
<div class="plot">
|
||||
<div class="y-axis">
|
||||
<span v-for="tick in ticks" :key="tick">{{ tick }}</span>
|
||||
</div>
|
||||
<div class="bars">
|
||||
<div v-for="item in items" :key="item.label" class="bar-column">
|
||||
<span
|
||||
class="bar-value"
|
||||
:style="{ bottom: `calc(${Math.max((item.value / maxValue) * 100, 3)}% + 8px)` }"
|
||||
>
|
||||
{{ item.value }}{{ valueSuffix }}
|
||||
</span>
|
||||
<div
|
||||
class="bar"
|
||||
:style="{
|
||||
height: `${Math.max((item.value / maxValue) * 100, 3)}%`,
|
||||
backgroundColor: item.color,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
export interface AnalysisBarItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: AnalysisBarItem[];
|
||||
max?: number;
|
||||
axisTitle?: string;
|
||||
valueSuffix?: string;
|
||||
showLegend?: boolean;
|
||||
}>(),
|
||||
{
|
||||
max: 0,
|
||||
axisTitle: "单位(h)",
|
||||
valueSuffix: "",
|
||||
showLegend: true,
|
||||
},
|
||||
);
|
||||
|
||||
const maxValue = computed(() => {
|
||||
if (props.max > 0) return props.max;
|
||||
return Math.max(...props.items.map((item) => item.value), 1);
|
||||
});
|
||||
|
||||
const ticks = computed(() => {
|
||||
const step = maxValue.value / 6;
|
||||
return Array.from({ length: 7 }, (_, index) => Math.round(maxValue.value - step * index));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.analysis-bar-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
width: 760px;
|
||||
margin: 0 auto 28px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 20px 34px;
|
||||
color: #646c78;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.axis-title {
|
||||
margin-left: 8px;
|
||||
color: #8a8f99;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.plot {
|
||||
display: flex;
|
||||
height: 330px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.y-axis {
|
||||
width: 45px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding-right: 12px;
|
||||
color: #a4a9b1;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.bars {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
padding: 0 34px 0 18px;
|
||||
border-bottom: 1px solid rgba(214, 221, 232, 0.45);
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(214, 221, 232, 0.34) 0,
|
||||
rgba(214, 221, 232, 0.34) 1px,
|
||||
transparent 1px,
|
||||
transparent 55px
|
||||
);
|
||||
}
|
||||
|
||||
.bar-column {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 58px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 42px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
position: absolute;
|
||||
color: #4b4f58;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
141
src/components/analysis/AnalysisDonutChart.vue
Normal file
141
src/components/analysis/AnalysisDonutChart.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="analysis-donut-chart">
|
||||
<div class="legend">
|
||||
<span v-for="item in items" :key="item.label">
|
||||
<i :style="{ backgroundColor: item.color }"></i>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="donut-wrap">
|
||||
<span
|
||||
v-for="(item, index) in labelItems"
|
||||
:key="item.label"
|
||||
class="donut-label"
|
||||
:class="`label-${index}`"
|
||||
:style="{ color: item.color }"
|
||||
>
|
||||
{{ item.label }} <strong>{{ item.percent }}%</strong>
|
||||
</span>
|
||||
<div class="donut" :style="{ background: donutGradient }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
export interface AnalysisDonutItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: AnalysisDonutItem[];
|
||||
}>();
|
||||
|
||||
const total = computed(() => props.items.reduce((sum, item) => sum + item.value, 0) || 1);
|
||||
|
||||
const labelItems = computed(() =>
|
||||
props.items.map((item) => ({
|
||||
...item,
|
||||
percent: ((item.value / total.value) * 100).toFixed(1),
|
||||
})),
|
||||
);
|
||||
|
||||
const donutGradient = computed(() => {
|
||||
let start = 0;
|
||||
const segments = props.items.map((item) => {
|
||||
const end = start + (item.value / total.value) * 100;
|
||||
const segment = `${item.color} ${start}% ${end}%`;
|
||||
start = end;
|
||||
return segment;
|
||||
});
|
||||
return `conic-gradient(${segments.join(", ")})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.analysis-donut-chart {
|
||||
width: 620px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 34px;
|
||||
color: #4f5967;
|
||||
font-size: 16px;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-wrap {
|
||||
position: relative;
|
||||
height: 330px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.donut {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 74px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-label {
|
||||
position: absolute;
|
||||
color: #98a3ad;
|
||||
font-size: 20px;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
margin-left: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.label-0 {
|
||||
left: 8px;
|
||||
top: 205px;
|
||||
}
|
||||
|
||||
.label-1 {
|
||||
right: 0;
|
||||
top: 82px;
|
||||
}
|
||||
|
||||
.label-2 {
|
||||
right: 4px;
|
||||
top: 245px;
|
||||
}
|
||||
|
||||
.label-3 {
|
||||
left: 0;
|
||||
top: 66px;
|
||||
color: #9da7b2 !important;
|
||||
}
|
||||
</style>
|
||||
101
src/components/analysis/AnalysisLineChart.vue
Normal file
101
src/components/analysis/AnalysisLineChart.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="analysis-line-chart">
|
||||
<div class="y-axis">
|
||||
<span v-for="tick in ticks" :key="tick">{{ tick }}</span>
|
||||
</div>
|
||||
<div class="plot">
|
||||
<svg viewBox="0 0 760 320" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="lineFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ef6666" stop-opacity="0.26" />
|
||||
<stop offset="100%" stop-color="#ef6666" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path :d="areaPath" fill="url(#lineFill)" />
|
||||
<path :d="linePath" fill="none" stroke="#df2828" stroke-width="4" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
values: number[];
|
||||
max?: number;
|
||||
}>(),
|
||||
{
|
||||
max: 700,
|
||||
},
|
||||
);
|
||||
|
||||
const width = 760;
|
||||
const height = 320;
|
||||
|
||||
const points = computed(() => {
|
||||
const count = Math.max(props.values.length - 1, 1);
|
||||
return props.values.map((value, index) => {
|
||||
const x = (index / count) * width;
|
||||
const y = height - (value / props.max) * height;
|
||||
return { x, y };
|
||||
});
|
||||
});
|
||||
|
||||
const linePath = computed(() =>
|
||||
points.value
|
||||
.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`)
|
||||
.join(" "),
|
||||
);
|
||||
|
||||
const areaPath = computed(() => {
|
||||
if (!points.value.length) return "";
|
||||
const end = points.value[points.value.length - 1];
|
||||
return `${linePath.value} L ${end.x.toFixed(1)} ${height} L 0 ${height} Z`;
|
||||
});
|
||||
|
||||
const ticks = computed(() => {
|
||||
const step = props.max / 7;
|
||||
return Array.from({ length: 7 }, (_, index) => Math.round(props.max - step * (index + 1)));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.analysis-line-chart {
|
||||
width: 900px;
|
||||
height: 360px;
|
||||
margin: 10px auto 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.y-axis {
|
||||
width: 55px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 4px 16px 34px 0;
|
||||
color: #a3a8b1;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.plot {
|
||||
flex: 1;
|
||||
height: 320px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(214, 221, 232, 0.35) 0,
|
||||
rgba(214, 221, 232, 0.35) 1px,
|
||||
transparent 1px,
|
||||
transparent 53px
|
||||
);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
112
src/components/analysis/AnalysisStatCard.vue
Normal file
112
src/components/analysis/AnalysisStatCard.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<article class="analysis-stat-card" :class="[`tone-${tone}`]">
|
||||
<h3>{{ title }}</h3>
|
||||
<p>
|
||||
<span class="value">{{ value }}</span>
|
||||
<span class="unit">{{ unit }}</span>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
value: string | number;
|
||||
unit: string;
|
||||
tone?: "red" | "orange" | "yellow" | "green" | "blue";
|
||||
}>(),
|
||||
{
|
||||
tone: "blue",
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.analysis-stat-card {
|
||||
position: relative;
|
||||
width: 554px;
|
||||
height: 218px;
|
||||
border: 1px solid rgba(221, 234, 241, 0.9);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 28px 30px 24px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(239, 250, 255, 0.86), rgba(252, 254, 255, 0.96));
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(132deg, transparent 0 48%, rgba(255, 255, 255, 0.38) 49%, transparent 58%),
|
||||
linear-gradient(132deg, transparent 0 68%, rgba(255, 255, 255, 0.25) 69%, transparent 78%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h3,
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #111;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #2d8ed6;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 7px;
|
||||
color: #111;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-red {
|
||||
background: linear-gradient(180deg, rgba(255, 242, 245, 0.9), rgba(255, 253, 253, 0.96));
|
||||
|
||||
.value {
|
||||
color: #d8222a;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-orange {
|
||||
background: linear-gradient(180deg, rgba(255, 248, 241, 0.9), rgba(255, 253, 250, 0.96));
|
||||
|
||||
.value {
|
||||
color: #f07b2d;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-yellow {
|
||||
background: linear-gradient(180deg, rgba(255, 253, 235, 0.92), rgba(255, 255, 250, 0.96));
|
||||
|
||||
.value {
|
||||
color: #d8b20e;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-green {
|
||||
background: linear-gradient(180deg, rgba(235, 255, 249, 0.92), rgba(250, 255, 253, 0.96));
|
||||
|
||||
.value {
|
||||
color: #30c590;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
312
src/components/analysis/SubjectAnalysisContent.vue
Normal file
312
src/components/analysis/SubjectAnalysisContent.vue
Normal file
@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="subject-analysis-content" :class="{ 'is-knowledge': dimension === 'map' }">
|
||||
<h2 class="section-title">{{ currentTitle }}</h2>
|
||||
|
||||
<template v-if="dimension === 'word'">
|
||||
<div class="stats-grid stats-grid-word">
|
||||
<AnalysisStatCard
|
||||
v-for="(item, index) in wordCards"
|
||||
:key="`${item.title}-${index}`"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="dimension === 'duration'">
|
||||
<div class="stats-grid stats-grid-narrow">
|
||||
<AnalysisStatCard
|
||||
v-for="item in durationCards"
|
||||
:key="item.title"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
<AnalysisBarChart :items="studyBars" :max="12" value-suffix="h" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="dimension === 'video'">
|
||||
<div class="stats-grid stats-grid-narrow">
|
||||
<AnalysisStatCard
|
||||
v-for="item in videoCards"
|
||||
:key="item.title"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
<AnalysisLineChart :values="videoLineValues" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="dimension === 'map'">
|
||||
<div class="knowledge-scroll">
|
||||
<AnalysisDonutChart :items="donutItems" />
|
||||
<p class="knowledge-subtitle">• 我的知识图谱变化情况 •</p>
|
||||
<AnalysisBarChart
|
||||
class="knowledge-bar"
|
||||
:items="knowledgeBars"
|
||||
:max="240"
|
||||
axis-title="单位(h)"
|
||||
:show-legend="true"
|
||||
/>
|
||||
<table class="knowledge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>知识点名称</th>
|
||||
<th>答题次数</th>
|
||||
<th>掌握程度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in knowledgeRows" :key="row.id">
|
||||
<td>语言表达简明、准确、鲜明、生动</td>
|
||||
<td>8</td>
|
||||
<td :class="row.status === '掌握' ? 'status-pass' : 'status-risk'">{{ row.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="stats-grid" :class="simpleGridClass">
|
||||
<AnalysisStatCard
|
||||
v-for="(item, index) in simpleCards"
|
||||
:key="`${item.title}-${index}`"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import AnalysisBarChart, { type AnalysisBarItem } from "./AnalysisBarChart.vue";
|
||||
import AnalysisDonutChart, { type AnalysisDonutItem } from "./AnalysisDonutChart.vue";
|
||||
import AnalysisLineChart from "./AnalysisLineChart.vue";
|
||||
import AnalysisStatCard from "./AnalysisStatCard.vue";
|
||||
|
||||
export type AnalysisDimension = "word" | "read" | "language" | "duration" | "video" | "map" | "practice" | "drill";
|
||||
|
||||
type StatTone = "red" | "orange" | "yellow" | "green" | "blue";
|
||||
|
||||
interface StatCard {
|
||||
title: string;
|
||||
value: string | number;
|
||||
unit: string;
|
||||
tone?: StatTone;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
dimension: AnalysisDimension;
|
||||
}>();
|
||||
|
||||
const titleMap: Record<AnalysisDimension, string> = {
|
||||
word: "单词统计",
|
||||
read: "阅读统计",
|
||||
language: "语感统计",
|
||||
duration: "学习时长统计",
|
||||
video: "视频学习统计",
|
||||
map: "知识图谱统计",
|
||||
practice: "练习统计",
|
||||
drill: "刷题统计",
|
||||
};
|
||||
|
||||
const currentTitle = computed(() => titleMap[props.dimension]);
|
||||
|
||||
const wordCards: StatCard[] = [
|
||||
{ title: "陌生", value: 0, unit: "词", tone: "red" },
|
||||
{ title: "认识", value: 0, unit: "词", tone: "orange" },
|
||||
{ title: "熟悉", value: 0, unit: "词", tone: "yellow" },
|
||||
{ title: "熟悉", value: 0, unit: "词", tone: "green" },
|
||||
{ title: "总计", value: 0, unit: "词", tone: "blue" },
|
||||
{ title: "新词数", value: 0, unit: "词", tone: "blue" },
|
||||
{ title: "复习数", value: 0, unit: "词", tone: "blue" },
|
||||
{ title: "有效学习时长", value: 0, unit: "s", tone: "blue" },
|
||||
];
|
||||
|
||||
const durationCards: StatCard[] = [
|
||||
{ title: "累计学习", value: 0, unit: "天" },
|
||||
{ title: "连续学习", value: 0, unit: "天" },
|
||||
];
|
||||
|
||||
const videoCards: StatCard[] = [
|
||||
{ title: "已学视频", value: 0, unit: "个" },
|
||||
{ title: "观看视频时长", value: 0, unit: "s" },
|
||||
];
|
||||
|
||||
const cardMap: Record<AnalysisDimension, StatCard[]> = {
|
||||
word: wordCards,
|
||||
read: [
|
||||
{ title: "练习数", value: 0, unit: "次" },
|
||||
{ title: "阅读词汇量", value: 0, unit: "次" },
|
||||
{ title: "阅读时长", value: 0, unit: "s" },
|
||||
{ title: "正确率", value: 0, unit: "%" },
|
||||
],
|
||||
language: [
|
||||
{ title: "句子练习数", value: 0, unit: "词" },
|
||||
{ title: "阅读词汇量", value: 0, unit: "词" },
|
||||
{ title: "正确率", value: 0, unit: "%" },
|
||||
],
|
||||
duration: durationCards,
|
||||
video: videoCards,
|
||||
map: [],
|
||||
practice: [
|
||||
{ title: "自由学", value: 0, unit: "次" },
|
||||
{ title: "AI精准学", value: 0, unit: "次" },
|
||||
{ title: "自由组卷", value: 0, unit: "次" },
|
||||
],
|
||||
drill: [
|
||||
{ title: "做题数", value: 0, unit: "道" },
|
||||
{ title: "产生错题数", value: 0, unit: "道" },
|
||||
{ title: "订正错题数", value: 0, unit: "道" },
|
||||
{ title: "答题正确率", value: 0, unit: "%" },
|
||||
],
|
||||
};
|
||||
|
||||
const simpleCards = computed(() => cardMap[props.dimension]);
|
||||
|
||||
const simpleGridClass = computed(() => ({
|
||||
"stats-grid-medium": props.dimension === "read" || props.dimension === "drill",
|
||||
"stats-grid-language": props.dimension === "language" || props.dimension === "practice",
|
||||
}));
|
||||
|
||||
const studyBars: AnalysisBarItem[] = [
|
||||
{ label: "自由学", value: 5.5, color: "#efa0e4" },
|
||||
{ label: "AI精准学", value: 8.6, color: "#858cf0" },
|
||||
{ label: "错题本", value: 11, color: "#6fb7ed" },
|
||||
{ label: "自由组卷", value: 6.3, color: "#68d9e8" },
|
||||
{ label: "单词闯关", value: 9, color: "#8ad000" },
|
||||
{ label: "单词测试", value: 4.6, color: "#f8c16e" },
|
||||
{ label: "语感训练", value: 9.5, color: "#f47373" },
|
||||
];
|
||||
|
||||
const videoLineValues = [365, 400, 385, 330, 275, 252, 248, 236, 210, 166, 80];
|
||||
|
||||
const donutItems: AnalysisDonutItem[] = [
|
||||
{ label: "陌生", value: 31, color: "#ef1d23" },
|
||||
{ label: "熟悉", value: 23, color: "#f5bf00" },
|
||||
{ label: "掌握", value: 28, color: "#1fc071" },
|
||||
{ label: "待点亮", value: 18, color: "#d4e0e5" },
|
||||
];
|
||||
|
||||
const knowledgeBars: AnalysisBarItem[] = [
|
||||
{ label: "陌生", value: 226, color: "#ef1d23" },
|
||||
{ label: "熟悉", value: 130, color: "#f5bf00" },
|
||||
{ label: "掌握", value: 178, color: "#1fc071" },
|
||||
];
|
||||
|
||||
const knowledgeRows = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: index,
|
||||
status: index === 1 ? "陌生" : "掌握",
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subject-analysis-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 62px 34px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
color: #060606;
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
gap: 26px 28px;
|
||||
}
|
||||
|
||||
.stats-grid-word {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 62px;
|
||||
}
|
||||
|
||||
.stats-grid-narrow,
|
||||
.stats-grid-medium,
|
||||
.stats-grid-language {
|
||||
width: 1140px;
|
||||
margin: 84px auto 62px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 26px 30px;
|
||||
}
|
||||
|
||||
.stats-grid-language {
|
||||
width: 1120px;
|
||||
margin-top: 84px;
|
||||
}
|
||||
|
||||
.stats-grid-language :deep(.analysis-stat-card) {
|
||||
min-height: 165px;
|
||||
}
|
||||
|
||||
.knowledge-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 28px 0 42px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.knowledge-subtitle {
|
||||
margin: 16px 0 18px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.knowledge-bar {
|
||||
margin-bottom: 54px;
|
||||
}
|
||||
|
||||
.knowledge-table {
|
||||
width: 1060px;
|
||||
margin: 0 auto;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e1e6ed;
|
||||
border-radius: 12px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 56px;
|
||||
padding: 0 42px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f7f9fc;
|
||||
color: #8c94a0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
th:nth-child(2),
|
||||
th:nth-child(3),
|
||||
td:nth-child(2),
|
||||
td:nth-child(3) {
|
||||
width: 210px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status-pass {
|
||||
color: #1fc071;
|
||||
}
|
||||
|
||||
.status-risk {
|
||||
color: #ef4d5a;
|
||||
}
|
||||
</style>
|
||||
237
src/components/analysis/SubjectAnalysisPanel.vue
Normal file
237
src/components/analysis/SubjectAnalysisPanel.vue
Normal file
@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<section class="analysis-panel">
|
||||
<header class="panel-header">
|
||||
<LmFilterTabs v-model="dimensionModel" :options="dimensionTabs" mode="compact" />
|
||||
|
||||
<div class="right-filters">
|
||||
<LmFilterTabs v-model="timeModel" :options="timeFilters" mode="compact" />
|
||||
<span class="update-tip">今日数据,每天0点更新</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="panel-main"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchend.passive="onTouchEnd"
|
||||
@mousedown="onMouseDown"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<Transition :name="slideTransition" mode="out-in">
|
||||
<SubjectAnalysisContent :key="activeDimension" :dimension="activeDimension" />
|
||||
</Transition>
|
||||
<div class="pagination-dots">
|
||||
<button
|
||||
v-for="item in dimensionTabs"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ active: item.key === activeDimension }"
|
||||
:aria-label="`切换到${item.label}`"
|
||||
@click="setDimension(item.key)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import LmFilterTabs, { type FilterTabOption } from "@/components/lm/LmFilterTabs.vue";
|
||||
import SubjectAnalysisContent, { type AnalysisDimension } from "./SubjectAnalysisContent.vue";
|
||||
|
||||
type TimeKey = "today" | "week" | "month" | "total";
|
||||
|
||||
const props = defineProps<{
|
||||
activeDimension: AnalysisDimension;
|
||||
activeTime: TimeKey;
|
||||
dimensionTabs: FilterTabOption[];
|
||||
timeFilters: FilterTabOption[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:activeDimension", value: AnalysisDimension): void;
|
||||
(e: "update:activeTime", value: TimeKey): void;
|
||||
}>();
|
||||
|
||||
const dimensionModel = computed({
|
||||
get: () => props.activeDimension,
|
||||
set: (value: string) => setDimension(value),
|
||||
});
|
||||
|
||||
const timeModel = computed({
|
||||
get: () => props.activeTime,
|
||||
set: (value: string) => emit("update:activeTime", value as TimeKey),
|
||||
});
|
||||
|
||||
const slideDirection = ref<"left" | "right">("left");
|
||||
const slideTransition = computed(() => `slide-${slideDirection.value}`);
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let mouseStartX = 0;
|
||||
let mouseStartY = 0;
|
||||
let isMouseDown = false;
|
||||
|
||||
function setDimension(value: string) {
|
||||
const next = value as AnalysisDimension;
|
||||
const currentIndex = currentDimensionIndex.value;
|
||||
const nextIndex = props.dimensionTabs.findIndex((item) => item.key === next);
|
||||
|
||||
if (nextIndex >= 0 && currentIndex >= 0) {
|
||||
slideDirection.value = nextIndex > currentIndex ? "left" : "right";
|
||||
}
|
||||
|
||||
emit("update:activeDimension", next);
|
||||
}
|
||||
|
||||
function switchByOffset(offset: number) {
|
||||
const nextIndex = currentDimensionIndex.value + offset;
|
||||
const next = props.dimensionTabs[nextIndex];
|
||||
if (!next) return;
|
||||
slideDirection.value = offset > 0 ? "left" : "right";
|
||||
emit("update:activeDimension", next.key as AnalysisDimension);
|
||||
}
|
||||
|
||||
function handleSwipe(endX: number, endY: number, startX: number, startY: number) {
|
||||
const diffX = startX - endX;
|
||||
const diffY = startY - endY;
|
||||
if (Math.abs(diffX) < 60 || Math.abs(diffX) <= Math.abs(diffY)) return;
|
||||
switchByOffset(diffX > 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
function onTouchStart(event: TouchEvent) {
|
||||
touchStartX = event.touches[0].clientX;
|
||||
touchStartY = event.touches[0].clientY;
|
||||
}
|
||||
|
||||
function onTouchEnd(event: TouchEvent) {
|
||||
const touch = event.changedTouches[0];
|
||||
handleSwipe(touch.clientX, touch.clientY, touchStartX, touchStartY);
|
||||
}
|
||||
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
isMouseDown = true;
|
||||
mouseStartX = event.clientX;
|
||||
mouseStartY = event.clientY;
|
||||
}
|
||||
|
||||
function onMouseUp(event: MouseEvent) {
|
||||
if (!isMouseDown) return;
|
||||
isMouseDown = false;
|
||||
handleSwipe(event.clientX, event.clientY, mouseStartX, mouseStartY);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
isMouseDown = false;
|
||||
}
|
||||
|
||||
const currentDimensionIndex = computed(() =>
|
||||
props.dimensionTabs.findIndex((item) => item.key === props.activeDimension),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.analysis-panel {
|
||||
width: 100%;
|
||||
max-width: 1792px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 24px 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.right-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 238px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.update-tip {
|
||||
color: #7c8595;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-main {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(250, 254, 255, 0.82), rgba(255, 255, 255, 0.95));
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition:
|
||||
transform 0.28s ease,
|
||||
opacity 0.28s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from,
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(36px);
|
||||
}
|
||||
|
||||
.slide-left-leave-to,
|
||||
.slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-36px);
|
||||
}
|
||||
|
||||
.pagination-dots {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 13px;
|
||||
height: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #dbe3ef;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
width: 34px;
|
||||
background: #9ec2ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -18,13 +18,7 @@
|
||||
</button>
|
||||
<div class="divider" />
|
||||
<div class="action-list">
|
||||
<button
|
||||
v-for="item in actions"
|
||||
:key="item.label"
|
||||
class="action-item"
|
||||
type="button"
|
||||
@click="handleActionClick(item)"
|
||||
>
|
||||
<button v-for="item in actions" :key="item.label" class="action-item" type="button">
|
||||
<span class="action-icon">
|
||||
<img
|
||||
v-if="item.iconSrc"
|
||||
@ -45,26 +39,16 @@
|
||||
</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">
|
||||
|
||||
@ -152,6 +152,8 @@ function onMouseUp(e: MouseEvent) {
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 52px 64px;
|
||||
width: 554px;
|
||||
height: 238px;
|
||||
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
@ -2,8 +2,14 @@
|
||||
<PageFrame section-class="insights-wrapper">
|
||||
<div class="insights-container">
|
||||
<section class="insights-grid">
|
||||
<article v-for="item in items" :key="item" class="insight-card">
|
||||
<h3 class="insight-title">{{ item }}</h3>
|
||||
<article
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="insight-card"
|
||||
:class="{ clickable: item.routeName }"
|
||||
@click="handleCardClick(item)"
|
||||
>
|
||||
<h3 class="insight-title">{{ item.label }}</h3>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
@ -11,9 +17,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageFrame from '@/components/layout/PageFrame.vue'
|
||||
|
||||
const items = ['学员管理', '排行榜', '学情分析', '错题本']
|
||||
type InsightItem = {
|
||||
label: string
|
||||
routeName?: string
|
||||
routeParams?: Record<string, string>
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const items: InsightItem[] = [
|
||||
{ label: '学员管理', routeName: 'student-management' },
|
||||
{ label: '排行榜', routeName: 'ranking-list' },
|
||||
{ label: '学情分析', routeName: 'subject-analysis', routeParams: { subject: '英语' } },
|
||||
{ label: '错题本' },
|
||||
]
|
||||
|
||||
function handleCardClick(item: InsightItem) {
|
||||
if (!item.routeName) return
|
||||
router.push({ name: item.routeName, params: item.routeParams })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -50,10 +75,13 @@ const items = ['学员管理', '排行榜', '学情分析', '错题本']
|
||||
background: #fff;
|
||||
padding: 36px;
|
||||
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
width: 848px;
|
||||
height: 373px;
|
||||
height: 373px;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15);
|
||||
|
||||
@ -17,49 +17,26 @@
|
||||
</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>
|
||||
<SubjectAnalysisPanel
|
||||
v-model:active-dimension="activeDimension"
|
||||
v-model:active-time="activeTime"
|
||||
:dimension-tabs="dimensionTabs"
|
||||
:time-filters="timeFilters"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import SubjectAnalysisPanel from "@/components/analysis/SubjectAnalysisPanel.vue";
|
||||
import type { AnalysisDimension } from "@/components/analysis/SubjectAnalysisContent.vue";
|
||||
import SubPageHeader from "@/components/layout/SubPageHeader.vue";
|
||||
import LmFilterTabs from "@/components/lm/LmFilterTabs.vue";
|
||||
import type { FilterTabOption } from "@/components/lm/LmFilterTabs.vue";
|
||||
|
||||
type Subject = "英语" | "数学" | "语文" | "科学";
|
||||
type DimensionKey = "word" | "read" | "language" | "duration" | "video" | "map" | "practice" | "drill";
|
||||
type TimeKey = "today" | "week" | "total";
|
||||
type TimeKey = "today" | "week" | "month" | "total";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -71,7 +48,7 @@ const activeSubject = computed<Subject>(() => {
|
||||
return subjectTabs.includes(raw as Subject) ? (raw as Subject) : "英语";
|
||||
});
|
||||
|
||||
const dimensionTabs: Array<{ key: DimensionKey; label: string }> = [
|
||||
const englishDimensionTabs: FilterTabOption[] = [
|
||||
{ key: "word", label: "单词" },
|
||||
{ key: "read", label: "阅读" },
|
||||
{ key: "language", label: "语感" },
|
||||
@ -82,25 +59,49 @@ const dimensionTabs: Array<{ key: DimensionKey; label: string }> = [
|
||||
{ key: "drill", label: "刷题" },
|
||||
];
|
||||
|
||||
const timeFilters: Array<{ key: TimeKey; label: string }> = [
|
||||
const commonDimensionTabs: FilterTabOption[] = [
|
||||
{ key: "duration", label: "学习时长" },
|
||||
{ key: "video", label: "视频学习" },
|
||||
{ key: "map", label: "知识图谱" },
|
||||
{ key: "practice", label: "练习" },
|
||||
{ key: "drill", label: "刷题" },
|
||||
];
|
||||
|
||||
const dimensionTabs = computed(() =>
|
||||
activeSubject.value === "英语" ? englishDimensionTabs : commonDimensionTabs,
|
||||
);
|
||||
|
||||
const baseTimeFilters: FilterTabOption[] = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "week", label: "近一周" },
|
||||
{ key: "month", label: "近一月" },
|
||||
{ key: "total", label: "总计" },
|
||||
];
|
||||
|
||||
const wordTimeFilters: FilterTabOption[] = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "week", label: "近一周" },
|
||||
{ key: "total", label: "总计" },
|
||||
];
|
||||
|
||||
const activeDimension = ref<DimensionKey>("word");
|
||||
const timeFilters = computed(() => (activeDimension.value === "word" ? wordTimeFilters : baseTimeFilters));
|
||||
|
||||
const activeDimension = ref<AnalysisDimension>("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" },
|
||||
];
|
||||
watch([activeSubject, dimensionTabs], () => {
|
||||
const keys = dimensionTabs.value.map((item) => item.key);
|
||||
if (!keys.includes(activeDimension.value)) {
|
||||
activeDimension.value = keys[0] as AnalysisDimension;
|
||||
}
|
||||
});
|
||||
|
||||
watch(timeFilters, () => {
|
||||
const keys = timeFilters.value.map((item) => item.key);
|
||||
if (!keys.includes(activeTime.value)) {
|
||||
activeTime.value = "today";
|
||||
}
|
||||
});
|
||||
|
||||
function switchSubject(subject: Subject) {
|
||||
if (subject === activeSubject.value) return;
|
||||
@ -117,6 +118,7 @@ function switchSubject(subject: Subject) {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #dfebff;
|
||||
}
|
||||
|
||||
.subject-tabs {
|
||||
@ -145,171 +147,4 @@ function switchSubject(subject: Subject) {
|
||||
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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user