AI 时代 OJ 设计的第 1 步:给「可信 AC」和学情分析攒数据,本身不判任何事。 - 契约:提交请求加可选的 trace(活跃时长、键入/粘贴/删除字符数、切后台次数等, 只有计数、不含按键内容);写成 .optional().catch(undefined),坏了就当没带, 不让附带数据把提交挡成 400 - 迁移 0016 建 submission_trace,与 submission 一对一、CASCADE;since_prev_ms 由服务端在同一条 INSERT 里算(排掉自身),bigint —— 实测已有 44 天的间隔,int4 装不下 - 后端写 trace 失败只记日志,不影响提交 - 前端 oj/problem/utils/editTrace.ts 是模块单例(扩展对象不能过 Pinia 的响应式代理), 只数带 userEvent 的事务:格式化回写 / 载入草稿 / 协作对方的改动天然排除; closeBrackets 越过右括号时是原样替换,按 no-op 跳过。比赛编辑器同样挂上 - 实跑:后端四种请求、前端浏览器里键入/粘贴/删除/setCode 回写/切后台/提交后清零, 计数与预期逐项一致 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import storage from "utils/storage"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||
import Form from "./Form.vue"
|
||||
|
||||
const route = useRoute()
|
||||
@@ -34,6 +35,10 @@ onMounted(() => {
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
beginEditTrace(
|
||||
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||
codeStore.code.value.length,
|
||||
)
|
||||
})
|
||||
|
||||
const changeCode = (v: string) => {
|
||||
@@ -58,6 +63,7 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
v-model:value="codeStore.code.value"
|
||||
:language="codeStore.code.language"
|
||||
:height="editorHeight"
|
||||
:extra-extensions="editTraceExtensions"
|
||||
@update:model-value="changeCode"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
@@ -8,6 +8,7 @@ import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import storage from "utils/storage"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||
import Form from "./Form.vue"
|
||||
|
||||
const FlowchartEditor = defineAsyncComponent(
|
||||
@@ -98,6 +99,11 @@ function loadCode() {
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
// 换了题才重新计数,同一道题重复 loadCode(协作结束读回草稿)是接着记
|
||||
beginEditTrace(
|
||||
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||
codeStore.code.value.length,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(loadCode)
|
||||
@@ -151,6 +157,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
||||
:language="codeStore.code.language"
|
||||
:problem-id="problem!._id"
|
||||
:height="editorHeight"
|
||||
:extra-extensions="editTraceExtensions"
|
||||
@update:model-value="changeCode"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
@@ -12,6 +12,8 @@ import SubmissionResult from "./SubmissionResult.vue"
|
||||
import { getSubmitButtonState } from "./submitButtonState"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
|
||||
import {
|
||||
checkPythonSyntax,
|
||||
prefetchPythonSyntaxChecker,
|
||||
@@ -24,6 +26,7 @@ const ProblemReaction = defineAsyncComponent(
|
||||
|
||||
// ==================== 基础状态 ====================
|
||||
const userStore = useUserStore()
|
||||
const collabStore = useCollabStore()
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
@@ -147,6 +150,11 @@ async function submit() {
|
||||
problemId: problem.value!.id,
|
||||
language: codeStore.code.language,
|
||||
code: codeStore.code.value,
|
||||
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere 同一个口径
|
||||
trace: snapshotEditTrace(
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value!._id,
|
||||
),
|
||||
}
|
||||
if (contestID) {
|
||||
data.contestId = parseInt(contestID)
|
||||
@@ -161,6 +169,8 @@ async function submit() {
|
||||
try {
|
||||
const res = await submitCode(data)
|
||||
console.log(`[Submit] 代码已提交: ID=${res.submissionId}`)
|
||||
// 交上了才清零;被限流 / 网络失败的话这一段接着记,下次提交一起报
|
||||
restartEditTrace(codeStore.code.value.length)
|
||||
|
||||
// 3. 启动冷却 + 监控
|
||||
startCooldown()
|
||||
|
||||
139
apps/web/src/oj/problem/utils/editTrace.ts
Normal file
139
apps/web/src/oj/problem/utils/editTrace.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import type { SubmissionTrace } from "@oj2/contract"
|
||||
|
||||
/**
|
||||
* 编辑过程信号的采集,随提交一起报给后端(落进 `submission_trace`)。
|
||||
* 字段含义见契约的 `submissionTraceSchema`。**只数字符数和次数,不留任何按键内容。**
|
||||
*
|
||||
* 是模块单例而不是 Pinia store:编辑器(ProblemEditor / ContestEditor 里)和
|
||||
* 提交按钮(Form → SubmitCode)是兄弟组件,得共用一份计数;而 CodeMirror 的
|
||||
* 扩展对象一旦经过 store 就会被包成响应式代理,facet 靠身份比较,代理过的扩展
|
||||
* 直接失效。计数本身也不需要响应式。
|
||||
*
|
||||
* **只数带 userEvent 的事务。** 下面这些都不带,所以天然不会被算进去:
|
||||
* - `codeStore.setCode()` —— vue-codemirror 的 setDoc 只 dispatch 一个 changes:
|
||||
* 提交前的自动格式化、载入草稿 / 模板、切语言都走这条;
|
||||
* - 课堂协作里对方的改动 —— y-codemirror.next 应用远程更新时只挂 ySyncAnnotation。
|
||||
*
|
||||
* 撤销 / 重做、编辑器内部拖动(`move.drop`)也不数:它们既不是新写的也不是外来的。
|
||||
*/
|
||||
|
||||
/** 两次编辑间隔超过这个就算走开了,中间这段不计入活跃时长 */
|
||||
const IDLE_MS = 60_000
|
||||
|
||||
let key: string | null = null
|
||||
let startedAt = 0
|
||||
let lastEditAt: number | null = null
|
||||
let activeMs = 0
|
||||
let typedChars = 0
|
||||
let pastedChars = 0
|
||||
let pasteCount = 0
|
||||
let maxPaste = 0
|
||||
let deletedChars = 0
|
||||
let blurCount = 0
|
||||
let initialLen = 0
|
||||
|
||||
function reset(len: number) {
|
||||
startedAt = performance.now()
|
||||
lastEditAt = null
|
||||
activeMs = 0
|
||||
typedChars = 0
|
||||
pastedChars = 0
|
||||
pasteCount = 0
|
||||
maxPaste = 0
|
||||
deletedChars = 0
|
||||
blurCount = 0
|
||||
initialLen = len
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始(或接着)记一道题。编辑器载入代码之后调。
|
||||
*
|
||||
* `traceKey` 没变就**什么都不做** —— 同一道题里切去看提交记录、再切回来,
|
||||
* 编辑器可能会重新挂载,不能因此把这一段的计数清掉。换了题才重新开始。
|
||||
* 切语言不换 key:学生改用另一种语言重写这道题,仍然是同一段做题过程。
|
||||
*/
|
||||
export function beginEditTrace(traceKey: string, len: number) {
|
||||
if (traceKey === key) return
|
||||
key = traceKey
|
||||
reset(len)
|
||||
}
|
||||
|
||||
/** 这一段的快照,附在提交请求上 */
|
||||
export function snapshotEditTrace(collab: boolean): SubmissionTrace {
|
||||
return {
|
||||
activeMs: Math.round(activeMs),
|
||||
sinceOpenMs: Math.round(performance.now() - startedAt),
|
||||
typedChars,
|
||||
pastedChars,
|
||||
pasteCount,
|
||||
maxPaste,
|
||||
deletedChars,
|
||||
blurCount,
|
||||
initialLen,
|
||||
collab,
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交成功之后调:下一条提交只记从这里往后的那一段 */
|
||||
export function restartEditTrace(len: number) {
|
||||
reset(len)
|
||||
}
|
||||
|
||||
function touch() {
|
||||
const now = performance.now()
|
||||
if (lastEditAt !== null && now - lastEditAt <= IDLE_MS)
|
||||
activeMs += now - lastEditAt
|
||||
lastEditAt = now
|
||||
}
|
||||
|
||||
// 只数 hidden 这一个事件:切标签页时 window 的 blur 和 visibilitychange 会一起触发,
|
||||
// 两个都数就是一次记两下。代价是同屏切到别的窗口(页面仍可见)不计,这本来就只是辅助信号。
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
blurCount++
|
||||
// 切走的这段不算活跃,回来之后的第一下编辑重新起算
|
||||
lastEditAt = null
|
||||
})
|
||||
|
||||
/** 挂到题目页的代码编辑器上。同一个实例,别每次渲染新建 —— 那会让编辑器反复重配扩展 */
|
||||
export const editTraceExtensions = [
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged) return
|
||||
for (const tr of update.transactions) {
|
||||
if (!tr.docChanged) continue
|
||||
// 顺序要紧:isUserEvent("input") 也会匹配 "input.paste"
|
||||
const pasted =
|
||||
tr.isUserEvent("input.paste") || tr.isUserEvent("input.drop")
|
||||
const typed = !pasted && tr.isUserEvent("input")
|
||||
const deleted = tr.isUserEvent("delete")
|
||||
if (!pasted && !typed && !deleted) continue
|
||||
|
||||
let inserted = 0
|
||||
let removed = 0
|
||||
tr.changes.iterChanges((fromA, toA, _fromB, _toB, text) => {
|
||||
// 原样替换不算:closeBrackets 越过已有的右括号 / 引号时,是把 `)` 替换成 `)`
|
||||
// 而不是只挪光标(@codemirror/autocomplete 的 handleClose),不排掉的话
|
||||
// 每敲一个右括号就多记一个键入加一个删除
|
||||
if (
|
||||
toA - fromA === text.length &&
|
||||
tr.startState.sliceDoc(fromA, toA) === text.toString()
|
||||
)
|
||||
return
|
||||
removed += toA - fromA
|
||||
inserted += text.length
|
||||
})
|
||||
|
||||
// 选中一段再打字 / 粘贴,被替换掉的那部分也算删除
|
||||
deletedChars += removed
|
||||
if (pasted) {
|
||||
pastedChars += inserted
|
||||
pasteCount++
|
||||
if (inserted > maxPaste) maxPaste = inserted
|
||||
} else if (typed) {
|
||||
typedChars += inserted
|
||||
}
|
||||
touch()
|
||||
}
|
||||
}),
|
||||
]
|
||||
Reference in New Issue
Block a user