feat: 学情中心

This commit is contained in:
阿梦 2026-05-02 13:14:22 +09:00
parent 69069f278c
commit aa8779c1d4
11 changed files with 1162 additions and 236 deletions

6
.components.d.ts vendored
View File

@ -8,6 +8,10 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default'] ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default']
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'] AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
ChoiceAnswerPanel: typeof import('./src/components/answerQuestions/ChoiceAnswerPanel.vue')['default'] ChoiceAnswerPanel: typeof import('./src/components/answerQuestions/ChoiceAnswerPanel.vue')['default']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
@ -25,6 +29,8 @@ declare module 'vue' {
QuestionStemPanel: typeof import('./src/components/answerQuestions/QuestionStemPanel.vue')['default'] QuestionStemPanel: typeof import('./src/components/answerQuestions/QuestionStemPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
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'] SubjectCard: typeof import('./src/components/cards/SubjectCard.vue')['default']
SubPageHeader: typeof import('./src/components/layout/SubPageHeader.vue')['default'] SubPageHeader: typeof import('./src/components/layout/SubPageHeader.vue')['default']
TabBar: typeof import('./src/components/layout/TabBar.vue')['default'] TabBar: typeof import('./src/components/layout/TabBar.vue')['default']

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

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

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

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

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

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

View File

@ -18,13 +18,7 @@
</button> </button>
<div class="divider" /> <div class="divider" />
<div class="action-list"> <div class="action-list">
<button <button v-for="item in actions" :key="item.label" class="action-item" type="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"
@ -45,26 +39,16 @@
</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">

View File

@ -152,6 +152,8 @@ function onMouseUp(e: MouseEvent) {
border-radius: 12px; border-radius: 12px;
background: #fff; background: #fff;
padding: 52px 64px; padding: 52px 64px;
width: 554px;
height: 238px;
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08); box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
cursor: pointer; cursor: pointer;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;

View File

@ -2,8 +2,14 @@
<PageFrame section-class="insights-wrapper"> <PageFrame section-class="insights-wrapper">
<div class="insights-container"> <div class="insights-container">
<section class="insights-grid"> <section class="insights-grid">
<article v-for="item in items" :key="item" class="insight-card"> <article
<h3 class="insight-title">{{ item }}</h3> 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> </article>
</section> </section>
</div> </div>
@ -11,9 +17,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router'
import PageFrame from '@/components/layout/PageFrame.vue' 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -50,11 +75,14 @@ const items = ['学员管理', '排行榜', '学情分析', '错题本']
background: #fff; background: #fff;
padding: 36px; padding: 36px;
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08); box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
cursor: pointer;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
width: 848px; width: 848px;
height: 373px; height: 373px;
&.clickable {
cursor: pointer;
}
&:hover { &:hover {
box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15); box-shadow: 0 6px 18px rgba(51, 83, 145, 0.15);
} }

View File

@ -17,49 +17,26 @@
</SubPageHeader> </SubPageHeader>
<main class="analysis-body"> <main class="analysis-body">
<section class="analysis-panel"> <SubjectAnalysisPanel
<header class="panel-header"> v-model:active-dimension="activeDimension"
<div class="dimension-group"> v-model:active-time="activeTime"
<LmFilterTabs v-model="activeDimension" :options="dimensionTabs" mode="compact" /> :dimension-tabs="dimensionTabs"
</div> :time-filters="timeFilters"
/>
<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> </main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; 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 SubPageHeader from "@/components/layout/SubPageHeader.vue";
import LmFilterTabs from "@/components/lm/LmFilterTabs.vue"; import type { FilterTabOption } from "@/components/lm/LmFilterTabs.vue";
type Subject = "英语" | "数学" | "语文" | "科学"; type Subject = "英语" | "数学" | "语文" | "科学";
type DimensionKey = "word" | "read" | "language" | "duration" | "video" | "map" | "practice" | "drill"; type TimeKey = "today" | "week" | "month" | "total";
type TimeKey = "today" | "week" | "total";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -71,7 +48,7 @@ const activeSubject = computed<Subject>(() => {
return subjectTabs.includes(raw as Subject) ? (raw as Subject) : "英语"; return subjectTabs.includes(raw as Subject) ? (raw as Subject) : "英语";
}); });
const dimensionTabs: Array<{ key: DimensionKey; label: string }> = [ const englishDimensionTabs: FilterTabOption[] = [
{ key: "word", label: "单词" }, { key: "word", label: "单词" },
{ key: "read", label: "阅读" }, { key: "read", label: "阅读" },
{ key: "language", label: "语感" }, { key: "language", label: "语感" },
@ -82,25 +59,49 @@ const dimensionTabs: Array<{ key: DimensionKey; label: string }> = [
{ key: "drill", label: "刷题" }, { 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: "today", label: "今日" },
{ key: "week", label: "近一周" }, { key: "week", label: "近一周" },
{ key: "total", 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 activeTime = ref<TimeKey>("today");
const statCards = [ watch([activeSubject, dimensionTabs], () => {
{ title: "陌生", value: 0, unit: "词", tone: "tone-red" }, const keys = dimensionTabs.value.map((item) => item.key);
{ title: "认识", value: 0, unit: "词", tone: "tone-orange" }, if (!keys.includes(activeDimension.value)) {
{ title: "熟悉", value: 0, unit: "词", tone: "tone-yellow" }, activeDimension.value = keys[0] as AnalysisDimension;
{ 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" }, watch(timeFilters, () => {
{ title: "有效学习时长", value: 0, unit: "s", tone: "tone-blue" }, const keys = timeFilters.value.map((item) => item.key);
]; if (!keys.includes(activeTime.value)) {
activeTime.value = "today";
}
});
function switchSubject(subject: Subject) { function switchSubject(subject: Subject) {
if (subject === activeSubject.value) return; if (subject === activeSubject.value) return;
@ -117,6 +118,7 @@ function switchSubject(subject: Subject) {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
background: #dfebff;
} }
.subject-tabs { .subject-tabs {
@ -145,171 +147,4 @@ function switchSubject(subject: Subject) {
min-height: 0; min-height: 0;
padding: 0 64px 32px; 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> </style>