refactor(智能分析): 重排版面,砍掉重复的三张周期图,补三个新维度

原来 8 张图挤在两列大网格里,左列还套了一层两列小网格 —— 四张小图各自只有半页的
一半宽,"难度掌握情况"的标题被挤断成两行、周期图的 x 轴标签斜着叠在一起、一年热力图
53 列塞进半宽几乎看不清。改成单列为主,宽的图给全宽,窄的图两两并排。

砍掉的重复:进步曲线 / 提交效率 / 周期综合读的是同一个 durationData,半年视图统共
6 个桶四个字段,摊成三张卡六条曲线还都是双轴。合并成一张:柱是完成题目数和总提交数,
线是 AC 率,等级进 tooltip(S/A/B/C 四档离散值连成折线读不出东西,逐题等级表格里有)。

同期解题排名分布也砍了:五片扇形对十几道题做统计本来就是噪声,而排名逐题列在
SolvedTable 里,饼图没有增加任何信息。

换形式:
- 标签雷达图 → 横向条形。雷达对比较大小是最差的形式之一,原来还把值归一化成"占最多
  标签的百分比",第一名恒为 100%,等于只画了个排序。现在画真实题数。
- 难度掌握情况 → 难度分布。去掉叠在里面的 S/A/B/C 维度,3×4 十二个格子对一个两个月
  做十来道题的学生大部分恒为 0。

热力图改成一格一周(53 格,周一起算,最后一格是本周)。按天切的话一年 365 格里三百多
格是空的,中职学生一年也就二三十天有提交,整张图看着像没用过。颜色阈值跟着按周重定。

新增三个维度:
- 错在哪里:判完的失败提交按状态码分组。编译错误占大头说明语法不熟,答案错误占大头
  说明是逻辑问题,两种情况老师该给的建议完全不同。
- 几次做对:到首次通过为止提交了几次,分一次过 / 2-3 / 4-6 / 7次以上。原来只有一个
  "平均提交次数",看不出分布。
- 流程图得分:detailsData.flowcharts 早就在下发,但全页一张图都没有,只在解题表格的
  第二个 tab 里列着。

顺带:
- 时间活跃度从"只统计 AC 时间"改成"统计全部提交",星期和时段由后端按东八区聚合。
  只看 AC 的话十来个点撒进 7×4 的格子几乎全是空的,和热力图的时区口径也对不上。
- 两两并排用弹性容器而不是固定两列网格:知识点分布在没有标签时整张卡不渲染,
  固定两列会空掉一半。
- AI 卡片原来有三个条件挂载点(solved>10 在右列、≤10 在全宽行、=0 藏在 Overview
  里面),单列之后收成最后一张无条件的卡。
- DurationChart 右轴的 S/A/B/C 一个刻度都没显示过:轴范围 -0.5~3.5,生成的刻度值是
  -0.5/0.5/1.5/2.5/3.5,拿去索引 gradeOrder 全是 undefined。

契约新增 activity / errors / rankScope / solved[].attempts / durationData[].acceptedCount。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn
This commit is contained in:
2026-09-03 03:08:54 -06:00
parent 69883bd016
commit 4d7be969d9
18 changed files with 759 additions and 1212 deletions

View File

@@ -1,78 +1,60 @@
<template>
<n-spin :show="aiStore.loading.fetching" :delay="50">
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-flex vertical size="large">
<n-flex align="center" justify="space-between">
<n-h3 style="margin: 0">请选择时间范围智能分析学习情况</n-h3>
<n-flex align="center">
<n-input
v-if="userStore.isSuperAdmin"
v-model:value="urlUsername"
placeholder="查看指定用户"
clearable
style="width: 140px"
@change="onUsernameChange"
@clear="onUsernameChange"
/>
<n-select
style="width: 140px"
:options="options"
v-model:value="urlDuration"
/>
</n-flex>
</n-flex>
<Overview />
<n-grid :cols="2" :x-gap="20" :y-gap="20">
<n-gi :span="isDesktop ? 1 : 2">
<DifficultyGradeChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<TagsRadarChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<RankDistributionChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<TimeActivityHeatmap />
</n-gi>
</n-grid>
<SolvedTable />
<n-flex vertical size="large">
<n-flex align="center" justify="space-between">
<n-h3 style="margin: 0">请选择时间范围智能分析学习情况</n-h3>
<n-flex align="center">
<n-input
v-if="userStore.isSuperAdmin"
v-model:value="urlUsername"
placeholder="查看指定用户"
clearable
style="width: 140px"
@change="onUsernameChange"
@clear="onUsernameChange"
/>
<n-select
style="width: 140px"
:options="options"
v-model:value="urlDuration"
/>
</n-flex>
</n-gi>
<n-gi :span="1">
<n-flex vertical size="large">
<Heatmap />
<ProgressChart />
<EfficiencyChart />
<DurationChart />
<AI v-if="aiStore.detailsData.solved.length > 10" />
</n-flex>
</n-gi>
<n-gi :span="2">
<AI
v-if="
aiStore.detailsData.solved.length > 0 &&
aiStore.detailsData.solved.length <= 10
"
/>
</n-gi>
</n-grid>
</n-flex>
<Overview />
<Heatmap />
<DurationChart />
<!-- 用弹性排布而不是两列网格知识点分布在没有标签时整张卡不渲染
固定两列会空掉一半flex 下剩的那张自动占满整行 -->
<n-flex class="pair" :size="20">
<DifficultyGradeChart />
<TagsChart />
</n-flex>
<n-flex class="pair" :size="20">
<ErrorTypeChart />
<AttemptsChart />
</n-flex>
<n-flex class="pair" :size="20">
<TimeActivityHeatmap />
<FlowchartScoreChart />
</n-flex>
<SolvedTable />
<AI />
</n-flex>
</n-spin>
</template>
<script setup lang="ts">
import { useBreakpoints } from "shared/composables/breakpoints"
import { formatISO, sub, type Duration } from "date-fns"
import { useRouteQuery } from "@vueuse/router"
import TagsRadarChart from "./components/TagsRadarChart.vue"
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
import TagsChart from "./components/TagsChart.vue"
import ErrorTypeChart from "./components/ErrorTypeChart.vue"
import AttemptsChart from "./components/AttemptsChart.vue"
import FlowchartScoreChart from "./components/FlowchartScoreChart.vue"
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
import RankDistributionChart from "./components/RankDistributionChart.vue"
import Overview from "./components/Overview.vue"
import Heatmap from "./components/Heatmap.vue"
import ProgressChart from "./components/ProgressChart.vue"
import DurationChart from "./components/DurationChart.vue"
import EfficiencyChart from "./components/EfficiencyChart.vue"
import AI from "./components/AI.vue"
import SolvedTable from "./components/SolvedTable.vue"
import { useAIStore } from "../store/ai"
@@ -81,8 +63,6 @@ import { DURATION_OPTIONS } from "utils/constants"
const aiStore = useAIStore()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const options = [...DURATION_OPTIONS]
const urlUsername = useRouteQuery<string>("username", "")
@@ -120,3 +100,8 @@ watch(
{ immediate: true },
)
</script>
<style scoped>
.pair > :deep(.n-card) {
flex: 1 1 320px;
}
</style>