Compare commits
5
Commits
972dac3776
...
b4afcbd47f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4afcbd47f | ||
|
|
1026b8f9a2 | ||
|
|
03985fcbf9 | ||
|
|
11ff340110 | ||
|
|
b5d2f1589e |
No files matched your search
@@ -516,8 +516,44 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
.groupBy(schema.submission.userId)
|
.groupBy(schema.submission.userId)
|
||||||
: [],
|
: [],
|
||||||
])
|
])
|
||||||
|
/**
|
||||||
|
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
|
||||||
|
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」
|
||||||
|
*/
|
||||||
|
const drawn =
|
||||||
|
ids.length && userIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
userId: schema.flowchartSubmission.userId,
|
||||||
|
problemId: schema.flowchartSubmission.problemId,
|
||||||
|
attempts: sql<number>`count(*) filter (where ${gte(schema.flowchartSubmission.createTime, start)})::int`,
|
||||||
|
firstPassedAt: sql<
|
||||||
|
string | null
|
||||||
|
>`min(${schema.flowchartSubmission.createTime}) filter (where ${and(eq(schema.flowchartSubmission.status, 2), inArray(schema.flowchartSubmission.aiGrade, FLOWCHART_PASS_GRADES))})`,
|
||||||
|
lastAt: sql<
|
||||||
|
string | null
|
||||||
|
>`max(${schema.flowchartSubmission.createTime}) filter (where ${gte(schema.flowchartSubmission.createTime, start)})`,
|
||||||
|
})
|
||||||
|
.from(schema.flowchartSubmission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.flowchartSubmission.userId, userIds),
|
||||||
|
inArray(schema.flowchartSubmission.problemId, ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
|
||||||
|
: []
|
||||||
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
|
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
||||||
|
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手
|
||||||
|
for (const row of drawn) {
|
||||||
|
if (!row.lastAt) continue
|
||||||
|
const known = lastByUser.get(row.userId)
|
||||||
|
if (!known || Date.parse(row.lastAt) > Date.parse(known)) lastByUser.set(row.userId, row.lastAt)
|
||||||
|
}
|
||||||
|
const earliest = (a: string | null | undefined, b: string | null | undefined) =>
|
||||||
|
!a ? (b ?? null) : !b ? a : Date.parse(a) <= Date.parse(b) ? a : b
|
||||||
const attendedByUser = new Map(attendance.map((row) => [row.userId, row.days]))
|
const attendedByUser = new Map(attendance.map((row) => [row.userId, row.days]))
|
||||||
|
|
||||||
return success(c, {
|
return success(c, {
|
||||||
@@ -536,11 +572,14 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
username: student.username,
|
username: student.username,
|
||||||
realName: student.realName ?? null,
|
realName: student.realName ?? null,
|
||||||
cells: ids.map((id) => {
|
cells: ids.map((id) => {
|
||||||
const cell = cellByKey.get(`${student.userId}:${id}`)
|
const key = `${student.userId}:${id}`
|
||||||
|
const cell = cellByKey.get(key)
|
||||||
|
const flow = drawnByKey.get(key)
|
||||||
|
const acceptedAt = earliest(cell?.firstAcceptedAt, flow?.firstPassedAt)
|
||||||
return {
|
return {
|
||||||
status: !cell ? "none" : cell.firstAcceptedAt ? "accepted" : "tried",
|
status: !cell && !flow ? "none" : acceptedAt ? "accepted" : "tried",
|
||||||
attempts: cell?.attempts ?? 0,
|
attempts: (cell?.attempts ?? 0) + (flow?.attempts ?? 0),
|
||||||
acceptedAt: cell?.firstAcceptedAt ?? null,
|
acceptedAt,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
lastSubmitAt: lastByUser.get(student.userId) ?? null,
|
lastSubmitAt: lastByUser.get(student.userId) ?? null,
|
||||||
|
|||||||
@@ -642,6 +642,11 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
|||||||
return { visible, hidden: rows.length - visible.length }
|
return { visible, hidden: rows.length - visible.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* current / history:前端从 2026-09-28 起改用下面的 scores + GET /flowcharts/:id,不再调这两个。
|
||||||
|
* 先留着给上线那一刻还开着旧页面的浏览器(机房里一节课都不刷新是常态),下一次清理时删掉,
|
||||||
|
* 连同契约里的 flowchartCurrentSchema / flowchartDetailSchema。
|
||||||
|
*/
|
||||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||||
|
|||||||
@@ -136,10 +136,21 @@ const toggleHelp = () => {
|
|||||||
collabStore.requestHelp(problem.value!._id, codeStore.code.language)
|
collabStore.requestHelp(problem.value!._id, codeStore.code.language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右栏窄于 600px(学生把左栏拖宽看长题面、宽 SQL 表时):工具栏折成两行会把编辑器往下挤,
|
||||||
|
* 所以次要的按钮收进「更多」、「写代码 / 画流程图」缩短一点(设计文档 5.4)。语言下拉不收:
|
||||||
|
* 窄于 120px 时图标和「Python」会被挤成两行
|
||||||
|
*/
|
||||||
|
const toolbarRef = useTemplateRef<{ $el: HTMLElement }>("toolbarRef")
|
||||||
|
const { width: toolbarWidth } = useElementSize(() => toolbarRef.value?.$el)
|
||||||
|
const narrow = computed(() => isDesktop.value && toolbarWidth.value > 0 && toolbarWidth.value < 600)
|
||||||
|
/** 「课堂统计」常驻在工具栏上:桌面、而且放得下 */
|
||||||
|
const statisticsInline = computed(() => isDesktop.value && !narrow.value)
|
||||||
|
|
||||||
const menuOptions = computed<DropdownOption[]>(() => {
|
const menuOptions = computed<DropdownOption[]>(() => {
|
||||||
const options: DropdownOption[] = []
|
const options: DropdownOption[] = []
|
||||||
// 移动端额外收纳桌面端常驻的「课堂统计」。「本题提交」挪到了「我的提交」抽屉的底部
|
// 放不下时(手机、右栏太窄)收进来的「课堂统计」。「本题提交」挪到了「我的提交」抽屉的底部
|
||||||
if (!isDesktop.value && userStore.isTeacherOrAbove) {
|
if (!statisticsInline.value && userStore.isTeacherOrAbove) {
|
||||||
options.push({
|
options.push({
|
||||||
label: "课堂统计",
|
label: "课堂统计",
|
||||||
key: "statistics",
|
key: "statistics",
|
||||||
@@ -261,11 +272,11 @@ const goEdit = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex align="center">
|
<n-flex ref="toolbarRef" align="center" :size="narrow ? 8 : 12">
|
||||||
<!-- 协作中的老师不会落到画图:求助入口本身就排掉了流程图 -->
|
<!-- 协作中的老师不会落到画图:求助入口本身就排掉了流程图 -->
|
||||||
<n-radio-group v-if="canDraw && !showCollabBar" v-model:value="mode" :size="buttonSize">
|
<n-radio-group v-if="canDraw && !showCollabBar" v-model:value="mode" :size="buttonSize">
|
||||||
<n-radio-button value="code">写代码</n-radio-button>
|
<n-radio-button value="code">{{ narrow ? "代码" : "写代码" }}</n-radio-button>
|
||||||
<n-radio-button value="draw">画流程图</n-radio-button>
|
<n-radio-button value="draw">{{ narrow ? "流程图" : "画流程图" }}</n-radio-button>
|
||||||
</n-radio-group>
|
</n-radio-group>
|
||||||
|
|
||||||
<!-- 协作中编辑器的语言跟着学生走,这个选择器改了也不会生效,索性禁掉 -->
|
<!-- 协作中编辑器的语言跟着学生走,这个选择器改了也不会生效,索性禁掉 -->
|
||||||
@@ -295,21 +306,23 @@ const goEdit = () => {
|
|||||||
<SubmitCode v-else-if="isDesktop" />
|
<SubmitCode v-else-if="isDesktop" />
|
||||||
|
|
||||||
<n-button
|
<n-button
|
||||||
v-if="isDesktop && userStore.isTeacherOrAbove"
|
v-if="statisticsInline && userStore.isTeacherOrAbove"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
@click="statisticPanel = true"
|
@click="statisticPanel = true"
|
||||||
>
|
>
|
||||||
课堂统计
|
课堂统计
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上课堂统计 -->
|
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 -->
|
||||||
<n-dropdown
|
<n-dropdown
|
||||||
v-if="menuOptions.length"
|
v-if="menuOptions.length"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
:options="menuOptions"
|
:options="menuOptions"
|
||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
>
|
>
|
||||||
<n-button :size="buttonSize">更多</n-button>
|
<n-button :size="buttonSize" :title="narrow ? '更多' : undefined">
|
||||||
|
{{ narrow ? "⋯" : "更多" }}
|
||||||
|
</n-button>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
|
|
||||||
<n-button
|
<n-button
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
/**
|
||||||
|
* 「做完」的实心绿上用什么字色。暗色主题的成功色是浅薄荷绿,白字几乎看不见 ——
|
||||||
|
* 和 Naive 自己的主按钮一样,暗色下换深色字
|
||||||
|
*/
|
||||||
|
const isDark = useDark()
|
||||||
|
const doneText = computed(() => (isDark.value ? "rgba(0, 0, 0, 0.85)" : "#fff"))
|
||||||
|
|
||||||
const STATUS_TEXT = { done: "做完了", tried: "做过,还没对", none: "还没做" } as const
|
const STATUS_TEXT = { done: "做完了", tried: "做过,还没对", none: "还没做" } as const
|
||||||
|
|
||||||
@@ -94,7 +100,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip.done {
|
.chip.done {
|
||||||
color: #fff;
|
color: v-bind(doneText);
|
||||||
background-color: v-bind("theme.successColor");
|
background-color: v-bind("theme.successColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { findChinesePunctuation } from "oj/problem/utils/chinesePunctuation"
|
import { findChinesePunctuation } from "oj/problem/utils/chinesePunctuation"
|
||||||
import { applyFixes, clearErrorMark, currentCode, showErrorMark } from "oj/problem/utils/errorMark"
|
import {
|
||||||
|
applyFixes,
|
||||||
|
clearErrorMark,
|
||||||
|
currentCode,
|
||||||
|
showErrorMark,
|
||||||
|
showPunctuationMarks,
|
||||||
|
} from "oj/problem/utils/errorMark"
|
||||||
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
|
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +61,9 @@ watch(
|
|||||||
fixedCount.value = null
|
fixedCount.value = null
|
||||||
if (ex?.line) showErrorMark(ex.line, ex.sourceLine, ex.caret)
|
if (ex?.line) showErrorMark(ex.line, ex.sourceLine, ex.caret)
|
||||||
else clearErrorMark()
|
else clearErrorMark()
|
||||||
|
// 中文标点一处不漏地标出来,和按钮上的「N 处」对得上
|
||||||
|
const code = ex?.punctuation ? currentCode() : null
|
||||||
|
if (code !== null) showPunctuationMarks(findChinesePunctuation(code))
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ const canTrial = computed(() => {
|
|||||||
<FlowchartResult v-if="drawing" />
|
<FlowchartResult v-if="drawing" />
|
||||||
<div v-show="!drawing" class="result-pane">
|
<div v-show="!drawing" class="result-pane">
|
||||||
<n-radio-group v-if="canTrial" v-model:value="resultSegment" size="small" class="segments">
|
<n-radio-group v-if="canTrial" v-model:value="resultSegment" size="small" class="segments">
|
||||||
<n-radio-button value="submit">提交结果</n-radio-button>
|
<!-- 语法没过就没交上去:那一段不叫「提交结果」 -->
|
||||||
|
<n-radio-button value="submit">{{
|
||||||
|
syntaxErrorInfo ? "交之前检查" : "提交结果"
|
||||||
|
}}</n-radio-button>
|
||||||
<n-radio-button value="samples">运行例子</n-radio-button>
|
<n-radio-button value="samples">运行例子</n-radio-button>
|
||||||
<n-radio-button value="custom">自己输入</n-radio-button>
|
<n-radio-button value="custom">自己输入</n-radio-button>
|
||||||
</n-radio-group>
|
</n-radio-group>
|
||||||
@@ -53,7 +56,7 @@ const canTrial = computed(() => {
|
|||||||
<!-- 提交结果用 v-show:切去看运行例子时不卸载,挂着的错误说明在编辑器里标着红 -->
|
<!-- 提交结果用 v-show:切去看运行例子时不卸载,挂着的错误说明在编辑器里标着红 -->
|
||||||
<div v-show="resultSegment === 'submit' || !canTrial">
|
<div v-show="resultSegment === 'submit' || !canTrial">
|
||||||
<n-flex v-if="syntaxErrorInfo" vertical>
|
<n-flex v-if="syntaxErrorInfo" vertical>
|
||||||
<n-alert type="warning" title="代码有语法错误,还没有提交" />
|
<n-alert type="warning" title="代码有语法错误,还没有交上去" />
|
||||||
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<template v-else-if="submission">
|
<template v-else-if="submission">
|
||||||
|
|||||||
@@ -33,6 +33,36 @@ function build(state: EditorState, mark: Mark): DecorationSet {
|
|||||||
return Decoration.set(ranges)
|
return Decoration.set(ranges)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中文标点的标记:报错只点名第一处,但「把中文标点都换成英文(N 处)」说的是 N 处 ——
|
||||||
|
* 编辑器里只看得到一处的话,学生对不上那个 N,也不知道一键替换会改哪里。
|
||||||
|
* 字符串和注释里的不标(那是合法的,见 chinesePunctuation.ts)
|
||||||
|
*/
|
||||||
|
const setPunctuation = StateEffect.define<PunctuationFix[]>()
|
||||||
|
const punctuationDeco = Decoration.mark({ class: "cm-punctuation-mark" })
|
||||||
|
|
||||||
|
const punctuationField = StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(deco, tr) {
|
||||||
|
deco = deco.map(tr.changes)
|
||||||
|
for (const effect of tr.effects) {
|
||||||
|
if (effect.is(setPunctuation)) {
|
||||||
|
const max = tr.state.doc.length
|
||||||
|
deco = Decoration.set(
|
||||||
|
effect.value
|
||||||
|
.filter((fix) => fix.to <= max && fix.to > fix.from)
|
||||||
|
.map((fix) => punctuationDeco.range(fix.from, fix.to)),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 清错误标记的时候(下一次提交、一键替换)一起清
|
||||||
|
if (effect.is(setMark) && effect.value === null) deco = Decoration.none
|
||||||
|
}
|
||||||
|
return deco
|
||||||
|
},
|
||||||
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
|
})
|
||||||
|
|
||||||
const markField = StateField.define<DecorationSet>({
|
const markField = StateField.define<DecorationSet>({
|
||||||
create: () => Decoration.none,
|
create: () => Decoration.none,
|
||||||
update(deco, tr) {
|
update(deco, tr) {
|
||||||
@@ -68,9 +98,19 @@ const theme = EditorView.baseTheme({
|
|||||||
textUnderlineOffset: "3px",
|
textUnderlineOffset: "3px",
|
||||||
backgroundColor: "rgba(208, 48, 80, 0.18)",
|
backgroundColor: "rgba(208, 48, 80, 0.18)",
|
||||||
},
|
},
|
||||||
|
".cm-punctuation-mark": {
|
||||||
|
backgroundColor: "rgba(240, 160, 32, 0.35)",
|
||||||
|
outline: "1px solid rgba(240, 160, 32, 0.8)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const errorMarkExtensions = [markField, tracker, theme]
|
export const errorMarkExtensions = [markField, punctuationField, tracker, theme]
|
||||||
|
|
||||||
|
/** 标出这些中文标点(位置是整篇代码里的偏移,来自 findChinesePunctuation) */
|
||||||
|
export function showPunctuationMarks(fixes: PunctuationFix[]) {
|
||||||
|
current?.dispatch({ effects: setPunctuation.of(fixes) })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标出第 `line` 行并滚过去。`sourceLine` 是报错里打出来的那一行(去了行首空格),
|
* 标出第 `line` 行并滚过去。`sourceLine` 是报错里打出来的那一行(去了行首空格),
|
||||||
|
|||||||
Reference in new issue
Block a user