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)
+
+
+ 我的提交统计:
+
+ {{ item.name }} × {{ item.count }}
+
+
+
()
+const isDark = useDark()
+const problemStore = useProblemStore()
+
+// AI 提示状态
+const hintContent = ref("")
+const hintLoading = ref(false)
+const hintError = ref("")
+
// 错误信息格式化
const msg = computed(() => {
if (!props.submission) return ""
@@ -30,6 +43,50 @@ const msg = computed(() => {
return msg
})
+// 是否显示AI提示区域
+const showAIHint = computed(() => {
+ if (!props.submission) return false
+ return (
+ problemStore.totalFailCount >= 3 &&
+ props.submission.result !== SubmissionStatus.accepted &&
+ props.submission.result !== SubmissionStatus.pending &&
+ props.submission.result !== SubmissionStatus.judging &&
+ props.submission.result !== SubmissionStatus.submitting
+ )
+})
+
+async function fetchHint(submissionId: string) {
+ hintLoading.value = true
+ hintContent.value = ""
+ hintError.value = ""
+
+ try {
+ const response = await fetch("/api/ai/hint", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ submission_id: submissionId }),
+ })
+
+ await consumeJSONEventStream(response, {
+ onMessage: (data: {
+ type: string
+ content?: string
+ message?: string
+ }) => {
+ if (data.type === "delta" && data.content) {
+ hintContent.value += data.content
+ } else if (data.type === "error") {
+ hintError.value = data.message || "AI 提示生成失败"
+ }
+ },
+ })
+ } catch (e: any) {
+ hintError.value = e.message || "请求失败"
+ } finally {
+ hintLoading.value = false
+ }
+}
+
// 测试用例表格数据(只在部分通过时显示)
const infoTable = computed(() => {
if (!props.submission?.info?.data?.length) return []
@@ -87,6 +144,36 @@ const columns: DataTableColumn[] = [
:columns="columns"
/>
+
+
+
+
+
+
+ AI 提示
+
+
+
+ 让 AI 分析我的代码
+
+
+
+
+
@@ -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
// 流程图相关字段