diff --git a/apps/web/src/oj/problem/components/SubmissionEffects.vue b/apps/web/src/oj/problem/components/SubmissionEffects.vue new file mode 100644 index 0000000..6114280 --- /dev/null +++ b/apps/web/src/oj/problem/components/SubmissionEffects.vue @@ -0,0 +1,205 @@ + + + diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index a47e8ef..250be47 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -2,7 +2,7 @@ import { Icon } from "@iconify/vue" import { useThemeVars } from "naive-ui" import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract" -import type { AiHintDone, JudgeCaseResult } from "@oj2/contract" +import type { JudgeCaseResult } from "@oj2/contract" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" import { submissionCaseResults, @@ -14,12 +14,11 @@ import { import type { Submission } from "utils/types" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { useProblemStore } from "oj/store/problem" +import { useSubmissionStore } from "oj/store/submission" import PythonErrorExplain from "./PythonErrorExplain.vue" import RuntimeErrorExplain from "./RuntimeErrorExplain.vue" import WrongAnswerExplain from "./WrongAnswerExplain.vue" import LessonNext from "./LessonNext.vue" -import { useAIStream } from "shared/composables/aiStream" -import { submitHintFeedback } from "oj/api" import { MdPreview } from "md-editor-v3" import "md-editor-v3/lib/preview.css" import { useDark } from "@vueuse/core" @@ -32,61 +31,22 @@ const isDark = useDark() const problemStore = useProblemStore() const theme = useThemeVars() -// AI 提示状态。 -// hintTarget 是后端发来的全文,hintContent 是已经"打"出来的那一截 —— -// 后端从 2c 起整段生成、过滤通过才推(设计 2.6),一次就把全文发过来, -// 逐字显示改在这边模拟。 -const hintTarget = ref("") -const hintContent = ref("") -const hintStream = useAIStream() -// 整条流跑完才算结束:提示是一次推全文,没有「第一个字到了」的中间态 -const hintLoading = hintStream.running -const hintError = ref("") -// 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮 -const hintId = ref(null) -// 这条提示是哪一级(-1 = 编译错误那一档,不在阶梯上),以及还能不能再往上要一级。 -// 两个都由后端算好在 done 里给,前端不自己推阶梯 -const hintLevel = ref(null) -const hintCanEscalate = ref(false) -const hintHelpful = ref(null) -const hintFeedbackSending = ref(false) - -// 打字机:每 24ms 吐 3 个字,约 125 字/秒。步子不敢迈太小 —— -// 每一帧都要让 MdPreview 重渲染一次 Markdown,机房那批机器扛不住逐字 -const TYPE_STEP = 3 -const TYPE_INTERVAL = 24 -let typingTimer: ReturnType | null = null -const hintTyping = computed(() => hintContent.value.length < hintTarget.value.length) - -function stopTyping() { - if (typingTimer === null) return - clearInterval(typingTimer) - typingTimer = null -} - -function startTyping() { - if (typingTimer !== null) return - typingTimer = setInterval(() => { - if (!hintTyping.value) { - stopTyping() - return - } - hintContent.value = hintTarget.value.slice(0, hintContent.value.length + TYPE_STEP) - }, TYPE_INTERVAL) -} - -function resetHint() { - stopTyping() - hintTarget.value = "" - hintContent.value = "" - hintError.value = "" - hintId.value = null - hintLevel.value = null - hintCanEscalate.value = false - hintHelpful.value = null -} - -onUnmounted(stopTyping) +// AI 提示的状态在 submission store 里(见 oj/problem/composables/useSubmissionHint.ts): +// 这个组件在提交按钮那边,编辑器一卸载它就跟着卸载,已经生成的提示不能跟着没 +const { + hintTarget, + hintContent, + hintLoading, + hintError, + hintId, + hintLevel, + hintCanEscalate, + hintHelpful, + hintFeedbackSending, + hintTyping, + fetchHint, + sendHintFeedback, +} = useSubmissionStore().hint /** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */ function stripJudgePath(text: string) { @@ -188,59 +148,6 @@ const showAIHint = computed(() => { ) }) -// 结果面板现在是 display-directive="show",关掉不再销毁组件,提示内容能留到重新打开。 -// 代价是换了一次提交它也留着,所以这里按提交 id 手动清一次 —— 否则新结果底下挂着 -// 上一次提交的提示,而且按钮已经被 v-if 藏了,学生没法重新分析。 -watch( - () => props.submission?.id, - () => { - // 上一次提交的提示还在生成的话掐掉,不然它会写进新提交的面板里 - hintStream.abort() - resetHint() - }, -) - -// more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看 -// 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准 -async function fetchHint(submissionId: string, more = false) { - resetHint() - try { - await hintStream.run( - "ai/hint", - { submissionId, more }, - { - onDelta(content) { - hintTarget.value += content - startTyping() - }, - onDone(data) { - hintId.value = data.hintId ?? null - hintLevel.value = data.level ?? null - hintCanEscalate.value = data.canEscalate === true - }, - }, - ) - } catch (error) { - hintError.value = (error as Error).message - } -} - -// 可以改票:点另一个就覆盖。失败了不打扰学生,按钮恢复原样就行 —— -// 评价是给我们看的,不值得为它弹一条报错 -async function sendHintFeedback(helpful: boolean) { - if (hintId.value === null || hintFeedbackSending.value) return - if (hintHelpful.value === helpful) return - hintFeedbackSending.value = true - try { - await submitHintFeedback(hintId.value, helpful) - hintHelpful.value = helpful - } catch { - // 静默 - } finally { - hintFeedbackSending.value = false - } -} - // 测试用例表格数据(只在部分通过时显示) const infoTable = computed(() => { const submission = props.submission diff --git a/apps/web/src/oj/problem/components/SubmitCode.vue b/apps/web/src/oj/problem/components/SubmitCode.vue index 7c80712..b00338d 100644 --- a/apps/web/src/oj/problem/components/SubmitCode.vue +++ b/apps/web/src/oj/problem/components/SubmitCode.vue @@ -1,319 +1,56 @@ - @@ -358,17 +94,4 @@ watch( > 上次结果 - - - - - diff --git a/apps/web/src/oj/problem/composables/useSubmissionHint.ts b/apps/web/src/oj/problem/composables/useSubmissionHint.ts new file mode 100644 index 0000000..2eb8c5c --- /dev/null +++ b/apps/web/src/oj/problem/composables/useSubmissionHint.ts @@ -0,0 +1,131 @@ +import type { AiHintDone } from "@oj2/contract" +import { submitHintFeedback } from "oj/api" +import { useAIStream } from "shared/composables/aiStream" + +/** + * 一次提交的 AI 提示。 + * + * 原来这些状态长在结果面板(SubmissionResult)里,而结果面板在提交按钮那个组件里 —— + * 手机上切一下页签、桌面切一下分栏模式,编辑器连同它一起卸载,已经生成的提示没了, + * 那次 LLM 调用白花。现在由 submission store 持有(见 oj/store/submission.ts)。 + * + * hintTarget 是后端发来的全文,hintContent 是已经「打」出来的那一截 —— + * 后端从 2c 起整段生成、过滤通过才推(设计 2.6),一次就把全文发过来, + * 逐字显示改在这边模拟。 + */ +export function useSubmissionHint() { + const hintTarget = ref("") + const hintContent = ref("") + const hintStream = useAIStream() + // 整条流跑完才算结束:提示是一次推全文,没有「第一个字到了」的中间态 + const hintLoading = hintStream.running + const hintError = ref("") + // 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮 + const hintId = ref(null) + // 这条提示是哪一级(-1 = 编译错误那一档,不在阶梯上),以及还能不能再往上要一级。 + // 两个都由后端算好在 done 里给,前端不自己推阶梯 + const hintLevel = ref(null) + const hintCanEscalate = ref(false) + const hintHelpful = ref(null) + const hintFeedbackSending = ref(false) + + // 打字机:每 24ms 吐 3 个字,约 125 字/秒。步子不敢迈太小 —— + // 每一帧都要让 MdPreview 重渲染一次 Markdown,机房那批机器扛不住逐字 + const TYPE_STEP = 3 + const TYPE_INTERVAL = 24 + let typingTimer: ReturnType | null = null + const hintTyping = computed(() => hintContent.value.length < hintTarget.value.length) + + function stopTyping() { + if (typingTimer === null) return + clearInterval(typingTimer) + typingTimer = null + } + + function startTyping() { + if (typingTimer !== null) return + typingTimer = setInterval(() => { + if (!hintTyping.value) { + stopTyping() + return + } + hintContent.value = hintTarget.value.slice(0, hintContent.value.length + TYPE_STEP) + }, TYPE_INTERVAL) + } + + function resetHint() { + stopTyping() + hintTarget.value = "" + hintContent.value = "" + hintError.value = "" + hintId.value = null + hintLevel.value = null + hintCanEscalate.value = false + hintHelpful.value = null + } + + /** 换了一次提交:还在生成的那条掐掉(不然它会写进新提交的面板里),内容清空 */ + function clearHint() { + hintStream.abort() + resetHint() + } + + // more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看 + // 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准 + async function fetchHint(submissionId: string, more = false) { + resetHint() + try { + await hintStream.run( + "ai/hint", + { submissionId, more }, + { + onDelta(content) { + hintTarget.value += content + startTyping() + }, + onDone(data) { + hintId.value = data.hintId ?? null + hintLevel.value = data.level ?? null + hintCanEscalate.value = data.canEscalate === true + }, + }, + ) + } catch (error) { + hintError.value = (error as Error).message + } + } + + // 可以改票:点另一个就覆盖。失败了不打扰学生,按钮恢复原样就行 —— + // 评价是给我们看的,不值得为它弹一条报错 + async function sendHintFeedback(helpful: boolean) { + if (hintId.value === null || hintFeedbackSending.value) return + if (hintHelpful.value === helpful) return + hintFeedbackSending.value = true + try { + await submitHintFeedback(hintId.value, helpful) + hintHelpful.value = helpful + } catch { + // 静默 + } finally { + hintFeedbackSending.value = false + } + } + + if (getCurrentScope()) onScopeDispose(stopTyping) + + return { + hintTarget, + hintContent, + hintLoading, + hintError, + hintId, + hintLevel, + hintCanEscalate, + hintHelpful, + hintFeedbackSending, + hintTyping, + fetchHint, + sendHintFeedback, + clearHint, + } +} diff --git a/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts b/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts index 0203a4e..58e65a3 100644 --- a/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts +++ b/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts @@ -1,4 +1,4 @@ -import { ref, computed, onUnmounted } from "vue" +import { ref, computed, getCurrentScope, onScopeDispose } from "vue" import { useIntervalFn, useTimeoutFn } from "@vueuse/core" import { getSubmission } from "oj/api" import { SubmissionStatus } from "utils/constants" @@ -153,9 +153,9 @@ export function useSubmissionMonitor() { }) // ==================== 清理 ==================== - onUnmounted(() => { - pausePolling() - }) + // 现在由 submission store 持有(见 oj/store/submission.ts),store 不销毁就一直在; + // 用 onScopeDispose 是为了在组件里调用时照样能收尾 + if (getCurrentScope()) onScopeDispose(pausePolling) return { // 状态 diff --git a/apps/web/src/oj/problem/detail.vue b/apps/web/src/oj/problem/detail.vue index b50649b..6d29a47 100644 --- a/apps/web/src/oj/problem/detail.vue +++ b/apps/web/src/oj/problem/detail.vue @@ -7,6 +7,9 @@ import { useProblemStore } from "oj/store/problem" import { useScreenModeStore } from "shared/store/screenMode" import { useMyFlowchartStore } from "shared/store/myFlowchart" import { useUserStore } from "shared/store/user" +import { useSubmissionStore } from "oj/store/submission" +// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好 +import SubmissionEffects from "./components/SubmissionEffects.vue" // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。 // 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版, @@ -131,6 +134,9 @@ watch( }, ) onBeforeUnmount(() => { + // 提交状态在 store 里、离开页面也不会自己没:不清的话回到题目页(哪怕换了一道题), + // 结果面板里还是上一次的那条 + useSubmissionStore().reset() problem.value = null errMsg.value = "无数据" screenModeStore.resetScreenMode() @@ -154,6 +160,7 @@ watch(