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"> <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 { useRouteQuery } from "@vueuse/router"
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue" import { useThemeVars } from "naive-ui"
import TagsChart from "./components/TagsChart.vue" import { useBreakpoints } from "shared/composables/breakpoints"
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 { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { DURATION_OPTIONS } from "utils/constants" import UserName from "shared/components/UserName.vue"
import { durationFromValue } from "utils/functions" 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 userStore = useUserStore()
const options = [...DURATION_OPTIONS] const { isDesktop } = useBreakpoints()
const urlUsername = useRouteQuery<string>("username", "") 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 const viewing = computed(() =>
aiStore.duration = urlDuration.value userStore.isTeacherOrAbove && urlUsername.value && urlUsername.value !== userStore.user?.username
? urlUsername.value
const subOptions = computed<Duration>( : undefined,
() => durationFromValue(aiStore.duration) ?? durationFromValue(DURATION_OPTIONS[0].value)!,
) )
const start = computed(() => formatISO(sub(new Date(), subOptions.value))) const report = ref<LearningReport | null>(null)
const end = computed(() => formatISO(new Date())) const loading = ref(false)
const notFound = ref(false)
/** 这段时间一道没交时,再拿一份「全部」的,说说上一节课和还差的题 */
const before = ref<LearningReport | null>(null)
function onUsernameChange() { async function load() {
aiStore.targetUsername = urlUsername.value const want = { period: period.value, username: viewing.value }
aiStore.fetchHeatmapData() loading.value = true
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration) 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(() => { watch([period, viewing], load, { immediate: true })
aiStore.fetchHeatmapData()
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( const emptyHint = computed(() => {
() => urlDuration.value, const last = before.value?.days.find((day) => day.lesson.length)
(val) => { if (!last) return ""
aiStore.duration = val const { date, weekday } = dayLabel(last.day)
aiStore.fetchAnalysisData(start.value, end.value, val) return `上一节课是 ${date} ${weekday},做对了 ${last.lesson.filter(isDone).length} / ${last.lesson.length} 道`
}, })
{ immediate: true }, 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> </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> <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; margin: 0;
font-size: var(--oj-fs-title); font-size: var(--oj-fs-title);
} }
.pair > :deep(.n-card) { .toolbar.single {
flex: 1 1 320px; 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> </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 { import {
type AiAnalysisRecord, type AiAnalysisRecord,
type LearningPeriod,
type LearningReport,
type AiHintFeedbackRequest, type AiHintFeedbackRequest,
type Contest, type Contest,
type ContestAccess, type ContestAccess,
@@ -35,10 +37,6 @@ import {
type FlowchartScores, type FlowchartScores,
type FlowchartList, type FlowchartList,
type FlowchartSubmission, type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type SolvedList,
type ProblemSet, type ProblemSet,
type ProblemSetClassView, type ProblemSetClassView,
type ProblemSetList, type ProblemSetList,
@@ -409,38 +407,20 @@ export function getTutorials(type: "python" | "c") {
return api.get<TutorialSummary[]>("tutorials", { params: { type } }) return api.get<TutorialSummary[]>("tutorials", { params: { type } })
} }
export function getAIDetailData(start: string, end: string, username?: string) { /** 智能分析整页的数据。username 只有老师传才生效,学生传了也只会拿到自己 */
return api.get<AiDetail>("ai/detail", { params: { start, end, username } }) export function getLearningReport(period: LearningPeriod, username?: string) {
} return api.get<LearningReport>("ai/report", {
params: { period, username: username || undefined },
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 },
}) })
} }
export function getAIDurationData(end: string, duration: string, username?: string) { /** 这个时间段上次生成的 AI 小结(老师钉住的优先),没有为 null */
return api.get<DurationData[]>("ai/duration", { export function getLatestAnalysis(period: LearningPeriod, username?: string) {
params: { end, duration, username }, 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 事件,可以改票 */ /** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
export function submitHintFeedback(hintId: number, helpful: boolean) { export function submitHintFeedback(hintId: number, helpful: boolean) {
return api.post<null>(`ai/hint/${hintId}/feedback`, { 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" /> <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> </svg>
看我的智能分析:哪类题卡得多、哪段时间最常做题 › 看我的智能分析:每节课做得怎样、哪几道还差一点 ›
</router-link> </router-link>
</div> </div>
</template> </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=" @click="
router.push({ router.push({
name: 'ai', 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, UserProfile,
EmbeddedSubmission as ContractEmbeddedSubmission, EmbeddedSubmission as ContractEmbeddedSubmission,
Message as ContractMessage, Message as ContractMessage,
Grade,
ProblemDifficulty, ProblemDifficulty,
JudgeStatus, JudgeStatus,
AstRules, AstRules,
@@ -465,16 +464,7 @@ export type Exercise = Omit<AdminExercise, "data"> & {
| ExerciseGroupData | ExerciseGroupData
} }
export type { export type { ProblemDifficulty }
DurationData,
FlowchartSummary,
SolvedProblem,
SolvedList,
AiDetail as DetailsData,
} from "@oj2/contract"
// 评级。空串是「无评级」,后端在没有可用数据时真会下发,见契约 gradeSchema
export type { Grade, ProblemDifficulty }
// ==================== 成就相关类型 ==================== // ==================== 成就相关类型 ====================