feat(智能分析): 页面改成每节课一行,一眼看出哪道一次就对、哪道交了很多次、哪道还没做对
Deploy / deploy (push) Canceled after 0s

一年热力图、半年月度图、难度分布、时间活跃度这些对多数学生大半空白的图都去掉了;
右边是差一点的(接着做)、错在哪、知识点;老师看学生能 ‹ › 翻同班下一个人。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 23:20:34 +08:00
1 parent 890e731a1c
commit e4abbc1645
27 files changed
+1343 -1710

No files matched your search

+320 -84
View File
@@ -1,107 +1,343 @@
<template>
<n-spin class="oj-page" :show="aiStore.loading.fetching" :delay="50">
<n-flex vertical :size="20">
<n-flex align="center" justify="space-between">
<h2 class="title">请选择时间范围,智能分析学习情况</h2>
<n-flex align="center">
<n-input
v-if="userStore.isTeacherOrAbove"
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 />
<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 { formatISO, sub, type Duration } from "date-fns"
import {
LEARNING_PERIOD_LABELS,
learningPeriodSchema,
type LearningPeriod,
type LearningReport,
} from "@oj2/contract"
import { useRouteQuery } from "@vueuse/router"
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 Overview from "./components/Overview.vue"
import Heatmap from "./components/Heatmap.vue"
import DurationChart from "./components/DurationChart.vue"
import AI from "./components/AI.vue"
import SolvedTable from "./components/SolvedTable.vue"
import { useAIStore } from "../store/ai"
import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { DURATION_OPTIONS } from "utils/constants"
import { durationFromValue } from "utils/functions"
import UserName from "shared/components/UserName.vue"
import { classLabel } from "oj/submission/utils"
import { parseTime } from "utils/functions"
import { getLearningReport } from "../api"
import AiSummary from "./components/AiSummary.vue"
import ErrorsCard from "./components/ErrorsCard.vue"
import KnowledgeCard from "./components/KnowledgeCard.vue"
import LessonDays from "./components/LessonDays.vue"
import StatStrip from "./components/StatStrip.vue"
import UnsolvedCard from "./components/UnsolvedCard.vue"
import { dayLabel, isDone } from "./utils"
const aiStore = useAIStore()
const theme = useThemeVars()
const userStore = useUserStore()
const options = [...DURATION_OPTIONS]
const { isDesktop } = useBreakpoints()
const urlUsername = useRouteQuery<string>("username", "")
const urlDuration = useRouteQuery<string>("duration", "months:6")
const urlPeriod = useRouteQuery<string>("period", "term")
const period = computed<LearningPeriod>(() => {
const parsed = learningPeriodSchema.safeParse(urlPeriod.value)
return parsed.success ? parsed.data : "term"
})
const PERIODS = Object.entries(LEARNING_PERIOD_LABELS) as [LearningPeriod, string][]
// Initialize store synchronously from URL params before watch fires
aiStore.targetUsername = urlUsername.value
aiStore.duration = urlDuration.value
const subOptions = computed<Duration>(
() => durationFromValue(aiStore.duration) ?? durationFromValue(DURATION_OPTIONS[0].value)!,
/** 老师看别人。学生在地址栏写了别人的名字,后端也只会给他自己的 */
const viewing = computed(() =>
userStore.isTeacherOrAbove && urlUsername.value && urlUsername.value !== userStore.user?.username
? urlUsername.value
: undefined,
)
const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
const end = computed(() => formatISO(new Date()))
const report = ref<LearningReport | null>(null)
const loading = ref(false)
const notFound = ref(false)
/** 这段时间一道没交时,再拿一份「全部」的,说说上一节课和还差的题 */
const before = ref<LearningReport | null>(null)
function onUsernameChange() {
aiStore.targetUsername = urlUsername.value
aiStore.fetchHeatmapData()
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
async function load() {
const want = { period: period.value, username: viewing.value }
loading.value = true
notFound.value = false
before.value = null
try {
const data = await getLearningReport(want.period, want.username)
if (want.period !== period.value || want.username !== viewing.value) return
report.value = data
if (!data.days.length && want.period !== "all")
before.value = await getLearningReport("all", want.username)
} catch {
report.value = null
notFound.value = true
} finally {
loading.value = false
}
}
onMounted(() => {
aiStore.fetchHeatmapData()
watch([period, viewing], load, { immediate: true })
const span = computed(() => {
const r = report.value
if (!r) return ""
if (!r.start) return ""
const from = parseTime(r.start, "M月D日")
const to = parseTime(r.end, "M月D日")
return from === to ? from : `${from} – ${to}`
})
watch(
() => urlDuration.value,
(val) => {
aiStore.duration = val
aiStore.fetchAnalysisData(start.value, end.value, val)
},
{ immediate: true },
)
const emptyHint = computed(() => {
const last = before.value?.days.find((day) => day.lesson.length)
if (!last) return ""
const { date, weekday } = dayLabel(last.day)
return `上一节课是 ${date} ${weekday},做对了 ${last.lesson.filter(isDone).length} / ${last.lesson.length} 道`
})
const longer = computed(() => PERIODS.slice(PERIODS.findIndex(([key]) => key === period.value) + 1))
const searchName = ref("")
watch(viewing, (value) => (searchName.value = value ?? ""), { immediate: true })
function goStudent(username: string | null | undefined) {
if (username) urlUsername.value = username
}
</script>
<template>
<div class="oj-page ai-page">
<div class="toolbar" :class="{ single: !isDesktop }">
<h2>智能分析</h2>
<div v-if="viewing" class="who">
<n-button
:disabled="!report?.neighbors?.prev"
aria-label="上一个学生"
@click="goStudent(report?.neighbors?.prev)"
>
‹
</n-button>
<n-input
v-model:value="searchName"
class="who-input"
placeholder="用户名"
@keyup.enter="goStudent(searchName.trim())"
/>
<n-button
:disabled="!report?.neighbors?.next"
aria-label="下一个学生"
@click="goStudent(report?.neighbors?.next)"
>
›
</n-button>
<span v-if="report?.className" class="muted">{{ classLabel(report.className) }}</span>
</div>
<div class="seg" role="group" aria-label="时间">
<button
v-for="[key, label] in PERIODS"
:key="key"
:class="{ on: period === key }"
@click="urlPeriod = key"
>
{{ label }}
</button>
</div>
<span v-if="isDesktop && span" class="muted">{{ span }}</span>
<span class="grow"></span>
<router-link
v-if="viewing"
:to="{ path: '/submission', query: { username: viewing, exactUsername: '1' } }"
class="link"
>
看<UserName :username="viewing" />的提交 ›
</router-link>
</div>
<n-spin :show="loading" :delay="150">
<n-empty v-if="notFound" description="没有这个用户" class="blank" />
<template v-else-if="report && !report.days.length">
<section class="card blank">
<b>{{ LEARNING_PERIOD_LABELS[period] }}{{ viewing ? "他" : "" }}还没交过题</b>
<span v-if="emptyHint" class="muted">{{ emptyHint }}</span>
<div class="actions">
<n-button v-for="[key, label] in longer" :key="key" @click="urlPeriod = key">
看{{ label }}
</n-button>
</div>
</section>
<section v-if="!viewing && before?.unsolved.length" class="card hint">
<b>还有 {{ before.unsolved.length }} 道差一点的</b>
<span class="muted names">{{
before.unsolved.map((item) => item.title).join("、")
}}</span>
<a href="#" class="link" @click.prevent="urlPeriod = 'all'">去看看 ›</a>
</section>
</template>
<div v-else-if="report" class="body">
<StatStrip :report="report" :teacher="!!viewing" :compact="!isDesktop" />
<div v-if="isDesktop" class="main">
<div class="column">
<LessonDays :days="report.days" :class-name="report.className" :username="viewing" />
<AiSummary :period="period" :username="viewing" />
</div>
<div class="column">
<UnsolvedCard
v-if="report.unsolved.length"
:items="report.unsolved"
:username="viewing"
/>
<ErrorsCard v-if="report.errors.length" :errors="report.errors" />
<KnowledgeCard :knowledge="report.knowledge" />
</div>
</div>
<template v-else>
<UnsolvedCard
v-if="report.unsolved.length"
:items="report.unsolved"
:username="viewing"
/>
<LessonDays
:days="report.days"
:class-name="report.className"
:username="viewing"
compact
/>
<ErrorsCard v-if="report.errors.length" :errors="report.errors" />
<KnowledgeCard :knowledge="report.knowledge" />
<AiSummary :period="period" :username="viewing" compact />
</template>
</div>
</n-spin>
</div>
</template>
<style scoped>
.title {
.ai-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.toolbar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.toolbar h2 {
margin: 0;
font-size: var(--oj-fs-title);
}
.pair > :deep(.n-card) {
flex: 1 1 320px;
.toolbar.single {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.toolbar.single .seg button {
flex: 1;
}
.who {
display: flex;
align-items: center;
gap: 6px;
}
.who-input {
width: 160px;
}
.seg {
display: inline-flex;
height: var(--oj-ctrl-h);
border: 1px solid v-bind("theme.borderColor");
border-radius: 4px;
overflow: hidden;
}
.seg button {
padding: 0 14px;
border: 0;
border-left: 1px solid v-bind("theme.borderColor");
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor2");
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
white-space: nowrap;
}
.seg button:first-child {
border-left: 0;
}
.seg button.on {
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-weight: 600;
}
.grow {
flex: 1;
}
.muted {
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
}
.link {
font-size: var(--oj-fs-sec);
color: #18a058;
text-decoration: none;
white-space: nowrap;
}
.body,
.column {
display: flex;
flex-direction: column;
gap: 16px;
}
.main {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 16px;
align-items: start;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
.blank {
padding: 40px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.blank b {
font-size: 18px;
}
.actions {
display: flex;
gap: 8px;
}
.hint {
margin-top: 16px;
padding: 12px 18px;
display: flex;
align-items: center;
gap: 10px;
border-color: rgba(240, 160, 32, 0.4);
background: rgba(240, 160, 32, 0.06);
font-size: var(--oj-fs-sec);
}
.names {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
-88
View File
@@ -1,88 +0,0 @@
<template>
<n-card size="small">
<template #header>
<div class="cool-title">
<span class="title-text">AI 帮你分析</span>
</div>
</template>
<n-spin :show="aiStore.loading.ai" :delay="50">
<n-flex align="center" justify="center" class="container">
<n-button
v-if="!aiStore.mdContent && !aiStore.loading.ai"
type="primary"
size="large"
:loading="aiStore.loading.fetching"
@click="handleAnalyze"
>
<template #icon>
<Icon icon="ph:sparkle" />
</template>
开始分析
</n-button>
<Markdown v-else :content="aiStore.mdContent" />
</n-flex>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import Markdown from "shared/components/Markdown.vue"
import { Icon } from "@iconify/vue"
const aiStore = useAIStore()
async function handleAnalyze() {
if (aiStore.loading.fetching || aiStore.loading.ai) {
return
}
if (aiStore.pinnedReport) {
await aiStore.simulatePinnedStream()
} else {
await aiStore.fetchAIAnalysis()
}
}
onMounted(async () => {
if (!aiStore.targetUsername) {
await aiStore.fetchPinnedReport()
}
})
</script>
<style scoped>
.cool-title {
position: relative;
padding: 8px 0;
}
.title-text {
font-size: 16px;
font-weight: 700;
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.8px;
position: relative;
z-index: 2;
animation: gradient-flow 3s ease infinite;
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 只按钮那一屏的时候别留一大片空白;出内容后高度由内容自己撑,
这里只是给加载中的 spin 一点地方 */
.container {
min-height: 120px;
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { LearningPeriod } from "@oj2/contract"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import Markdown from "shared/components/Markdown.vue"
import { parseTime } from "utils/functions"
import { streamAI } from "utils/stream"
import { getLatestAnalysis } from "../../api"
/**
* AI 小结。进来先拿这个时间段上次生成的那份(老师钉住的优先),没有才出按钮 ——
* 一份小结要等十几秒、也要花钱,不在进页面时自动生成。
*/
const props = defineProps<{
period: LearningPeriod
username?: string
/** 手机:默认只露前几行 */
compact?: boolean
}>()
const theme = useThemeVars()
const content = ref("")
const createdAt = ref("")
const pinned = ref(false)
const generating = ref(false)
const waiting = ref(false)
const failed = ref("")
const expanded = ref(false)
let controller: AbortController | null = null
async function load() {
controller?.abort()
generating.value = false
failed.value = ""
expanded.value = false
const period = props.period
const username = props.username
const record = await getLatestAnalysis(period, username).catch(() => null)
if (period !== props.period || username !== props.username) return
content.value = record?.analysis ?? ""
createdAt.value = record?.createTime ?? ""
pinned.value = record?.isPinned ?? false
}
watch(() => [props.period, props.username], load, { immediate: true })
onBeforeUnmount(() => controller?.abort())
async function generate() {
controller?.abort()
const current = new AbortController()
controller = current
generating.value = true
waiting.value = true
failed.value = ""
content.value = ""
pinned.value = false
expanded.value = true
try {
await streamAI(
"ai/analysis",
{ period: props.period, username: props.username || undefined },
{
signal: current.signal,
onDelta(delta) {
waiting.value = false
content.value += delta
},
},
)
createdAt.value = new Date().toISOString()
} catch (error) {
if (current.signal.aborted) return
failed.value = (error as Error).message || "AI 服务暂时不可用,请稍后再试"
} finally {
if (controller === current) {
generating.value = false
waiting.value = false
}
}
}
const folded = computed(() => props.compact && !expanded.value && !generating.value)
</script>
<template>
<section class="card">
<h3><Icon icon="ph:sparkle" />AI 帮你分析</h3>
<template v-if="content">
<div class="body" :class="{ folded }">
<Markdown :content="content" />
</div>
<div v-if="!generating" class="foot">
<a v-if="folded" href="#" @click.prevent="expanded = true">展开全文 ▾</a>
<span v-if="createdAt"
>{{ parseTime(createdAt, "M月D日") }} 生成{{ pinned ? " · 老师置顶" : "" }}</span
>
<a href="#" @click.prevent="generate">重新分析</a>
</div>
</template>
<n-spin v-else-if="waiting" size="small">
<div class="waiting">正在看{{ username ? "他" : "你" }}的做题记录…</div>
</n-spin>
<div v-else class="empty">
<p>让 AI 按这些数据,写一段{{ username ? "给他" : "给你" }}的小结和下一步建议</p>
<n-button type="primary" @click="generate">
<template #icon><Icon icon="ph:sparkle" /></template>
AI 帮我分析
</n-button>
</div>
<p v-if="failed" class="failed">{{ failed }}</p>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid rgba(107, 71, 194, 0.3);
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--oj-fs-h2);
color: #7c5cd0;
}
.body.folded {
max-height: 150px;
overflow: hidden;
/* Chrome 105 只认带前缀的,不带前缀的到 120 才有 */
-webkit-mask-image: linear-gradient(#000 60%, transparent);
mask-image: linear-gradient(#000 60%, transparent);
}
.foot {
display: flex;
gap: 12px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.foot a {
color: #18a058;
text-decoration: none;
}
.waiting {
height: 60px;
}
.empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.empty p,
.failed {
margin: 0;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor2");
}
.failed {
color: v-bind("theme.errorColor");
}
</style>
@@ -1,89 +0,0 @@
<template>
<n-card title="几次做对" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 通过前提交了几次,看有没有在死磕 </n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// detailsData.attempts 是后端算的「每道题到首次通过为止的提交次数」,分档放在前端,
// 想调档位不用改契约。逐题的题名不在这里 —— 明细是分页拿的,别为了 tooltip 把全量拉回来
const BUCKETS = [
{ label: "一次过", color: "#18A058", min: 1, max: 1 },
{ label: "2-3 次", color: "#2080F0", min: 2, max: 3 },
{ label: "4-6 次", color: "#F0A020", min: 4, max: 6 },
{ label: "7 次以上", color: "#D03050", min: 7, max: Infinity },
]
const buckets = computed(() =>
BUCKETS.map((bucket) => ({
...bucket,
count: aiStore.detailsData.attempts.filter(
(value) => value >= bucket.min && value <= bucket.max,
).length,
})),
)
const show = computed(() => aiStore.detailsData.attempts.length > 0)
const data = computed(() => ({
labels: buckets.value.map((bucket) => bucket.label),
datasets: [
{
label: "题目数量",
data: buckets.value.map((bucket) => bucket.count),
backgroundColor: buckets.value.map((bucket) => bucket.color),
borderColor: buckets.value.map((bucket) => bucket.color),
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 64,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "题目数量" },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const value = Number(ctx.parsed.y)
const total = aiStore.detailsData.attempts.length
const percent = total ? (value / total) * 100 : 0
return `${value} 道题(占 ${percent.toFixed(0)}%)`
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
@@ -1,72 +0,0 @@
<template>
<n-card title="难度分布" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 看看简单题和难题各做了多少 </n-text>
</template>
<div style="height: 300px">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// 难度和等级的顺序(后端返回的是中文)
const difficultyOrder = ["简单", "中等", "困难"]
const difficultyColors = ["#18A058", "#F0A020", "#D03050"]
// 只按难度分。原来还往里叠了一层 S/A/B/C 等级,3×4 十二个格子,
// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有。
// 直接读后端的 difficulty 聚合 —— 逐题列表现在是分页拿的,前端手上没有全量
const counts = computed(() =>
difficultyOrder.map((name) => aiStore.detailsData.difficulty[name] ?? 0),
)
const show = computed(() => aiStore.detailsData.solvedCount > 0)
const data = computed(() => ({
labels: difficultyOrder,
datasets: [
{
label: "完成题目数",
data: counts.value,
backgroundColor: difficultyColors,
borderColor: difficultyColors,
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 64,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "题目数量" },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `完成 ${ctx.parsed.y} 道题`,
},
},
},
}))
</script>
@@ -1,171 +0,0 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 做题量和提交质量的变化 </n-text>
</template>
<div class="chart">
<Chart type="bar" :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip,
Legend,
Colors,
LineController,
BarController,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
import { parseTime } from "utils/functions"
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip,
Legend,
Colors,
LineController,
BarController,
)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的每周情况"
} else if (aiStore.duration === "months:6") {
return "过去半年的每月情况"
} else if (aiStore.duration === "years:1") {
return "过去一年的每月情况"
} else {
return "过去四周的情况"
}
})
const show = computed(() => aiStore.durationData.length > 0)
// 这一张顶掉了原来的三张(进步曲线 / 提交效率 / 周期综合)—— 它们读的是同一个
// durationData,半年视图统共就 6 个桶、四个字段,没有必要摊成三张卡六条曲线。
// 等级放进 tooltip 不再单独占一条轴:S/A/B/C 是四档离散值,连成折线读不出东西,
// 而且逐题的等级在下面的解题表格里本来就有
const data = computed<ChartData<"bar" | "line">>(() => ({
labels: aiStore.durationData.map((duration) =>
[parseTime(duration.start, "M月D日"), parseTime(duration.end, "M月D日")].join("~"),
),
datasets: [
{
type: "bar",
label: "完成题目数",
data: aiStore.durationData.map((duration) => duration.problemCount),
yAxisID: "y",
order: 2,
},
{
type: "bar",
label: "总提交次数",
data: aiStore.durationData.map((duration) => duration.submissionCount),
yAxisID: "y",
order: 2,
},
{
type: "line",
label: "AC率",
// 没有提交的周期给 null 而不是 0,配合 spanGaps: false 断开,
// 否则空档会被画成「AC率 0%」,看着像交了一堆全错
data: aiStore.durationData.map((duration) =>
duration.submissionCount > 0
? (duration.acceptedCount / duration.submissionCount) * 100
: null,
),
spanGaps: false,
tension: 0.4,
yAxisID: "y1",
order: 1,
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
}))
const options = computed<ChartOptions<"bar" | "line">>(() => ({
interaction: {
intersect: false,
mode: "index",
},
maintainAspectRatio: false,
scales: {
x: {
grid: { display: false },
},
y: {
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "数量" },
beginAtZero: true,
},
y1: {
type: "linear",
position: "right",
min: 0,
max: 100,
ticks: { callback: (v) => `${Number(v).toFixed(0)}%` },
title: { display: true, text: "AC率" },
grid: { display: false },
},
},
plugins: {
legend: {
display: true,
position: "bottom",
labels: {
boxWidth: 12,
padding: 8,
font: { size: 11 },
},
},
title: { display: false },
tooltip: {
callbacks: {
label: (ctx: TooltipItem<"bar" | "line">) => {
const dsLabel = ctx.dataset.label || ""
if (ctx.dataset.label === "AC率") {
return `${dsLabel}: ${Number(ctx.parsed.y).toFixed(1)}%`
}
return `${dsLabel}: ${ctx.formattedValue}`
},
footer: (items: TooltipItem<"bar" | "line">[]) => {
const index = items[0]?.dataIndex
const bucket = index === undefined ? undefined : aiStore.durationData[index]
if (!bucket) return ""
const lines = [`本期等级: ${bucket.grade || "无"}`]
if (bucket.submissionCount > 0) {
lines.push(`通过 ${bucket.acceptedCount} 次 / 共提交 ${bucket.submissionCount} 次`)
}
return lines
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 320px;
width: 100%;
}
</style>
@@ -1,105 +0,0 @@
<template>
<n-card title="错在哪里" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">没通过的提交都是什么错</n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
import { JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// 判题状态码是落库的值,名字统一从 JUDGE_STATUS 取,不在这里另抄一份中文
const COLORS: Record<string, string> = {
"-2": "#F0A020", // 编译失败
"-1": "#D03050", // 答案错误
"1": "#7C4DFF", // 运行超时
"2": "#7C4DFF",
"3": "#2080F0", // 内存超限
"4": "#E88080", // 运行错误
"5": "#909399", // 系统错误
"8": "#18A058", // 部分正确
}
// 同一个中文名可能对应多个状态码(1 和 2 都叫「运行超时」),按名字合并
const grouped = computed(() => {
const byName = new Map<string, { count: number; color: string }>()
for (const item of aiStore.detailsData.errors) {
const name =
JUDGE_STATUS[String(item.result) as unknown as SUBMISSION_RESULT]?.name ??
`状态 ${item.result}`
const seen = byName.get(name)
if (seen) seen.count += item.count
else
byName.set(name, {
count: item.count,
color: COLORS[String(item.result)] ?? "#909399",
})
}
return [...byName].sort((a, b) => b[1].count - a[1].count)
})
const show = computed(() => grouped.value.length > 0)
const data = computed(() => ({
labels: grouped.value.map(([name]) => name),
datasets: [
{
label: "提交次数",
data: grouped.value.map(([, item]) => item.count),
backgroundColor: grouped.value.map(([, item]) => item.color),
borderColor: grouped.value.map(([, item]) => item.color),
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 28,
},
],
}))
const total = computed(() => grouped.value.reduce((sum, [, item]) => sum + item.count, 0))
const options = computed<ChartOptions<"bar">>(() => ({
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "提交次数" },
},
y: { grid: { display: false } },
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const value = Number(ctx.parsed.x)
const percent = total.value ? (value / total.value) * 100 : 0
return `${value} 次(占没通过的 ${percent.toFixed(0)}%)`
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { LearningReport } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { statusName } from "../utils"
/** 没通过的提交按结果分;错得最多的那种下面给一个检查办法 */
const props = defineProps<{ errors: LearningReport["errors"] }>()
const theme = useThemeVars()
/** 两个超时码合成一行 */
const rows = computed(() => {
const merged = new Map<string, number>()
for (const item of props.errors) {
const name = statusName(item.result) || "其他"
merged.set(name, (merged.get(name) ?? 0) + item.count)
}
return [...merged].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count }))
})
const total = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0))
const top = computed(() => rows.value[0]?.count ?? 1)
const COLORS: Record<string, string> = { 答案错误: "#d03050", 编译失败: "#e7a33e" }
const TIPS: Record<string, string> = {
答案错误: "先拿题目里的例子自己跑一遍,输出要和例子一字不差",
编译失败: "看报错指的是第几行,多半是括号、冒号、引号没配对",
运行时错误: "看报错说的是第几行、什么错,再回到那一行",
运行超时: "看看循环能不能停下来",
}
const tip = computed(() => TIPS[rows.value[0]?.name ?? ""])
</script>
<template>
<section class="card">
<h3>
错在哪<small>没通过的 {{ total }} 次</small>
</h3>
<div v-for="row in rows" :key="row.name" class="row">
<span class="name">{{ row.name }}</span>
<span class="track">
<span
class="bar"
:style="{
width: `${(row.count / top) * 100}%`,
background: COLORS[row.name] ?? '#9aa3ad',
}"
></span>
</span>
<span class="count">{{ row.count }} 次</span>
</div>
<div v-if="tip" class="tip">{{ tip }}</div>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
font-size: var(--oj-fs-h2);
display: flex;
align-items: baseline;
gap: 8px;
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.row {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--oj-fs-sec);
}
.name {
width: 74px;
flex-shrink: 0;
}
.track {
flex: 1;
height: 10px;
border-radius: 2px 5px 5px 2px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.bar {
display: block;
height: 100%;
min-width: 3px;
border-radius: 2px 5px 5px 2px;
}
.count {
width: 52px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.tip {
padding: 8px 10px;
border-radius: 6px;
background: v-bind("theme.actionColor");
font-size: var(--oj-fs-sec);
line-height: 22px;
}
</style>
@@ -1,92 +0,0 @@
<template>
<n-card title="流程图得分" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">每题的最高分和平均分</n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// detailsData.flowcharts 早就在下发了,但全页一张图都没有,只在解题表格的
// 第二个 tab 里列成表格。这里直接画出来,不用改后端和契约
const items = computed(() =>
[...aiStore.detailsData.flowcharts].sort(
(a, b) => b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
),
)
const show = computed(() => items.value.length > 0)
const data = computed(() => ({
labels: items.value.map((item) => `${item.problemId} ${item.problemTitle}`),
datasets: [
{
label: "最高分",
data: items.value.map((item) => item.bestScore),
backgroundColor: "#18A058",
borderColor: "#18A058",
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 22,
},
{
label: "平均分",
data: items.value.map((item) => item.avgScore),
backgroundColor: "rgba(99, 102, 241, 0.75)",
borderColor: "rgb(99, 102, 241)",
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 22,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
max: 100,
title: { display: true, text: "得分" },
},
y: { grid: { display: false } },
},
plugins: {
legend: {
display: true,
position: "bottom",
labels: { boxWidth: 12, padding: 8, font: { size: 11 } },
},
tooltip: {
callbacks: {
afterBody: (ctx) => {
const item = items.value[ctx[0]!.dataIndex]
if (!item) return ""
return `提交 ${item.submissionCount} 次${item.bestGrade ? `,最好等级 ${item.bestGrade}` : ""}`
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
-55
View File
@@ -1,55 +0,0 @@
<template>
<div align="center" style="display: inline-flex; margin: 0 10px">
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16" style="cursor: help">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
/>
</svg>
</n-icon>
</template>
<div style="max-width: 300px; line-height: 1.4">
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
<div>使用加权平均方法计算综合等级:</div>
<div>• S级 = 4分,A级 = 3分,B级 = 2分,C级 = 1分</div>
<div>• 根据平均分数确定最终等级:</div>
<div>- S级:≥3.5分</div>
<div>- A级:2.5-3.5分</div>
<div>- B级:1.5-2.5分</div>
<div>- C级:<1.5分</div>
</div>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import type { Grade } from "utils/types"
const props = defineProps<{
// 空串是「无评级」,四张图都不渲染 —— 后端没有可用数据时会下发它
grade: Grade
}>()
</script>
<style scoped>
img {
animation: shake 0.5s infinite;
width: 30px;
height: 30px;
}
@keyframes shake {
0% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-10px) scale(1.1);
}
100% {
transform: translateY(0) scale(1);
}
}
</style>
-258
View File
@@ -1,258 +0,0 @@
<template>
<n-card title="过去一年的提交热力图" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 每格一周,激励持续学习 </n-text>
</template>
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<div
class="heatmap-container"
ref="containerRef"
:style="{
'--cell-stroke': cellStroke,
'--cell-stroke-hover': cellStrokeHover,
}"
>
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
preserveAspectRatio="xMinYMin meet"
class="heatmap-svg"
>
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
<text :x="label.x" :y="10" class="label" font-size="10">
{{ label.text }}
</text>
</g>
<g :transform="`translate(0, ${MONTH_HEIGHT})`">
<rect
v-for="(cell, i) in cells"
:key="i"
:x="cell.x"
:y="0"
:width="CELL_SIZE"
:height="CELL_HEIGHT"
:fill="cell.color"
class="cell"
rx="3"
@mouseenter="(e) => showTooltip(e, cell)"
@mouseleave="hideTooltip"
/>
</g>
</svg>
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
<div class="tooltip-date">{{ tooltip.date }}</div>
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
{{ tooltip.text }}
</div>
</div>
</div>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { parseTime, zonedParts } from "utils/functions"
import { useChartTheme } from "shared/composables/chartTheme"
const aiStore = useAIStore()
const { isDark } = useChartTheme()
const containerRef = useTemplateRef<HTMLElement>("containerRef")
const CELL_SIZE = 22
// 一格一周之后只剩一行,格子做成竖长条,卡片不至于扁成一条缝
const CELL_HEIGHT = 34
const CELL_GAP = 4
const CELL_TOTAL = CELL_SIZE + CELL_GAP
const MONTH_HEIGHT = 18
const RIGHT_PADDING = 5
// 深色下空格子不能再用接近白的 #ebedf0,整张图会变成一片发亮的方块
const LIGHT_COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
const DARK_COLORS = ["#22272e", "#0e4429", "#006d32", "#26a641", "#39d353"]
const COLORS = computed(() => (isDark.value ? DARK_COLORS : LIGHT_COLORS))
const cellStroke = computed(() =>
isDark.value ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.05)",
)
const cellStrokeHover = computed(() =>
isDark.value ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.3)",
)
// 阈值按「一周」定,不是按一天。按天的老阈值(>7 就到顶)放到周上,
// 稍微认真做几天题就全是最深的一档,看不出差别
const getColor = (count: number) => {
const palette = COLORS.value
if (count === 0) return palette[0]
if (count <= 3) return palette[1]
if (count <= 8) return palette[2]
if (count <= 15) return palette[3]
return palette[4]
}
// 一格一周,横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交,
// 365 格里三百多格空着,整张图看着像没用过。
//
// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,年月日按东八区取(`zonedParts`),
// 不用浏览器本地的 `getMonth()` / `getDate()`。东八区没有夏令时,周日就是加 6 天。
const cells = computed(() =>
aiStore.heatmapData.map((item, i) => {
const start = new Date(item.timestamp)
const parts = zonedParts(start)
return {
start,
end: new Date(start.getTime() + 6 * 86_400_000),
month: parts?.month ?? 1,
day: parts?.day ?? 1,
count: item.value,
color: getColor(item.value),
x: i * CELL_TOTAL,
}
}),
)
const monthLabels = computed(() => {
const labels: { text: string; x: number }[] = []
let lastMonth = -1
cells.value.forEach((cell, i) => {
const month = cell.month
if (month !== lastMonth) {
// 第一格所在的月往往只露出小半个月,标签会和下一个月挤在一起,跳过
if (i > 0 || cell.day <= 7) {
labels.push({ text: `${month}月`, x: cell.x })
}
lastMonth = month
}
})
return labels
})
const svgWidth = computed(() => cells.value.length * CELL_TOTAL + RIGHT_PADDING)
const svgHeight = computed(() => MONTH_HEIGHT + CELL_HEIGHT)
interface Cell {
start: Date
end: Date
count: number
color: string
x: number
}
const tooltip = ref<{
x: number
y: number
date: string
text: string
count: number
} | null>(null)
const tooltipStyle = computed(() => ({
left: `${tooltip.value?.x}px`,
top: `${tooltip.value?.y}px`,
}))
const getTooltipText = (count: number) => (count === 0 ? "这周没有提交" : `这周提交了 ${count} 次`)
const showTooltip = (e: MouseEvent, cell: Cell) => {
const rect = (e.target as HTMLElement).getBoundingClientRect()
const containerRect = containerRef.value?.getBoundingClientRect()
if (containerRect) {
tooltip.value = {
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top - 10,
date: `${parseTime(cell.start, "M月D日")} ~ ${parseTime(cell.end, "M月D日")}`,
text: getTooltipText(cell.count),
count: cell.count,
}
}
}
const hideTooltip = () => {
tooltip.value = null
}
</script>
<style scoped>
.heatmap-container {
width: 100%;
padding: 10px 0;
position: relative;
}
.heatmap-svg {
width: 100%;
height: auto;
display: block;
}
.label {
fill: currentColor;
opacity: 0.7;
}
.cell {
cursor: pointer;
transition: all 0.2s ease;
stroke: var(--cell-stroke);
stroke-width: 0.5;
}
.cell:hover {
stroke: var(--cell-stroke-hover);
stroke-width: 1.5;
filter: brightness(0.9);
}
.tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fade-in 0.2s ease;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
}
.tooltip-date {
font-weight: 500;
margin-bottom: 2px;
}
.tooltip-count {
opacity: 0.6;
}
.tooltip-count.active {
color: #7bc96f;
opacity: 0.9;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translate(-50%, calc(-100% - 5px));
}
to {
opacity: 1;
transform: translate(-50%, -100%);
}
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { LearningReport } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
/** 全部知识点:做对过的标绿写几道,做过没做对的写「还没做对」,没碰过的虚线。点进题目列表按它筛 */
const props = defineProps<{ knowledge: LearningReport["knowledge"] }>()
const theme = useThemeVars()
const touched = computed(() => props.knowledge.filter((item) => item.tried).length)
</script>
<template>
<section class="card">
<h3>
知识点<small>做过 {{ touched }} / {{ knowledge.length }} 个</small>
</h3>
<div class="grid">
<router-link
v-for="item in knowledge"
:key="item.name"
:to="{ path: '/problem', query: { tag: item.name } }"
class="cell"
:class="item.solved ? 'solved' : item.tried ? 'tried' : 'untouched'"
>
<span class="name">{{ item.name }}</span>
<span class="sub">
{{ item.solved ? `做对 ${item.solved} 道` : item.tried ? "还没做对" : "还没做过" }}
</span>
</router-link>
</div>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
font-size: var(--oj-fs-h2);
display: flex;
align-items: baseline;
gap: 8px;
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.cell {
display: flex;
flex-direction: column;
gap: 1px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 6px;
text-decoration: none;
color: v-bind("theme.textColor1");
min-width: 0;
}
.name {
font-size: var(--oj-fs-sec);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 12px;
}
.solved {
background: rgba(24, 160, 88, 0.12);
}
.solved .name {
font-weight: 600;
}
.solved .sub {
color: #18a058;
}
.tried {
background: rgba(240, 160, 32, 0.12);
}
.tried .sub {
color: #b86e00;
}
.untouched {
border: 1px dashed v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import type { LearningDay } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import ProblemChip from "./ProblemChip.vue"
import { dayLabel, isDone } from "../utils"
/** 一天一行,新的在前:先是班上这节课的题,再是这天自己练的 */
const props = defineProps<{
days: LearningDay[]
className: string | null
username?: string
/** 手机:日期单独一行,题目块在下面折行 */
compact?: boolean
}>()
const theme = useThemeVars()
/** 一天自己练的超过这么多就先收起来;做得多的学生一天能自己刷二十几道 */
const OWN_CAP = 10
/** 「全部」能有上百天,先列这么多天 */
const DAY_CAP = 12
const openOwn = ref(new Set<string>())
const showAll = ref(false)
watch(
() => props.days,
() => {
openOwn.value = new Set()
showAll.value = false
},
)
const shown = computed(() => (showAll.value ? props.days : props.days.slice(0, DAY_CAP)))
const you = computed(() => (props.username ? "他" : "你"))
const subtitle = computed(() =>
props.className
? `${classLabel(props.className)}课上做的题,加上${you.value}自己练的`
: `${you.value}做过的题`,
)
function ownOf(day: LearningDay) {
return openOwn.value.has(day.day) ? day.own : day.own.slice(0, OWN_CAP)
}
function expand(day: string) {
openOwn.value = new Set([...openOwn.value, day])
}
</script>
<template>
<section class="card" :class="{ compact }">
<h3>
每节课<small>{{ subtitle }}</small>
</h3>
<div v-for="day in shown" :key="day.day" class="day">
<div class="when">
<b>{{ dayLabel(day.day).date }}</b>
<span>{{ dayLabel(day.day).weekday }}</span>
<span v-if="compact && day.lesson.length" class="done">
做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }}
</span>
</div>
<div class="groups">
<div v-if="day.lesson.length" class="chips">
<ProblemChip
v-for="item in day.lesson"
:key="item.displayId"
:item="item"
in-lesson
:username="username"
/>
</div>
<div v-if="day.own.length" class="chips">
<span class="own">自己练</span>
<ProblemChip
v-for="item in ownOf(day)"
:key="item.displayId"
:item="item"
:in-lesson="false"
:username="username"
/>
<button
v-if="day.own.length > OWN_CAP && !openOwn.has(day.day)"
class="more"
@click="expand(day.day)"
>
还有 {{ day.own.length - OWN_CAP }} 道 ▾
</button>
</div>
</div>
<div v-if="!compact" class="done">
<template v-if="day.lesson.length">
做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }}
</template>
</div>
</div>
<button v-if="!showAll && days.length > DAY_CAP" class="earlier" @click="showAll = true">
更早的 {{ days.length - DAY_CAP }} 天 ▾
</button>
</section>
</template>
<style scoped>
.card {
padding: 14px 20px 6px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0 0 8px;
font-size: var(--oj-fs-h2);
display: flex;
align-items: baseline;
gap: 10px;
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.day {
display: flex;
gap: 12px;
padding: 10px 0;
border-top: 1px solid v-bind("theme.dividerColor");
}
.when {
width: 92px;
flex-shrink: 0;
line-height: 30px;
white-space: nowrap;
}
.when b {
font-size: var(--oj-fs-body);
margin-right: 4px;
}
.when span {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.groups {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.own {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
margin-right: 2px;
}
.done {
width: 72px;
flex-shrink: 0;
text-align: right;
line-height: 30px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.more,
.earlier {
height: 30px;
padding: 0 12px;
border: 1px dashed v-bind("theme.borderColor");
border-radius: 6px;
background: transparent;
color: #18a058;
font: inherit;
font-size: var(--oj-fs-meta);
cursor: pointer;
}
.earlier {
width: 100%;
margin: 4px 0 10px;
}
.compact {
padding: 12px 14px 4px;
}
.compact .day {
flex-direction: column;
gap: 6px;
}
.compact .when {
width: auto;
display: flex;
align-items: baseline;
gap: 6px;
line-height: 22px;
}
.compact .when .done {
margin-left: auto;
width: auto;
line-height: 22px;
}
</style>
@@ -1,53 +0,0 @@
<template>
<n-alert :show-icon="false" type="success" v-if="aiStore.detailsData.solvedCount">
<span>{{ durationLabel }},</span>
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solvedCount }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contestCount > 0">
,并且参加
<b class="charming"> {{ aiStore.detailsData.contestCount }} </b>
次比赛
</span>
<span>,综合评价给到</span>
<Grade :grade="aiStore.detailsData.grade" />
<span>{{ greeting }}</span>
</n-alert>
<n-alert v-else type="error" title="你还没有完成任何题目">
开始解题,看看你的学习能力吧!
</n-alert>
</template>
<script lang="ts" setup>
import Grade from "./Grade.vue"
import { parseTime } from "utils/functions"
import { useAIStore } from "oj/store/ai"
const aiStore = useAIStore()
const durationLabel = computed(() => {
if (aiStore.duration.includes("hours")) {
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
} else if (aiStore.duration.includes("days")) {
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
} else if (aiStore.duration.includes("weeks") || aiStore.duration.includes("months")) {
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
} else {
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
}
})
const greeting = computed(() => {
return {
"": "还没有足够的数据来评级",
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",
C: "你还需要努力!",
}[aiStore.detailsData.grade]
})
</script>
<style scoped>
.charming {
font-size: 1.2rem;
}
</style>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { LearningItem } from "@oj2/contract"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { statusName } from "../utils"
/**
* 一道题一块:绿勾一次就对、黄色交几次才对、红色没做对、虚线没交。
* 指上去看细节(班上第几个做对、最后一次错在哪),点进去学生是做题、老师是看提交。
*/
const props = defineProps<{
item: LearningItem
/** 课上的题才说「班上第几个做对」;自己练的题同学大多没做,排第几没意义 */
inLesson: boolean
/** 老师在看这个学生:称呼用「他」,点进去看他的提交 */
username?: string
}>()
const theme = useThemeVars()
const you = computed(() => (props.username ? "他" : "你"))
const problemLink = computed(() => `/problem/${props.item.displayId}`)
const submissionLink = computed(() => ({
path: "/submission",
query: props.username
? { problem: props.item.displayId, username: props.username, exactUsername: "1" }
: { problem: props.item.displayId, myself: "1" },
}))
const detail = computed(() => {
const item = props.item
const rank =
props.inLesson && item.rank && item.classSolved
? `班上第 ${item.rank} 个做对(${item.classSolved} 人做对)`
: ""
if (item.status === "first") return ["一次就对", rank]
if (item.status === "tries") return [`交了 ${item.attempts} 次才对`, rank]
if (item.status === "fail") {
const last = statusName(item.lastResult)
return [`交了 ${item.attempts} 次还没做对`, last ? `最后一次${last}` : ""]
}
return [
`这节课的题,${you.value}还没交`,
item.classSolved ? `班上 ${item.classSolved} 人做对` : "",
]
})
</script>
<template>
<n-popover trigger="hover" :delay="200" :show-arrow="false" placement="bottom-start">
<template #trigger>
<router-link :to="username ? submissionLink : problemLink" class="chip" :class="item.status">
<Icon
v-if="item.status === 'first'"
icon="ph:check-bold"
class="check"
aria-label="一次就对"
/>
<span class="title">{{ item.title }}</span>
<span v-if="item.status === 'tries'" class="count">交 {{ item.attempts }} 次</span>
<span v-else-if="item.status === 'fail'" class="count"
>没做对 · {{ item.attempts }} 次</span
>
<span v-else-if="item.status === 'none'" class="count">没交</span>
</router-link>
</template>
<div class="tip">
<span>{{ detail.filter(Boolean).join(" · ") }}</span>
<router-link v-if="item.status !== 'none'" :to="submissionLink"
>看{{ you }}的提交 ›</router-link
>
<router-link v-else-if="!username" :to="problemLink">去做 ›</router-link>
</div>
</n-popover>
</template>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
height: 30px;
box-sizing: border-box;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 6px;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor1");
text-decoration: none;
white-space: nowrap;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
}
.count {
flex-shrink: 0;
font-size: var(--oj-fs-meta);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.check {
flex-shrink: 0;
color: #18a058;
}
.first {
background: rgba(24, 160, 88, 0.12);
}
.tries {
background: rgba(240, 160, 32, 0.15);
}
.tries .count {
color: #b86e00;
}
.fail {
background: rgba(208, 48, 80, 0.09);
border-color: rgba(208, 48, 80, 0.3);
}
.fail .count {
color: #c0283f;
}
.none {
border: 1px dashed v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
}
.chip:hover {
filter: brightness(0.97);
}
.tip {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--oj-fs-meta);
}
.tip a {
color: #18a058;
text-decoration: none;
white-space: nowrap;
}
</style>
@@ -1,166 +0,0 @@
<template>
<n-tabs animated v-if="hasSolved && flowcharts.length">
<n-tab-pane name="代码提交">
<n-data-table
remote
striped
:data="aiStore.solvedRows"
:columns="columns"
:loading="aiStore.loading.solved"
:pagination="solvedPagination"
@update:page="aiStore.fetchSolved"
/>
</n-tab-pane>
<n-tab-pane name="流程图提交">
<n-data-table
striped
:data="flowcharts"
:columns="flowchartsColumns"
:pagination="paginationFor(flowcharts)"
/>
</n-tab-pane>
</n-tabs>
<n-data-table
v-else-if="hasSolved"
remote
striped
:data="aiStore.solvedRows"
:columns="columns"
:loading="aiStore.loading.solved"
:pagination="solvedPagination"
@update:page="aiStore.fetchSolved"
/>
<n-data-table
v-else-if="flowcharts.length"
striped
:data="flowcharts"
:columns="flowchartsColumns"
:pagination="paginationFor(flowcharts)"
/>
</template>
<script lang="ts" setup>
import { NButton, NTooltip } from "naive-ui"
import TagTitle from "./TagTitle.vue"
import type { FlowchartSummary, SolvedProblem } from "utils/types"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
const router = useRouter()
const aiStore = useAIStore()
const hasSolved = computed(() => aiStore.detailsData.solvedCount > 0)
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
// 代码提交这张走服务端分页:翻页只拉一页,不把整年的题一次发给浏览器。
// 流程图那张仍然是全量下发的(一个 OJ 的流程图题就那么几道),本地分页即可。
// 行数不够一页时不显示分页器,和 ExerciseAttempts.vue 一致
const solvedPagination = computed(() =>
aiStore.solvedTotal > aiStore.solvedPageSize
? {
page: aiStore.solvedPage,
pageSize: aiStore.solvedPageSize,
itemCount: aiStore.solvedTotal,
}
: false,
)
function paginationFor(rows: unknown[]) {
const pageSize = aiStore.solvedPageSize
return rows.length > pageSize ? { pageSize } : false
}
const columns: DataTableColumn<SolvedProblem>[] = [
{
title: "完成的题目",
key: "problem.title",
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
if (row.problem.contestId) {
router.push("/contest/" + row.problem.contestId + "/problem/" + row.problem.displayId)
} else {
router.push("/problem/" + row.problem.displayId)
}
},
},
() => {
if (row.problem.contestId) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.displayId + " " + row.problem.title
}
},
),
},
{
// 用后端下发的 rankScope,不要看 className 有没有值:班里只有一个人时
// 后端会回退到全服排名,那种学生原来看到的是「班级排名」配全服数据
title: () => (aiStore.detailsData.rankScope === "class" ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.acCount,
},
{
title: "同期排名",
key: "period_rank",
width: 100,
align: "center",
render: (row) => row.periodRank + " / " + row.periodAcCount,
},
{
title: () =>
h(NTooltip, null, {
trigger: () => h("span", { style: "cursor:help; border-bottom: 1px dashed" }, "等级"),
default: () =>
h("div", null, [
h("div", null, "基于同时段排名的百分位:"),
h("div", null, "S — 前 10%"),
h("div", null, "A — 前 35%"),
h("div", null, "B — 前 75%"),
h("div", null, "C — 其余"),
]),
}),
key: "grade",
width: 100,
align: "center",
},
]
const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{
title: "完成的题目",
key: "problem_title",
width: 300,
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
router.push("/problem/" + row.problemId)
},
},
() => `${row.problemId} ${row.problemTitle}`,
),
},
{ title: "提交次数", key: "submissionCount", width: 100, align: "center" },
{
title: "最高分",
key: "best",
width: 100,
align: "center",
render: (row) => `${row.bestScore} (${row.bestGrade})`,
},
{
title: "最新提交时间",
key: "latest_submission_time",
width: 200,
align: "center",
render: (row) => parseTime(row.latestSubmissionTime),
},
{ title: "平均分", key: "avgScore", width: 100, align: "center" },
]
</script>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { LearningReport } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
/** 顶上一排数:做对几道、一次就对、上了几节课、差一点的;老师看时最前面多一格「班上第几」 */
const props = defineProps<{ report: LearningReport; teacher: boolean; compact?: boolean }>()
const theme = useThemeVars()
interface Cell {
big: number
unit: string
sub: string
warn?: boolean
}
const cells = computed(() => {
const r = props.report
const c = r.classStats
const percent = r.solved ? Math.round((r.firstTry / r.solved) * 100) : 0
const firstSub = `一次就对 · 占 ${percent}%`
const list: Cell[] = [
{ big: r.solved, unit: "道", sub: c ? `做对 · 班上中间那位 ${c.medianSolved} 道` : "做对" },
{
big: r.firstTry,
unit: "道",
sub: c && !props.compact ? `${firstSub},班上中间那位 ${c.medianFirstPercent}%` : firstSub,
},
{ big: r.lessonCount, unit: "节课", sub: `交了 ${r.submissionCount} 次` },
]
if (r.unsolved.length)
list.push({ big: r.unsolved.length, unit: "道", sub: "差一点 · 交过还没做对", warn: true })
if (props.teacher && c)
list.unshift({ big: c.rank, unit: `/ ${c.size}`, sub: "班上按做对题数排第几" })
return list
})
</script>
<template>
<div class="strip" :class="{ compact }">
<div v-for="cell in cells" :key="cell.sub" class="cell">
<div class="num">
<b :class="{ warn: cell.warn }">{{ cell.big }}</b>
<span>{{ cell.unit }}</span>
</div>
<div class="sub">{{ cell.sub }}</div>
</div>
</div>
</template>
<style scoped>
.strip {
display: flex;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
.cell {
flex: 1;
min-width: 0;
padding: 14px 20px;
border-left: 1px solid v-bind("theme.dividerColor");
}
.cell:first-child {
border-left: 0;
}
.num {
display: flex;
align-items: baseline;
gap: 4px;
}
.num b {
font-size: 30px;
line-height: 38px;
font-variant-numeric: tabular-nums;
}
.num b.warn {
color: #c27c0e;
}
.num span {
font-size: var(--oj-fs-body);
}
.sub {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.compact {
display: grid;
grid-template-columns: 1fr 1fr;
}
.compact .cell {
padding: 10px 12px;
border-left: 0;
}
.compact .num b {
font-size: 26px;
line-height: 32px;
}
</style>
@@ -1,17 +0,0 @@
<template>
<n-flex vertical align="start">
<n-flex align="center">
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
<span>{{ problem.contestTitle }}</span>
</n-flex>
<span>{{ problem.displayId }} {{ problem.title }}</span>
</n-flex>
</template>
<script setup lang="ts">
import type { SolvedProblem } from "utils/types"
interface Props {
problem: SolvedProblem["problem"]
}
const props = defineProps<Props>()
</script>
@@ -1,76 +0,0 @@
<template>
<n-card title="知识点分布" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">看看做过哪几类题</n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// 横向条形而不是雷达图:只有 3~5 个类目,雷达对「比较大小」是最差的形式之一,
// 而且原来还把值归一化成「占最多标签的百分比」,第一名恒为 100%,等于只画了个排序。
// 这里直接画真实题数。后端 topTags 已经截到前 5,不需要再截
const entries = computed(() =>
Object.entries(aiStore.detailsData.tags).sort(([, a], [, b]) => b - a),
)
const show = computed(() => entries.value.length > 0)
const data = computed(() => ({
labels: entries.value.map(([label]) => label),
datasets: [
{
label: "完成题目数",
data: entries.value.map(([, value]) => value),
backgroundColor: "rgba(99, 102, 241, 0.75)",
borderColor: "rgb(99, 102, 241)",
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 28,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "题目数量" },
},
y: {
grid: { display: false },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `完成 ${ctx.parsed.x} 道题`,
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
@@ -1,138 +0,0 @@
<template>
<n-card title="时间活跃度分析" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 基于全部提交,发现做题高峰时段 </n-text>
</template>
<div style="height: 300px">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
const TIME_PERIODS = [
{ label: "凌晨(0-6)", start: 0, end: 6 },
{ label: "上午(6-12)", start: 6, end: 12 },
{ label: "下午(12-18)", start: 12, end: 18 },
{ label: "晚上(18-24)", start: 18, end: 24 },
]
// 直接用后端聚合好的 activity(按全部提交、按东八区切好的 7×4)。
// 原来是拿 solved 里的 acTime 在浏览器本地时区现算 —— 只统计 AC,
// 28 个格子里能落进去的点太少;时区也和后端的热力图对不上
const activityMatrix = computed(() => {
const matrix: { [weekday: number]: { [period: number]: number } } = {}
for (let i = 0; i < 7; i++) {
matrix[i] = {}
for (let j = 0; j < TIME_PERIODS.length; j++) {
matrix[i][j] = 0
}
}
aiStore.detailsData.activity.forEach((item) => {
const row = matrix[item.weekday]
if (row && item.period in row) row[item.period] += item.count
})
return matrix
})
const show = computed(() => aiStore.detailsData.activity.some((item) => item.count > 0))
// 为每个时间段准备数据集
const data = computed(() => {
const datasets = TIME_PERIODS.map((period, periodIndex) => {
return {
label: period.label,
data: WEEKDAYS.map((_, weekday) => activityMatrix.value[weekday][periodIndex]),
backgroundColor: getTimePeriodColor(periodIndex),
borderColor: getTimePeriodColor(periodIndex),
borderWidth: 1,
}
})
return {
labels: WEEKDAYS,
datasets,
}
})
// 根据时间段返回对应的颜色
function getTimePeriodColor(periodIndex: number): string {
const colors = [
"#9D9D9D", // 凌晨 - 灰色
"#FFD700", // 上午 - 金色
"#4ECDC4", // 下午 - 青色
"#5B5F97", // 晚上 - 深蓝紫
]
return colors[periodIndex] || "#999"
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "提交次数",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `当天总计: ${total} 次提交`
},
},
},
},
}
</script>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { LearningItem } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { statusName } from "../utils"
/** 交过还没做对的题。学生「接着做」,老师「看提交」 */
const props = defineProps<{ items: LearningItem[]; username?: string }>()
const theme = useThemeVars()
const router = useRouter()
function submissions(item: LearningItem) {
return {
path: "/submission",
query: { problem: item.displayId, username: props.username, exactUsername: "1" },
}
}
</script>
<template>
<section class="card">
<h3>差一点的</h3>
<div v-for="item in items" :key="item.displayId" class="row">
<div class="text">
<div class="title">
<span class="pid">{{ item.displayId }}</span
>{{ item.title }}
</div>
<div class="meta">
交了 {{ item.attempts }} 次<template v-if="statusName(item.lastResult)">
· 最后一次{{ statusName(item.lastResult) }}</template
>
</div>
</div>
<router-link v-if="username" :to="submissions(item)" class="link">看提交 ›</router-link>
<n-button
v-else
type="primary"
size="small"
@click="router.push(`/problem/${item.displayId}`)"
>
接着做
</n-button>
</div>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px 8px;
border: 1px solid rgba(240, 160, 32, 0.4);
border-radius: var(--oj-radius);
background: rgba(240, 160, 32, 0.06);
}
h3 {
margin: 0 0 6px;
font-size: var(--oj-fs-h2);
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid rgba(240, 160, 32, 0.22);
}
.text {
flex: 1;
min-width: 0;
}
.title {
font-size: var(--oj-fs-body);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pid {
margin-right: 6px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.meta {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.link {
font-size: var(--oj-fs-meta);
color: #18a058;
text-decoration: none;
white-space: nowrap;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
import type { LearningItem } from "@oj2/contract"
import { JUDGE_STATUS } from "utils/constants"
const WEEKDAY = "日一二三四五六"
/** `2026-09-18` → 9月18日 / 周五。日历日本身就是东八区的,直接拆字符串,不经 Date */
export function dayLabel(day: string) {
const [year, month, date] = day.split("-").map(Number) as [number, number, number]
const weekday = new Date(Date.UTC(year, month - 1, date)).getUTCDay()
return { date: `${month}月${date}日`, weekday: `周${WEEKDAY[weekday]}` }
}
export function statusName(result: number | null) {
if (result === null) return ""
return JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? ""
}
export function isDone(item: LearningItem) {
return item.status === "first" || item.status === "tries"
}
+10 -30
View File
@@ -1,5 +1,7 @@
import {
type AiAnalysisRecord,
type LearningPeriod,
type LearningReport,
type AiHintFeedbackRequest,
type Contest,
type ContestAccess,
@@ -35,10 +37,6 @@ import {
type FlowchartScores,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type SolvedList,
type ProblemSet,
type ProblemSetClassView,
type ProblemSetList,
@@ -409,38 +407,20 @@ export function getTutorials(type: "python" | "c") {
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
}
export function getAIDetailData(start: string, end: string, username?: string) {
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
}
export function getAISolved(
start: string,
end: string,
offset: number,
limit: number,
username?: string,
) {
return api.get<SolvedList>("ai/solved", {
params: { start, end, offset, limit, username },
/** 智能分析整页的数据。username 只有老师传才生效,学生传了也只会拿到自己 */
export function getLearningReport(period: LearningPeriod, username?: string) {
return api.get<LearningReport>("ai/report", {
params: { period, username: username || undefined },
})
}
export function getAIDurationData(end: string, duration: string, username?: string) {
return api.get<DurationData[]>("ai/duration", {
params: { end, duration, username },
/** 这个时间段上次生成的 AI 小结(老师钉住的优先),没有为 null */
export function getLatestAnalysis(period: LearningPeriod, username?: string) {
return api.get<AiAnalysisRecord | null>("ai/analysis/latest", {
params: { period, username: username || undefined },
})
}
export function getAIHeatmapData(username?: string) {
return api.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
/** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
export function submitHintFeedback(hintId: number, helpful: boolean) {
return api.post<null>(`ai/hint/${hintId}/feedback`, {
+1 -1
View File
@@ -196,7 +196,7 @@ function threatNote(me: RankRow, behind: RankRow) {
>
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
</svg>
看我的智能分析:哪类题卡得多、哪段时间最常做题 ›
看我的智能分析:每节课做得怎样、哪几道还差一点 ›
</router-link>
</div>
</template>
-203
View File
@@ -1,203 +0,0 @@
import type { DetailsData, DurationData, SolvedProblem } from "utils/types"
import { streamAI } from "utils/stream"
import {
getAIDetailData,
getAIDurationData,
getAIHeatmapData,
getAIPinnedReport,
getAISolved,
} from "../api"
export const useAIStore = defineStore("ai", () => {
const duration = ref("months:6")
const targetUsername = ref("")
// 生成 AI 分析时要把同一段时间原样报给后端(数据由后端重算,前端只报范围)
const rangeStart = ref("")
const rangeEnd = ref("")
const durationData = ref<DurationData[]>([])
const detailsData = reactive<DetailsData>({
username: "",
start: "",
end: "",
grade: "",
className: null,
tags: {},
difficulty: {},
contestCount: 0,
solvedCount: 0,
attempts: [],
flowcharts: [],
activity: [],
errors: [],
rankScope: "global",
})
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
// 解题明细走服务端分页:一个活跃学生一年几百道题,整份跟着 detail 一起下发没必要
const solvedRows = ref<SolvedProblem[]>([])
const solvedTotal = ref(0)
const solvedPage = ref(1)
const solvedPageSize = ref(20)
const loading = reactive({
fetching: false, // 合并 details 和 duration 的 loading
ai: false,
heatmap: false,
solved: false,
})
const mdContent = ref("")
const pinnedReport = ref<{ analysis: string } | null>(null)
async function fetchDetailsData(start: string, end: string) {
const res = await getAIDetailData(start, end, targetUsername.value || undefined)
detailsData.start = res.start
detailsData.end = res.end
detailsData.grade = res.grade
detailsData.className = res.className
detailsData.tags = res.tags
detailsData.difficulty = res.difficulty
detailsData.contestCount = res.contestCount
detailsData.solvedCount = res.solvedCount
detailsData.attempts = res.attempts
detailsData.activity = res.activity
detailsData.errors = res.errors
detailsData.rankScope = res.rankScope
detailsData.flowcharts = res.flowcharts
}
async function fetchDurationData(end: string, duration: string) {
const res = await getAIDurationData(end, duration, targetUsername.value || undefined)
durationData.value = res
}
async function fetchSolved(page = solvedPage.value) {
if (!rangeStart.value || !rangeEnd.value) return
loading.solved = true
try {
const res = await getAISolved(
rangeStart.value,
rangeEnd.value,
(page - 1) * solvedPageSize.value,
solvedPageSize.value,
targetUsername.value || undefined,
)
solvedRows.value = res.results
solvedTotal.value = res.total
solvedPage.value = page
} finally {
loading.solved = false
}
}
async function fetchHeatmapData() {
loading.heatmap = true
const res = await getAIHeatmapData(targetUsername.value || undefined)
heatmapData.value = res
loading.heatmap = false
}
async function fetchAnalysisData(start: string, end: string, duration: string) {
rangeStart.value = start
rangeEnd.value = end
// 换时间范围就回到第一页,否则停在第 5 页但新范围只有两页
solvedPage.value = 1
loading.fetching = true
try {
await Promise.all([
fetchDetailsData(start, end),
fetchDurationData(end, duration),
fetchSolved(1),
])
} finally {
loading.fetching = false
}
}
let aiController: AbortController | null = null
// 不用 useAIStream:store 没有会卸载的作用域,而且 loading.ai 要和其它几个
// loading 放在同一个 reactive 里给页面用
async function fetchAIAnalysis() {
aiController?.abort()
const controller = new AbortController()
aiController = controller
loading.ai = true
mdContent.value = ""
try {
await streamAI(
"ai/analysis",
{
start: rangeStart.value,
end: rangeEnd.value,
duration: duration.value,
username: targetUsername.value || undefined,
},
{
signal: controller.signal,
onDelta(content) {
loading.ai = false
mdContent.value += content
},
},
)
} catch (error) {
if (controller.signal.aborted) return
mdContent.value = `生成失败:${(error as Error).message}`
} finally {
if (aiController === controller) {
aiController = null
loading.ai = false
}
}
}
async function fetchPinnedReport() {
const res = await getAIPinnedReport()
pinnedReport.value = res
}
async function simulatePinnedStream() {
if (!pinnedReport.value) return
const text = pinnedReport.value.analysis
mdContent.value = ""
const CHUNK = 6
const DELAY = 18
await new Promise<void>((resolve) => {
let i = 0
function step() {
if (i >= text.length) {
resolve()
return
}
mdContent.value += text.slice(i, i + CHUNK)
i += CHUNK
setTimeout(step, DELAY)
}
step()
})
}
return {
fetchAnalysisData,
fetchHeatmapData,
fetchSolved,
solvedRows,
solvedTotal,
solvedPage,
solvedPageSize,
fetchAIAnalysis,
fetchPinnedReport,
simulatePinnedStream,
durationData,
detailsData,
heatmapData,
duration,
targetUsername,
loading,
mdContent,
pinnedReport,
}
})
+1 -1
View File
@@ -171,7 +171,7 @@ onMounted(() => {
@click="
router.push({
name: 'ai',
query: { username: profile.user.username, duration: 'months:6' },
query: { username: profile.user.username },
})
"
>
+1 -11
View File
@@ -9,7 +9,6 @@ import type {
UserProfile,
EmbeddedSubmission as ContractEmbeddedSubmission,
Message as ContractMessage,
Grade,
ProblemDifficulty,
JudgeStatus,
AstRules,
@@ -465,16 +464,7 @@ export type Exercise = Omit<AdminExercise, "data"> & {
| ExerciseGroupData
}
export type {
DurationData,
FlowchartSummary,
SolvedProblem,
SolvedList,
AiDetail as DetailsData,
} from "@oj2/contract"
// 评级。空串是「无评级」,后端在没有可用数据时真会下发,见契约 gradeSchema
export type { Grade, ProblemDifficulty }
export type { ProblemDifficulty }
// ==================== 成就相关类型 ====================