「失败 3 次解锁 AI 提示」实际是「在当前这次页面会话里再失败 3 次」: problemStore.failCount 是个从 0 起数的内存计数器,刷新、切题、跳进跳出就归零, 而后端闸门数的是数据库里的历史失败数,两边根本不是一回事。昨天在这题上撞了 十次墙的学生今天进来照样看不到按钮。 - 数法收成一个 countFailedSubmissions(),题目详情的 myFailedCount 和 POST /ai/hint 共用。原来详情把「等待/正在评分」也算失败,连点三次提交就能 把按钮点亮,点下去却回 hint-locked - 阈值 3 挪进契约 HINT_MIN_FAILURES,两端引用同一个常量 - failCount 改成 myFailedCount + 本次会话增量;在题目页里登录的补拉一次详情, 否则停在匿名时的 0 - 结果面板改 display-directive="show",不再一收起来就把流式输出中的提示连同 那次 LLM 调用一起作废;补「上次结果」按钮,原来唯一的重开方式是再提交一次 - prompt 里的判题结果翻成中文,原来拼的是裸状态码,模型不知道 -1 是什么 - system_error 不计入失败数、也不显示按钮:判题机自己崩了不是学生的问题 - 比赛中不给提示,和「求助」按钮同一个口径 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv
This commit is contained in:
@@ -18,3 +18,40 @@ export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
|
|||||||
export function isAccepted(result: number) {
|
export function isAccepted(result: number) {
|
||||||
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
|
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判题状态的中文名,和前端 `utils/constants.ts` 的 `JUDGE_STATUS` 一致,两边必须同步。
|
||||||
|
* 目前只用在喂给模型的 prompt 里 —— 原来那里拼的是裸状态码(`结果:-1`),
|
||||||
|
* 模型根本不知道 -1 是「答案错误」还是别的什么,等于白给一条信息。
|
||||||
|
*/
|
||||||
|
export const JUDGE_STATUS_NAME: Record<number, string> = {
|
||||||
|
[JudgeStatus.COMPILE_ERROR]: "编译失败",
|
||||||
|
[JudgeStatus.WRONG_ANSWER]: "答案错误",
|
||||||
|
[JudgeStatus.ACCEPTED]: "答案正确",
|
||||||
|
[JudgeStatus.CPU_TIME_LIMIT_EXCEEDED]: "运行超时",
|
||||||
|
[JudgeStatus.REAL_TIME_LIMIT_EXCEEDED]: "运行超时",
|
||||||
|
[JudgeStatus.MEMORY_LIMIT_EXCEEDED]: "内存超限",
|
||||||
|
[JudgeStatus.RUNTIME_ERROR]: "运行时错误",
|
||||||
|
[JudgeStatus.SYSTEM_ERROR]: "系统错误",
|
||||||
|
[JudgeStatus.PENDING]: "等待评分",
|
||||||
|
[JudgeStatus.JUDGING]: "正在评分",
|
||||||
|
[JudgeStatus.PARTIALLY_ACCEPTED]: "部分正确",
|
||||||
|
[JudgeStatus.AST_CHECK_FAILED]: "答案正确,但语法未通过",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function judgeStatusName(result: number) {
|
||||||
|
return JUDGE_STATUS_NAME[result] ?? `未知状态(${result})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了)
|
||||||
|
* 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题,
|
||||||
|
* 不该推着 AI 提示的解锁进度往前走。
|
||||||
|
*/
|
||||||
|
export const NON_FAILURE_RESULTS: number[] = [
|
||||||
|
JudgeStatus.ACCEPTED,
|
||||||
|
JudgeStatus.AST_CHECK_FAILED,
|
||||||
|
JudgeStatus.PENDING,
|
||||||
|
JudgeStatus.JUDGING,
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
HINT_MIN_FAILURES,
|
||||||
aiAnalysisRecordSchema,
|
aiAnalysisRecordSchema,
|
||||||
aiAnalysisRequestSchema,
|
aiAnalysisRequestSchema,
|
||||||
aiDetailSchema,
|
aiDetailSchema,
|
||||||
@@ -11,25 +12,23 @@ import {
|
|||||||
solvedListSchema,
|
solvedListSchema,
|
||||||
solvedProblemSchema,
|
solvedProblemSchema,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, notInArray, sql } from "drizzle-orm"
|
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm"
|
||||||
import { Hono, type Context } from "hono"
|
import { Hono, type Context } from "hono"
|
||||||
|
|
||||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||||
import { getPreviousLogin, type AuthUser } from "../auth/session"
|
import { getPreviousLogin, type AuthUser } from "../auth/session"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus, judgeStatusName } from "../judge/status"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { completeChat, streamChat } from "../services/ai"
|
import { completeChat, streamChat } from "../services/ai"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
|
import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
|
||||||
|
|
||||||
export const aiRoutes = new Hono<AppEnv>()
|
export const aiRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
const accepted = [0, 10]
|
const accepted = [0, 10]
|
||||||
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
|
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
|
||||||
/** 解锁 AI 提示所需的失败提交数,与前端 SubmissionResult.vue 的显示条件一致 */
|
|
||||||
const HINT_MIN_FAILURES = 3
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每次 AI 调用都过一遍令牌桶,复用 services/throttling 的那只桶(capacity 20 / 0.03 每秒)。
|
* 每次 AI 调用都过一遍令牌桶,复用 services/throttling 的那只桶(capacity 20 / 0.03 每秒)。
|
||||||
@@ -495,15 +494,15 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
|||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||||
.where(and(eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, c.get("user")!.id))).limit(1)
|
.where(and(eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, c.get("user")!.id))).limit(1)
|
||||||
if (!row) return failure(c, 404, "submission-not-found", "Submission not found")
|
if (!row) return failure(c, 404, "submission-not-found", "Submission not found")
|
||||||
// 失败次数在端点这边也要卡一道。前端那个 problemStore.failCount 是页面内的计数器,
|
// 比赛里不给 AI 提示,和「求助」按钮同一个口径。前端在比赛路由下压根不显示按钮,
|
||||||
// 刷新就归零,直接 POST 更是完全绕开它 —— 不然这就是个不限次数的免费 LLM 接口。
|
// 这里是防直接 POST 的那一道 —— 比赛只有 ACM 模式,提示等于变相放水。
|
||||||
// 判题中的提交不算失败,否则连点几次提交就能提前解锁。
|
if (row.submission.contestId !== null) return failure(c, 403, "contest-hint-disabled", "Hint is disabled in contests")
|
||||||
const [failed] = await db.select({ value: count() }).from(schema.submission).where(and(
|
// 失败次数在端点这边也要卡一道:直接 POST 完全绕开前端的显示条件 ——
|
||||||
eq(schema.submission.userId, c.get("user")!.id),
|
// 不然这就是个不限次数的免费 LLM 接口。数法(判题中的不算、判题机自己崩的不算)
|
||||||
eq(schema.submission.problemId, row.submission.problemId),
|
// 由 countFailedSubmissions 统一,题目详情的 myFailedCount 走的是同一个函数,
|
||||||
notInArray(schema.submission.result, [...accepted, JudgeStatus.PENDING, JudgeStatus.JUDGING]),
|
// 所以前端亮出按钮的时刻和这里放行的时刻严格对齐。
|
||||||
))
|
const failed = await countFailedSubmissions(c.get("user")!.id, row.submission.problemId)
|
||||||
if ((failed?.value ?? 0) < HINT_MIN_FAILURES) return failure(c, 403, "hint-locked", "Hint unlocks after 3 failed submissions")
|
if (failed < HINT_MIN_FAILURES) return failure(c, 403, "hint-locked", `Hint unlocks after ${HINT_MIN_FAILURES} failed submissions`)
|
||||||
const limited = await throttleAi(c)
|
const limited = await throttleAi(c)
|
||||||
if (limited) return limited
|
if (limited) return limited
|
||||||
// 这里**不要**把 problem.answers 的参考答案放进 prompt。学生的代码本身就是 prompt 的
|
// 这里**不要**把 problem.answers 的参考答案放进 prompt。学生的代码本身就是 prompt 的
|
||||||
@@ -511,7 +510,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
|||||||
// 写「不可透露」只是软约束,挡不住。题面预算从 500 提到 2000(正好是参考答案让出来的那份),
|
// 写「不可透露」只是软约束,挡不住。题面预算从 500 提到 2000(正好是参考答案让出来的那份),
|
||||||
// 让模型靠题目要求 + 报错信息判断,入门题的常见错误够用了。
|
// 让模型靠题目要求 + 报错信息判断,入门题的常见错误够用了。
|
||||||
const system = "你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
|
const system = "你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
|
||||||
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${row.submission.result}\n错误:${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码:${row.submission.code.slice(0, 2000)}`
|
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${judgeStatusName(row.submission.result)}\n错误:${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码:${row.submission.code.slice(0, 2000)}`
|
||||||
return streamChat(system, prompt)
|
return streamChat(system, prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
type SampleUser,
|
type SampleUser,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
|
|
||||||
|
import { and, count, eq, notInArray } from "drizzle-orm"
|
||||||
|
|
||||||
import type { AuthUser } from "../auth/session"
|
import type { AuthUser } from "../auth/session"
|
||||||
|
import { db, schema } from "../db"
|
||||||
|
import { NON_FAILURE_RESULTS } from "../judge/status"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户对象的序列化层,对齐旧后端 `utils/api/_serializers.py` 的 `UsernameSerializer`。
|
* 用户对象的序列化层,对齐旧后端 `utils/api/_serializers.py` 的 `UsernameSerializer`。
|
||||||
@@ -108,3 +112,25 @@ export function rounded(value: number, digits = 2) {
|
|||||||
const factor = 10 ** digits
|
const factor = 10 ** digits
|
||||||
return Math.round(value * factor) / factor
|
return Math.round(value * factor) / factor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个用户在这道题上失败了几次 —— 也就是 AI 提示的解锁进度。
|
||||||
|
*
|
||||||
|
* 题目详情下发的 `myFailedCount` 和 `POST /ai/hint` 的服务端闸门必须用**同一个**口径,
|
||||||
|
* 所以两边都走这里。原来是各写各的:详情那边 `notInArray(result, [0, 10])` 把
|
||||||
|
* 等待评分 / 正在评分也算成失败,连点三次提交就能让按钮亮起来,而 hint 端点排掉了
|
||||||
|
* 这两个状态,于是按钮亮着、点下去回 `hint-locked`。
|
||||||
|
*/
|
||||||
|
export async function countFailedSubmissions(userId: number, problemId: number) {
|
||||||
|
const [failed] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, userId),
|
||||||
|
eq(schema.submission.problemId, problemId),
|
||||||
|
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return failed?.value ?? 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { db, schema } from "../db"
|
|||||||
import { astRequirements } from "../judge/ast"
|
import { astRequirements } from "../judge/ast"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
import { countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
||||||
|
|
||||||
export const problemRoutes = new Hono<AppEnv>()
|
export const problemRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -277,17 +277,9 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
|||||||
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
||||||
if (typeof problemStatus === "number") myStatus = problemStatus
|
if (typeof problemStatus === "number") myStatus = problemStatus
|
||||||
|
|
||||||
const [failed] = await db
|
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
|
||||||
.select({ value: count() })
|
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
|
||||||
.from(schema.submission)
|
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.submission.userId, user.id),
|
|
||||||
eq(schema.submission.problemId, row.problem.id),
|
|
||||||
notInArray(schema.submission.result, [0, 10]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
myFailedCount = failed?.value ?? 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { HINT_MIN_FAILURES } from "@oj2/contract"
|
||||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||||
import {
|
import {
|
||||||
submissionMemoryFormat,
|
submissionMemoryFormat,
|
||||||
@@ -54,19 +55,39 @@ const msg = computed(() => {
|
|||||||
return msg
|
return msg
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否显示AI提示区域
|
// 是否显示AI提示区域。
|
||||||
|
// 阈值和后端 POST /ai/hint 共用契约里的 HINT_MIN_FAILURES,别在这里写死数字;
|
||||||
|
// failCount 现在含服务端下发的历史失败数,刷新页面不会把进度清掉。
|
||||||
|
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
|
||||||
|
// 只会瞎编一通,后端的失败计数同样不认这个状态。
|
||||||
const showAIHint = computed(() => {
|
const showAIHint = computed(() => {
|
||||||
if (!props.submission) return false
|
if (!props.submission) return false
|
||||||
|
// 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数:
|
||||||
|
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
|
||||||
|
if (problemStore.problem?.contestId != null) return false
|
||||||
return (
|
return (
|
||||||
problemStore.failCount >= 3 &&
|
problemStore.failCount >= HINT_MIN_FAILURES &&
|
||||||
props.submission.result !== SubmissionStatus.accepted &&
|
props.submission.result !== SubmissionStatus.accepted &&
|
||||||
props.submission.result !== SubmissionStatus.ast_check_failed &&
|
props.submission.result !== SubmissionStatus.ast_check_failed &&
|
||||||
|
props.submission.result !== SubmissionStatus.system_error &&
|
||||||
props.submission.result !== SubmissionStatus.pending &&
|
props.submission.result !== SubmissionStatus.pending &&
|
||||||
props.submission.result !== SubmissionStatus.judging &&
|
props.submission.result !== SubmissionStatus.judging &&
|
||||||
props.submission.result !== SubmissionStatus.submitting
|
props.submission.result !== SubmissionStatus.submitting
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 结果面板现在是 display-directive="show",关掉不再销毁组件,提示内容能留到重新打开。
|
||||||
|
// 代价是换了一次提交它也留着,所以这里按提交 id 手动清一次 —— 否则新结果底下挂着
|
||||||
|
// 上一次提交的提示,而且按钮已经被 v-if 藏了,学生没法重新分析。
|
||||||
|
watch(
|
||||||
|
() => props.submission?.id,
|
||||||
|
() => {
|
||||||
|
hintContent.value = ""
|
||||||
|
hintError.value = ""
|
||||||
|
hintLoading.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async function fetchHint(submissionId: string) {
|
async function fetchHint(submissionId: string) {
|
||||||
hintLoading.value = true
|
hintLoading.value = true
|
||||||
hintContent.value = ""
|
hintContent.value = ""
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 失败计数 ====================
|
// ==================== 失败计数 ====================
|
||||||
|
// 这里只数本次会话的增量,历史失败数由 problem.myFailedCount 带进来。
|
||||||
|
// 排除的状态要和后端 judge/status.ts 的 NON_FAILURE_RESULTS 对齐,
|
||||||
|
// 尤其是 system_error —— 判题机自己崩了不该推进 AI 提示的解锁进度。
|
||||||
watch(
|
watch(
|
||||||
() => submission.value?.result,
|
() => submission.value?.result,
|
||||||
(result) => {
|
(result) => {
|
||||||
@@ -184,7 +187,8 @@ watch(
|
|||||||
return
|
return
|
||||||
if (
|
if (
|
||||||
result !== SubmissionStatus.accepted &&
|
result !== SubmissionStatus.accepted &&
|
||||||
result !== SubmissionStatus.ast_check_failed
|
result !== SubmissionStatus.ast_check_failed &&
|
||||||
|
result !== SubmissionStatus.system_error
|
||||||
) {
|
) {
|
||||||
problemStore.incrementFailCount()
|
problemStore.incrementFailCount()
|
||||||
}
|
}
|
||||||
@@ -228,9 +232,13 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 提交按钮 + 结果弹窗 -->
|
<!-- 提交按钮 + 结果弹窗。
|
||||||
|
display-directive 默认是 "if":面板一收起来整个 SubmissionResult 就被卸载,
|
||||||
|
正在流式输出的 AI 提示连同已经生成的内容一起没了,那次 LLM 调用白花。
|
||||||
|
改成 "show" 之后内容留着,重新打开还是原样。 -->
|
||||||
<n-popover
|
<n-popover
|
||||||
trigger="manual"
|
trigger="manual"
|
||||||
|
display-directive="show"
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
scrollable
|
scrollable
|
||||||
:show-arrow="false"
|
:show-arrow="false"
|
||||||
@@ -258,6 +266,17 @@ watch(
|
|||||||
<SubmissionResult :submission="submission" />
|
<SubmissionResult :submission="submission" />
|
||||||
</n-popover>
|
</n-popover>
|
||||||
|
|
||||||
|
<!-- 结果面板点一下别处就收起来,而 showResult 只在提交时被置 true ——
|
||||||
|
原来唯一的重开方式是「再提交一次」,AI 提示读到一半去看眼题面就回不来了。
|
||||||
|
只在这次会话提交过之后才出现,没提交时工具栏保持原样。 -->
|
||||||
|
<n-button
|
||||||
|
v-if="submission && !showResult"
|
||||||
|
:size="isDesktop ? 'medium' : 'small'"
|
||||||
|
@click="showResult = true"
|
||||||
|
>
|
||||||
|
上次结果
|
||||||
|
</n-button>
|
||||||
|
|
||||||
<!-- 评价弹窗 -->
|
<!-- 评价弹窗 -->
|
||||||
<n-modal
|
<n-modal
|
||||||
preset="card"
|
preset="card"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { storeToRefs } from "pinia"
|
|||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useScreenModeStore } from "shared/store/screenMode"
|
import { useScreenModeStore } from "shared/store/screenMode"
|
||||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk
|
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk
|
||||||
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
|
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
|
||||||
@@ -128,6 +129,21 @@ async function init() {
|
|||||||
}
|
}
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
watch(() => problemID, init)
|
watch(() => problemID, init)
|
||||||
|
|
||||||
|
// 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 ——
|
||||||
|
// 会话过期后直接在题目页登录的(机房里最常见的那条路)不补拉一次,AI 提示的解锁进度
|
||||||
|
// 就还是匿名时的 0,等于白改。只换 problem,不走 init:那里还会重置分栏模式。
|
||||||
|
watch(
|
||||||
|
() => useUserStore().isAuthed,
|
||||||
|
async (authed) => {
|
||||||
|
if (!authed || !problem.value) return
|
||||||
|
try {
|
||||||
|
problem.value = await getProblem(problemID, contestID)
|
||||||
|
} catch {
|
||||||
|
// 拉不到就留着现在这份题面,不要把页面清空
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
problem.value = null
|
problem.value = null
|
||||||
errMsg.value = "无数据"
|
errMsg.value = "无数据"
|
||||||
|
|||||||
@@ -5,7 +5,27 @@ export const useProblemStore = defineStore("problem", () => {
|
|||||||
const problem = ref<Problem | null>(null)
|
const problem = ref<Problem | null>(null)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const failCount = ref(0)
|
/**
|
||||||
|
* 本次会话里新增的失败提交数。**只是增量**,历史失败数看 `problem.myFailedCount`。
|
||||||
|
*
|
||||||
|
* 原来 failCount 就是这一个从 0 起数的 ref,于是「失败 3 次解锁 AI 提示」实际变成了
|
||||||
|
* 「在当前这次页面会话里再失败 3 次」:刷新一下、从题单跳进跳出一次就清零,
|
||||||
|
* 昨天在这题上撞了十次墙的学生今天进来照样看不到按钮。而后端的闸门数的是数据库里
|
||||||
|
* 的历史失败数,两边根本不是一回事。
|
||||||
|
*/
|
||||||
|
const sessionFailCount = ref(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这道题一共失败了几次 = 服务端算好的历史值 + 本次会话的增量。
|
||||||
|
* 和后端 `countFailedSubmissions` 同一个口径,所以按钮亮起来的时刻就是
|
||||||
|
* `POST /ai/hint` 放行的时刻。
|
||||||
|
*
|
||||||
|
* 题目详情只在 problemID 变化时重新拉(见 oj/problem/detail.vue 的 init),
|
||||||
|
* 而那时下面的 watch 已经把增量清零了,不会和新的 myFailedCount 叠加。
|
||||||
|
*/
|
||||||
|
const failCount = computed(
|
||||||
|
() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value,
|
||||||
|
)
|
||||||
|
|
||||||
const languages = computed<LANGUAGE[]>(() => {
|
const languages = computed<LANGUAGE[]>(() => {
|
||||||
if (route.name === "problem" && problem.value?.allowFlowchart) {
|
if (route.name === "problem" && problem.value?.allowFlowchart) {
|
||||||
@@ -15,13 +35,13 @@ export const useProblemStore = defineStore("problem", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function incrementFailCount() {
|
function incrementFailCount() {
|
||||||
failCount.value++
|
sessionFailCount.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => problem.value?.id,
|
() => problem.value?.id,
|
||||||
() => {
|
() => {
|
||||||
failCount.value = 0
|
sessionFailCount.value = 0
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export async function aiStreamError(response: Response) {
|
|||||||
return new Error("AI 请求太频繁了,歇一会儿再试")
|
return new Error("AI 请求太频繁了,歇一会儿再试")
|
||||||
case "hint-locked":
|
case "hint-locked":
|
||||||
return new Error("再多试几次,AI 提示会自动解锁")
|
return new Error("再多试几次,AI 提示会自动解锁")
|
||||||
|
case "contest-hint-disabled":
|
||||||
|
return new Error("比赛中不提供 AI 提示")
|
||||||
case "permission-denied":
|
case "permission-denied":
|
||||||
return new Error("没有权限使用这个功能")
|
return new Error("没有权限使用这个功能")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ export const aiAnalysisRequestSchema = z.object({
|
|||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解锁「让 AI 分析我的代码」所需的失败提交数。前端拿它决定按钮露不露面、
|
||||||
|
* 后端拿它卡 POST /ai/hint —— 放在契约里就是为了不让两边各写一个 3。
|
||||||
|
*/
|
||||||
|
export const HINT_MIN_FAILURES = 3
|
||||||
|
|
||||||
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
|
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
|
||||||
|
|
||||||
export const classAnalysisRequestSchema = z.object({
|
export const classAnalysisRequestSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user