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:
18
apps/api/src/db/0016_add_submission_trace.sql
Normal file
18
apps/api/src/db/0016_add_submission_trace.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- 提交的编辑过程信号(AI 时代 OJ 设计的第 1 步:过程信号采集),字段含义见 schema.ts 的
|
||||||
|
-- submissionTrace 与契约的 submissionTraceSchema。纯建表,历史提交没有对应行,这是预期的。
|
||||||
|
CREATE TABLE "submission_trace" (
|
||||||
|
"submission_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"active_ms" integer NOT NULL,
|
||||||
|
"since_open_ms" integer NOT NULL,
|
||||||
|
"typed_chars" integer NOT NULL,
|
||||||
|
"pasted_chars" integer NOT NULL,
|
||||||
|
"paste_count" integer NOT NULL,
|
||||||
|
"max_paste" integer NOT NULL,
|
||||||
|
"deleted_chars" integer NOT NULL,
|
||||||
|
"blur_count" integer NOT NULL,
|
||||||
|
"initial_len" integer NOT NULL,
|
||||||
|
"collab" boolean NOT NULL,
|
||||||
|
"since_prev_ms" bigint
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "submission_trace" ADD CONSTRAINT "submission_trace_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
3837
apps/api/src/db/meta/0016_snapshot.json
Normal file
3837
apps/api/src/db/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
|||||||
"when": 1789364546358,
|
"when": 1789364546358,
|
||||||
"tag": "0015_submission_filter_indexes",
|
"tag": "0015_submission_filter_indexes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789817209482,
|
||||||
|
"tag": "0016_add_submission_trace",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -953,6 +953,44 @@ export const submission = pgTable(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交时附带的编辑过程信号,和 submission 一对一。字段含义见契约的
|
||||||
|
* `submissionTraceSchema`,这里只记表本身的取舍:
|
||||||
|
*
|
||||||
|
* - **没有行 ≠ 可疑。** 2026-09 之前的全部历史提交、刷新过页面的、老版本前端交的
|
||||||
|
* 都没有 trace,用它的地方一律把「缺失」当「无数据」。
|
||||||
|
* - 类型化的列而不是一个 jsonb:「可信 AC」和学情热力图要在 SQL 里按这些值筛、聚合。
|
||||||
|
* - `since_prev_ms` 是唯一由**服务端**算的一列(距同一用户同一道题上一次提交),
|
||||||
|
* 客户端伪造不了;这道题的第一次提交为 null。
|
||||||
|
* - CASCADE 挂在 submission 上、不挂 user:它是提交的附属,提交没了它没有意义,
|
||||||
|
* 人是谁顺着 submission 就能查到。同表的 message / problemset_submission 也是这一档。
|
||||||
|
*/
|
||||||
|
export const submissionTrace = pgTable(
|
||||||
|
"submission_trace",
|
||||||
|
{
|
||||||
|
submissionId: text("submission_id").primaryKey().notNull(),
|
||||||
|
activeMs: integer("active_ms").notNull(),
|
||||||
|
sinceOpenMs: integer("since_open_ms").notNull(),
|
||||||
|
typedChars: integer("typed_chars").notNull(),
|
||||||
|
pastedChars: integer("pasted_chars").notNull(),
|
||||||
|
pasteCount: integer("paste_count").notNull(),
|
||||||
|
maxPaste: integer("max_paste").notNull(),
|
||||||
|
deletedChars: integer("deleted_chars").notNull(),
|
||||||
|
blurCount: integer("blur_count").notNull(),
|
||||||
|
initialLen: integer("initial_len").notNull(),
|
||||||
|
collab: boolean().notNull(),
|
||||||
|
// bigint:int4 的毫秒数只够 24.8 天,隔一个假期回来重交就溢出了
|
||||||
|
sincePrevMs: bigint("since_prev_ms", { mode: "number" }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.submissionId],
|
||||||
|
foreignColumns: [submission.id],
|
||||||
|
name: "submission_trace_submission_id_fk_submission_id",
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
export const tutorial = pgTable(
|
export const tutorial = pgTable(
|
||||||
"tutorial",
|
"tutorial",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type SubmissionDetail,
|
type SubmissionDetail,
|
||||||
type SubmissionList,
|
type SubmissionList,
|
||||||
type SubmissionListItem,
|
type SubmissionListItem,
|
||||||
|
type SubmissionTrace,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
@@ -59,6 +60,38 @@ function objectValue(value: unknown): Record<string, unknown> {
|
|||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落编辑过程信号。**失败只记日志、不影响提交** —— 这是附带的统计数据,
|
||||||
|
* 提交已经进库了,不能因为它回一个 500 让学生以为没交上。
|
||||||
|
*
|
||||||
|
* `since_prev_ms` 在同一条 INSERT 里用子查询算,排掉刚插进去的这条自己;
|
||||||
|
* 两次提交并发到达时也各自取到的是对方之外的最近一条。这道题第一次提交时
|
||||||
|
* `max()` 为 null,列就是 null。
|
||||||
|
*/
|
||||||
|
async function saveTrace(
|
||||||
|
submissionId: string,
|
||||||
|
userId: number,
|
||||||
|
problemId: number,
|
||||||
|
createTime: string,
|
||||||
|
trace: SubmissionTrace,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await db.insert(schema.submissionTrace).values({
|
||||||
|
submissionId,
|
||||||
|
...trace,
|
||||||
|
sincePrevMs: sql`(
|
||||||
|
select (extract(epoch from ${createTime}::timestamptz - max(${schema.submission.createTime})) * 1000)::bigint
|
||||||
|
from ${schema.submission}
|
||||||
|
where ${schema.submission.userId} = ${userId}
|
||||||
|
and ${schema.submission.problemId} = ${problemId}
|
||||||
|
and ${schema.submission.id} <> ${submissionId}
|
||||||
|
)`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to record submission trace", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
||||||
const parsed = createSubmissionRequestSchema.safeParse(
|
const parsed = createSubmissionRequestSchema.safeParse(
|
||||||
await c.req.json().catch(() => null),
|
await c.req.json().catch(() => null),
|
||||||
@@ -168,6 +201,15 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
|||||||
contestId,
|
contestId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (parsed.data.trace)
|
||||||
|
await saveTrace(
|
||||||
|
submissionId,
|
||||||
|
user.id,
|
||||||
|
problem.id,
|
||||||
|
createTime,
|
||||||
|
parsed.data.trace,
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await judgeQueue.add(
|
await judgeQueue.add(
|
||||||
"judge",
|
"judge",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import CodeEditor from "shared/components/CodeEditor.vue"
|
|||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import type { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
|
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -34,6 +35,10 @@ onMounted(() => {
|
|||||||
problem.value!.template[codeStore.code.language] ||
|
problem.value!.template[codeStore.code.language] ||
|
||||||
SOURCES[codeStore.code.language],
|
SOURCES[codeStore.code.language],
|
||||||
)
|
)
|
||||||
|
beginEditTrace(
|
||||||
|
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||||
|
codeStore.code.value.length,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const changeCode = (v: string) => {
|
const changeCode = (v: string) => {
|
||||||
@@ -58,6 +63,7 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
v-model:value="codeStore.code.value"
|
v-model:value="codeStore.code.value"
|
||||||
:language="codeStore.code.language"
|
:language="codeStore.code.language"
|
||||||
:height="editorHeight"
|
:height="editorHeight"
|
||||||
|
:extra-extensions="editTraceExtensions"
|
||||||
@update:model-value="changeCode"
|
@update:model-value="changeCode"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
|||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import type { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
|
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
const FlowchartEditor = defineAsyncComponent(
|
const FlowchartEditor = defineAsyncComponent(
|
||||||
@@ -98,6 +99,11 @@ function loadCode() {
|
|||||||
problem.value!.template[codeStore.code.language] ||
|
problem.value!.template[codeStore.code.language] ||
|
||||||
SOURCES[codeStore.code.language],
|
SOURCES[codeStore.code.language],
|
||||||
)
|
)
|
||||||
|
// 换了题才重新计数,同一道题重复 loadCode(协作结束读回草稿)是接着记
|
||||||
|
beginEditTrace(
|
||||||
|
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||||
|
codeStore.code.value.length,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadCode)
|
onMounted(loadCode)
|
||||||
@@ -151,6 +157,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
|||||||
:language="codeStore.code.language"
|
:language="codeStore.code.language"
|
||||||
:problem-id="problem!._id"
|
:problem-id="problem!._id"
|
||||||
:height="editorHeight"
|
:height="editorHeight"
|
||||||
|
:extra-extensions="editTraceExtensions"
|
||||||
@update:model-value="changeCode"
|
@update:model-value="changeCode"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import SubmissionResult from "./SubmissionResult.vue"
|
|||||||
import { getSubmitButtonState } from "./submitButtonState"
|
import { getSubmitButtonState } from "./submitButtonState"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
import { useCollabStore } from "shared/store/collab"
|
||||||
|
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
|
||||||
import {
|
import {
|
||||||
checkPythonSyntax,
|
checkPythonSyntax,
|
||||||
prefetchPythonSyntaxChecker,
|
prefetchPythonSyntaxChecker,
|
||||||
@@ -24,6 +26,7 @@ const ProblemReaction = defineAsyncComponent(
|
|||||||
|
|
||||||
// ==================== 基础状态 ====================
|
// ==================== 基础状态 ====================
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const collabStore = useCollabStore()
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const { problem } = storeToRefs(problemStore)
|
const { problem } = storeToRefs(problemStore)
|
||||||
@@ -147,6 +150,11 @@ async function submit() {
|
|||||||
problemId: problem.value!.id,
|
problemId: problem.value!.id,
|
||||||
language: codeStore.code.language,
|
language: codeStore.code.language,
|
||||||
code: codeStore.code.value,
|
code: codeStore.code.value,
|
||||||
|
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere 同一个口径
|
||||||
|
trace: snapshotEditTrace(
|
||||||
|
collabStore.room !== null &&
|
||||||
|
collabStore.room.problemId === problem.value!._id,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if (contestID) {
|
if (contestID) {
|
||||||
data.contestId = parseInt(contestID)
|
data.contestId = parseInt(contestID)
|
||||||
@@ -161,6 +169,8 @@ async function submit() {
|
|||||||
try {
|
try {
|
||||||
const res = await submitCode(data)
|
const res = await submitCode(data)
|
||||||
console.log(`[Submit] 代码已提交: ID=${res.submissionId}`)
|
console.log(`[Submit] 代码已提交: ID=${res.submissionId}`)
|
||||||
|
// 交上了才清零;被限流 / 网络失败的话这一段接着记,下次提交一起报
|
||||||
|
restartEditTrace(codeStore.code.value.length)
|
||||||
|
|
||||||
// 3. 启动冷却 + 监控
|
// 3. 启动冷却 + 监控
|
||||||
startCooldown()
|
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()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
@@ -4,6 +4,7 @@ import { python } from "@codemirror/lang-python"
|
|||||||
import { sql, SQLite } from "@codemirror/lang-sql"
|
import { sql, SQLite } from "@codemirror/lang-sql"
|
||||||
import { bracketMatching } from "@codemirror/language"
|
import { bracketMatching } from "@codemirror/language"
|
||||||
import { Codemirror } from "vue-codemirror"
|
import { Codemirror } from "vue-codemirror"
|
||||||
|
import type { Extension } from "@codemirror/state"
|
||||||
import {
|
import {
|
||||||
autocompletion,
|
autocompletion,
|
||||||
closeBrackets,
|
closeBrackets,
|
||||||
@@ -21,6 +22,8 @@ interface Props {
|
|||||||
height?: string
|
height?: string
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
/** 追加的 CodeMirror 扩展。传一个稳定的数组实例,每次渲染新建会让编辑器反复重配 */
|
||||||
|
extraExtensions?: Extension[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -29,6 +32,7 @@ const {
|
|||||||
height = "100%",
|
height = "100%",
|
||||||
readonly = false,
|
readonly = false,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
|
extraExtensions = [],
|
||||||
} = defineProps<Props>()
|
} = defineProps<Props>()
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
|
|
||||||
@@ -49,6 +53,7 @@ const extensions = computed(() => [
|
|||||||
override: [enhanceCompletion(language), completeAnyWord],
|
override: [enhanceCompletion(language), completeAnyWord],
|
||||||
}),
|
}),
|
||||||
isDark.value ? oneDark : smoothy,
|
isDark.value ? oneDark : smoothy,
|
||||||
|
...extraExtensions,
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
completeAnyWord,
|
completeAnyWord,
|
||||||
} from "@codemirror/autocomplete"
|
} from "@codemirror/autocomplete"
|
||||||
import type { EditorView } from "@codemirror/view"
|
import type { EditorView } from "@codemirror/view"
|
||||||
|
import type { Extension } from "@codemirror/state"
|
||||||
import type { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
import { oneDark } from "../themes/oneDark"
|
import { oneDark } from "../themes/oneDark"
|
||||||
import { smoothy } from "../themes/smoothy"
|
import { smoothy } from "../themes/smoothy"
|
||||||
@@ -26,6 +27,8 @@ interface Props {
|
|||||||
height?: string
|
height?: string
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
/** 追加的 CodeMirror 扩展。传一个稳定的数组实例,每次渲染新建会让编辑器反复重配 */
|
||||||
|
extraExtensions?: Extension[]
|
||||||
/**
|
/**
|
||||||
* 当前这个编辑器属于哪道题(题目的展示 ID)。
|
* 当前这个编辑器属于哪道题(题目的展示 ID)。
|
||||||
*
|
*
|
||||||
@@ -43,6 +46,7 @@ const {
|
|||||||
height = "100%",
|
height = "100%",
|
||||||
readonly = false,
|
readonly = false,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
|
extraExtensions = [],
|
||||||
problemId = "",
|
problemId = "",
|
||||||
} = defineProps<Props>()
|
} = defineProps<Props>()
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
@@ -59,6 +63,7 @@ const extensions = computed(() => [
|
|||||||
override: [enhanceCompletion(language), completeAnyWord],
|
override: [enhanceCompletion(language), completeAnyWord],
|
||||||
}),
|
}),
|
||||||
getInitialExtension(),
|
getInitialExtension(),
|
||||||
|
...extraExtensions,
|
||||||
])
|
])
|
||||||
|
|
||||||
interface EditorReadyPayload {
|
interface EditorReadyPayload {
|
||||||
|
|||||||
@@ -79,6 +79,36 @@ export const statisticInfoSchema = z.looseObject({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑过程的聚合信号,落进 `submission_trace`。**只有计数,不含任何按键内容。**
|
||||||
|
*
|
||||||
|
* 口径是「自上次提交以来」的增量(前端每次提交成功后清零,切题也清零),
|
||||||
|
* 所以同一道题连交几次,每条提交各记各的那一段。
|
||||||
|
*
|
||||||
|
* 全部来自客户端,**可以伪造** —— 这是接受了的:它只用来给「可信 AC」加权、
|
||||||
|
* 给老师提示「建议关注」,不单独判任何事。服务端自己算的间隔另见 `since_prev_ms`。
|
||||||
|
*/
|
||||||
|
export const submissionTraceSchema = z.object({
|
||||||
|
/** 活跃编辑时长:相邻两次编辑间隔不超过 60 秒才累加,页面不可见时不计 */
|
||||||
|
activeMs: z.number().int().min(0).max(1e8),
|
||||||
|
/** 打开这道题(或上次提交)到这次提交的墙钟时长 */
|
||||||
|
sinceOpenMs: z.number().int().min(0).max(1e9),
|
||||||
|
/** 键入、输入法上屏、补全插入的字符数 */
|
||||||
|
typedChars: z.number().int().min(0).max(1e7),
|
||||||
|
/** 粘贴、从外部拖入的字符数 */
|
||||||
|
pastedChars: z.number().int().min(0).max(1e7),
|
||||||
|
pasteCount: z.number().int().min(0).max(1e5),
|
||||||
|
/** 单次最大粘贴的字符数 */
|
||||||
|
maxPaste: z.number().int().min(0).max(1e7),
|
||||||
|
deletedChars: z.number().int().min(0).max(1e7),
|
||||||
|
/** 页面切到后台的次数(切标签页、切窗口、最小化)。只作辅助,别单独拿来说事 */
|
||||||
|
blurCount: z.number().int().min(0).max(1e5),
|
||||||
|
/** 这一段开始时编辑器里已有的字符数(本地草稿 / 模板 / 上次提交后的代码) */
|
||||||
|
initialLen: z.number().int().min(0).max(1e7),
|
||||||
|
/** 提交时这道题正在课堂协作中。老师替学生交的那条也会是 true,统计时要排掉 */
|
||||||
|
collab: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
export const createSubmissionRequestSchema = z.object({
|
export const createSubmissionRequestSchema = z.object({
|
||||||
problemId: z.number().int().positive(),
|
problemId: z.number().int().positive(),
|
||||||
/**
|
/**
|
||||||
@@ -102,6 +132,13 @@ export const createSubmissionRequestSchema = z.object({
|
|||||||
* 所以这里带错了顶多是标记不准,不会影响成绩。
|
* 所以这里带错了顶多是标记不准,不会影响成绩。
|
||||||
*/
|
*/
|
||||||
problemSetId: z.number().int().positive().optional(),
|
problemSetId: z.number().int().positive().optional(),
|
||||||
|
/**
|
||||||
|
* 编辑过程信号,见 submissionTraceSchema。**坏了就当没带**(`.catch`):
|
||||||
|
* 整个请求体是一把 safeParse,这里要是能 400,一份附带的统计数据就能挡住
|
||||||
|
* 学生交作业。刷新过页面、老版本前端、脚本提交都会没有它,那是「无数据」,
|
||||||
|
* 不是「可疑」。
|
||||||
|
*/
|
||||||
|
trace: submissionTraceSchema.optional().catch(undefined),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createSubmissionResponseSchema = z.object({
|
export const createSubmissionResponseSchema = z.object({
|
||||||
@@ -384,6 +421,7 @@ export const formatCodeRequestSchema = z.object({
|
|||||||
export const formatCodeResponseSchema = z.object({ code: z.string() })
|
export const formatCodeResponseSchema = z.object({ code: z.string() })
|
||||||
|
|
||||||
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
|
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
|
||||||
|
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
|
||||||
export type CreateSubmissionRequest = z.infer<
|
export type CreateSubmissionRequest = z.infer<
|
||||||
typeof createSubmissionRequestSchema
|
typeof createSubmissionRequestSchema
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user