From 9029e2914867959afd1a88638bdb5aa90dfb70df Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 8 Mar 2026 21:12:47 +0800 Subject: [PATCH] feat: add teaching enhancement features 1. AI personalized hints after 3 failures (streaming SSE) 2. Submission error distribution panel in "my submissions" tab 3. Similar problem recommendations on AC or 3+ failures 4. Admin stuck problems analysis page Co-Authored-By: Claude Opus 4.6 --- src/admin/api.ts | 5 + src/admin/problem/Stuck.vue | 56 +++++++++++ src/oj/api.ts | 6 ++ src/oj/problem/components/ProblemContent.vue | 76 +++++++++++++++ .../problem/components/ProblemSubmission.vue | 42 +++++++- .../problem/components/SubmissionResult.vue | 95 +++++++++++++++++++ src/oj/problem/components/SubmitCode.vue | 17 ++++ src/oj/store/problem.ts | 27 +++++- src/routes.ts | 6 ++ src/shared/layout/admin.vue | 10 ++ src/utils/types.ts | 1 + 11 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 src/admin/problem/Stuck.vue diff --git a/src/admin/api.ts b/src/admin/api.ts index 03f3291..ea3853b 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -434,3 +434,8 @@ export function getProblemSetProgress(problemSetId: number) { export function removeUserFromProblemSet(problemSetId: number, userId: number) { return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`) } + +// 学生卡点分析 +export function getStuckProblems() { + return http.get("admin/problem/stuck") +} diff --git a/src/admin/problem/Stuck.vue b/src/admin/problem/Stuck.vue new file mode 100644 index 0000000..f3687e7 --- /dev/null +++ b/src/admin/problem/Stuck.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/oj/api.ts b/src/oj/api.ts index 04890df..ed78180 100644 --- a/src/oj/api.ts +++ b/src/oj/api.ts @@ -301,6 +301,12 @@ export function getAILoginSummary() { return http.get("ai/login_summary") } +// ==================== 相似题目推荐 ==================== + +export function getSimilarProblems(problemId: string) { + return http.get("problem/similar", { params: { problem_id: problemId } }) +} + // ==================== 流程图相关API ==================== export function submitFlowchart(data: { diff --git a/src/oj/problem/components/ProblemContent.vue b/src/oj/problem/components/ProblemContent.vue index f111f9b..2aa53d4 100644 --- a/src/oj/problem/components/ProblemContent.vue +++ b/src/oj/problem/components/ProblemContent.vue @@ -5,11 +5,13 @@ import { storeToRefs } from "pinia" import { useCodeStore } from "oj/store/code" import { useProblemStore } from "oj/store/problem" import { createTestSubmission } from "utils/judge" +import { DIFFICULTY } from "utils/constants" import { Problem, ProblemStatus } from "utils/types" import Copy from "shared/components/Copy.vue" import { useDark } from "@vueuse/core" import { MdPreview } from "md-editor-v3" import "md-editor-v3/lib/preview.css" +import { getSimilarProblems } from "oj/api" type Sample = Problem["samples"][number] & { id: number @@ -28,6 +30,34 @@ const { problem } = storeToRefs(problemStore) const problemSetId = computed(() => route.params.problemSetId) +const router = useRouter() + +// 相似题目推荐 +const similarProblems = ref([]) +const similarLoaded = ref(false) + +async function loadSimilarProblems() { + if (similarLoaded.value || !problem.value) return + try { + const res = await getSimilarProblems(problem.value._id) + similarProblems.value = res.data || [] + } catch { + similarProblems.value = [] + } + similarLoaded.value = true +} + +// AC 或失败次数 >= 3 时加载推荐 +watch( + () => [problem.value?.my_status, problemStore.totalFailCount], + ([status, failCount]) => { + if (status === 0 || (failCount as number) >= 3) { + loadSimilarProblems() + } + }, + { immediate: true }, +) + const hasTriedButNotPassed = computed(() => { return ( problem.value?.my_status !== undefined && @@ -231,6 +261,52 @@ function type(status: ProblemStatus) { :theme="isDark ? 'dark' : 'light'" /> + + +
+ +

+ + + 相似题目推荐 + +

+ + + + + {{ sp._id }} + + {{ sp.title }} + + + + {{ + DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等" + }} + + + + +
diff --git a/src/oj/problem/components/ProblemSubmission.vue b/src/oj/problem/components/ProblemSubmission.vue index cc76bf8..9c511d6 100644 --- a/src/oj/problem/components/ProblemSubmission.vue +++ b/src/oj/problem/components/ProblemSubmission.vue @@ -4,7 +4,11 @@ import { getSubmissions, getRankOfProblem } from "oj/api" import Pagination from "shared/components/Pagination.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { useUserStore } from "shared/store/user" -import { LANGUAGE_SHOW_VALUE } from "utils/constants" +import { + JUDGE_STATUS, + LANGUAGE_SHOW_VALUE, + SubmissionStatus, +} from "utils/constants" import { parseTime } from "utils/functions" import { renderTableTitle } from "utils/renders" import { Submission } from "utils/types" @@ -83,6 +87,23 @@ const query = reactive({ page: 1, }) +// 错误分布统计 +const statusDistribution = computed(() => { + if (!submissions.value.length) return [] + const counts = new Map() + for (const s of submissions.value) { + counts.set(s.result, (counts.get(s.result) || 0) + 1) + } + return Array.from(counts.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([result, count]) => ({ + result, + name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name || "未知", + type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type || "info", + count, + })) +}) + const errorMsg = computed(() => { if (!userStore.isAuthed) return "请先登录" else if (!userStore.showSubmissions) return "提交列表已被管理员关闭" @@ -256,6 +277,25 @@ watch(query, listSubmissions) @@ -96,4 +183,12 @@ const columns: DataTableColumn[] = [ word-break: break-all; line-height: 1.5; } + +.gradient-text { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: bold; +} diff --git a/src/oj/problem/components/SubmitCode.vue b/src/oj/problem/components/SubmitCode.vue index f9c049f..47a2fba 100644 --- a/src/oj/problem/components/SubmitCode.vue +++ b/src/oj/problem/components/SubmitCode.vue @@ -124,6 +124,23 @@ async function submit() { startMonitoring(res.data.submission_id) } +// ==================== 失败计数 ==================== +watch( + () => submission.value?.result, + (result) => { + if (result === undefined || result === null) return + if ( + result === SubmissionStatus.pending || + result === SubmissionStatus.judging || + result === SubmissionStatus.submitting + ) + return + if (result !== SubmissionStatus.accepted) { + problemStore.incrementFailCount() + } + }, +) + // ==================== AC庆祝效果 ==================== watch( () => submission.value?.result, diff --git a/src/oj/store/problem.ts b/src/oj/store/problem.ts index 317a5bf..1ebb42f 100644 --- a/src/oj/store/problem.ts +++ b/src/oj/store/problem.ts @@ -10,6 +10,9 @@ export const useProblemStore = defineStore("problem", () => { const problem = ref(null) const route = useRoute() + // 本次会话内累计的失败次数(与服务端 my_failed_count 叠加) + const localFailCount = ref(0) + // ==================== 计算属性 ==================== const languages = computed(() => { if (route.name === "problem" && problem.value?.allow_flowchart) { @@ -18,11 +21,27 @@ export const useProblemStore = defineStore("problem", () => { return problem.value?.languages ?? [] }) - return { - // 状态 - problem, + const totalFailCount = computed( + () => (problem.value?.my_failed_count ?? 0) + localFailCount.value, + ) - // 计算属性 + function incrementFailCount() { + localFailCount.value++ + } + + // 切题时重置 + watch( + () => problem.value?.id, + () => { + localFailCount.value = 0 + }, + ) + + return { + problem, + localFailCount, languages, + totalFailCount, + incrementFailCount, } }) diff --git a/src/routes.ts b/src/routes.ts index db6ef47..59f3ca9 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -276,6 +276,12 @@ export const admins: RouteRecordRaw = { props: true, meta: { requiresSuperAdmin: true }, }, + { + path: "problem/stuck", + name: "admin stuck problems", + component: () => import("admin/problem/Stuck.vue"), + meta: { requiresSuperAdmin: true }, + }, // 题单管理路由 { path: "problemset/list", diff --git a/src/shared/layout/admin.vue b/src/shared/layout/admin.vue index 0988eba..668ab86 100644 --- a/src/shared/layout/admin.vue +++ b/src/shared/layout/admin.vue @@ -99,6 +99,15 @@ const options = computed(() => { ), key: "admin tutorial list", }, + { + label: () => + h( + RouterLink, + { to: "/admin/problem/stuck" }, + { default: () => "卡点" }, + ), + key: "admin stuck problems", + }, ) } @@ -112,6 +121,7 @@ const active = computed(() => { if (path === "/admin") return "admin home" if (path.startsWith("/admin/config")) return "admin config" if (path.startsWith("/admin/problemset")) return "admin problemset list" + if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems" if (path.startsWith("/admin/problem")) return "admin problem list" if (path.startsWith("/admin/contest")) return "admin contest list" if (path.startsWith("/admin/user")) return "admin user list" diff --git a/src/utils/types.ts b/src/utils/types.ts index 0a73f35..887fd5f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -128,6 +128,7 @@ export interface Problem { share_submission: boolean contest: number my_status: number + my_failed_count?: number visible: boolean // 流程图相关字段