评阅页直接运行学生的程序;离开页面时结束正在运行的程序
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 运行区(窗体桌面、提示框、虚拟实训台、编译错误和控制台输出)抽成 RunArea 组件,实验页和评阅页共用
- 评阅页代码区加"运行"标签页:运行学生提交的代码,可以操作窗体和实训台;换学生时自动停止并清空;
  点编译错误跳到对应代码;原来的按钮改为"在新标签页运行"
- 运行会话加 close():离开实验页、评阅页时断开连接,服务器随即结束程序(之前要等 60 分钟超时)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 20:12:31 +08:00
1 parent 91237b85b8
commit 8a92354fe4
4 files changed
+215 -113

No files matched your search

+88 -11
View File
@@ -1,14 +1,17 @@
<script setup lang="ts">
// 评阅一个作业:左边是全班学生(提交状态、自动评分、老师评分),右边是选中学生的代码和界面截图、自动评分结果、
// 规范提示、AI 建议和老师评分。"运行他的程序"在新标签页的实验页里打开学生的代码。
// 评阅一个作业:左边是全班学生(提交状态、自动评分、老师评分),右边是选中学生的界面截图、运行区、代码,
// 以及自动评分结果、规范提示、AI 建议和老师评分。"运行"标签页里直接运行学生的程序(换学生时自动停止);
// "在新标签页运行"打开完整的实验页,地方更大。
// AI 建议只是参考:老师采纳(或修改后保存)才成为老师评分,学生才能看到。
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NEmpty, NInput, NInputNumber, NSpin, NTab, NTabs, NTag, useDialog, useMessage } from 'naive-ui'
import type { AiReview, Assignment, Review, Screenshot, SourceFile, Submission } from '@iot/shared'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import CodeEditor from '../editor/CodeEditor.vue'
import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue'
import { conventionHints } from '@iot/shared'
import { downloadCsv } from '../teacher/csv'
@@ -39,8 +42,9 @@ interface Detail {
submissionCount: number
}
/** 代码区"界面截图"标签页的名字(不会和文件名冲突) */
/** 代码区"界面截图""运行"标签页的名字(不会和 .cs 文件名冲突) */
const SCREENS = '界面截图'
const RUN = '运行'
const props = defineProps<{ assignmentId: string }>()
const route = useRoute()
@@ -48,6 +52,27 @@ const router = useRouter()
const message = useMessage()
const dialog = useDialog()
// ---------- 运行学生的程序 ----------
const session = createRunSession()
const editorRef = ref<InstanceType<typeof CodeEditor>>()
const runState = session.state
const running = computed(() => runState.status === 'compiling' || runState.status === 'running')
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
function runProgram() {
if (!detail.value?.files || running.value) return
void session.run(detail.value.files.map((f) => ({ ...f })))
}
/** 点编译错误:切到对应文件并跳到出错的行 */
async function showDiagnostic(file: string, line: number, column: number) {
activeFile.value = file
await nextTick()
editorRef.value?.reveal(file, line, column)
}
onBeforeUnmount(() => session.close())
const assignment = ref<Assignment | null>(null)
const rows = ref<Row[]>([])
const selectedId = ref<number>()
@@ -88,6 +113,14 @@ onMounted(async () => {
async function select(userId: number | undefined) {
if (userId === undefined) return
// 换学生:停掉上一个学生的程序,清空运行区
if (running.value) await session.stop()
runState.forms = []
runState.messageBoxes = []
runState.diagnostics = []
runState.errors = []
runState.console = ''
const wasRunTab = activeFile.value === RUN
selectedId.value = userId
void router.replace({ query: { student: userId } })
detail.value = await api<Detail>('GET', `/api/teacher/assignments/${props.assignmentId}/students/${userId}`).catch((e: Error) => {
@@ -97,10 +130,12 @@ async function select(userId: number | undefined) {
const manual = assignment.value?.scoring.manual ?? []
scores.value = manual.map((_, i) => detail.value?.review?.scores[i] ?? null)
comment.value = detail.value?.review?.comment ?? ''
// 默认先看界面截图,没有截图时看第一个窗体的代码
activeFile.value = detail.value?.screenshots.length
? SCREENS
: (codeFiles.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? codeFiles.value[0]?.path)
// 正在看"运行"时换学生,留在"运行";否则默认先看界面截图,没有截图时看第一个窗体的代码
activeFile.value = wasRunTab
? RUN
: detail.value?.screenshots.length
? SCREENS
: (codeFiles.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? codeFiles.value[0]?.path)
pollAi()
}
@@ -191,7 +226,7 @@ async function go(step: 1 | -1) {
message.info(step > 0 ? '后面没有已提交的学生了' : '前面没有已提交的学生了')
}
function runProgram() {
function openRunPage() {
if (detail.value?.submission) window.open(router.resolve(`/teacher/submissions/${detail.value.submission.id}/run`).href, '_blank')
}
@@ -280,7 +315,7 @@ async function signOut() {
<span class="spacer" />
<NButton size="small" @click="go(-1)">上一个</NButton>
<NButton size="small" @click="go(1)">下一个</NButton>
<NButton size="small" type="primary" :disabled="!detail.submission" @click="runProgram">运行他的程序</NButton>
<NButton size="small" :disabled="!detail.submission" title="在新标签页里打开完整的实验页,地方更大" @click="openRunPage">在新标签页运行</NButton>
</div>
<NEmpty v-if="!detail.submission" description="还没有提交" class="empty" />
@@ -288,6 +323,7 @@ async function signOut() {
<div class="code">
<NTabs :value="activeFile" type="card" size="small" @update:value="(v: string) => (activeFile = v)">
<NTab v-if="detail.screenshots.length" :name="SCREENS">{{ SCREENS }}</NTab>
<NTab :name="RUN">▶ {{ RUN }}</NTab>
<NTab v-for="f in codeFiles" :key="f.path" :name="f.path">{{ f.path }}</NTab>
</NTabs>
<div v-if="activeFile === SCREENS" class="screens">
@@ -296,7 +332,26 @@ async function signOut() {
<figcaption>{{ s.form }}</figcaption>
</figure>
</div>
<CodeEditor v-show="activeFile !== SCREENS" class="editor" :files="codeFiles" :active-file="activeFile" :diagnostics="[]" read-only />
<div v-show="activeFile === RUN" class="run-pane">
<div class="run-bar">
<NButton size="small" type="primary" :disabled="running" @click="runProgram">▶ 运行</NButton>
<NButton size="small" :disabled="!running" @click="session.stop">■ 停止</NButton>
<NTag size="small" :type="runState.status === 'running' ? 'success' : runState.status === 'compiling' ? 'warning' : 'default'">
{{ STATUS_TEXT[runState.status] }}
</NTag>
<span class="muted">运行的是学生提交的代码,可以操作窗体和实训台</span>
</div>
<RunArea class="run-area-host" :session="session" @diagnostic="showDiagnostic" />
</div>
<CodeEditor
v-show="activeFile !== SCREENS && activeFile !== RUN"
ref="editorRef"
class="editor"
:files="codeFiles"
:active-file="activeFile"
:diagnostics="runState.diagnostics"
read-only
/>
</div>
<div class="side">
@@ -463,6 +518,28 @@ async function signOut() {
font-weight: normal;
}
.run-pane {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid #e5e7eb;
border-top: none;
}
.run-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid #f0f0f0;
}
.run-area-host {
flex: 1;
min-height: 0;
}
.screens {
flex: 1;
display: flex;
+6 -101
View File
@@ -13,12 +13,10 @@ import CodeEditor from '../editor/CodeEditor.vue'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue'
import { captureForms } from '../grading/screenshots'
import TrainingBoard from '../scene/TrainingBoard.vue'
import FormWindow from '../winforms/FormWindow.vue'
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
@@ -91,7 +89,10 @@ function onKeyDown(event: KeyboardEvent) {
}
onMounted(() => window.addEventListener('keydown', onKeyDown, { capture: true }))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture: true }))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
session.close()
})
onMounted(async () => {
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
@@ -473,40 +474,7 @@ function run() {
/>
</section>
<section class="run-area">
<div class="desktop">
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
:form="form.root"
:modal="form.modal"
:offset="index"
:act="session.act"
/>
<MessageBoxDialog
v-for="box in state.messageBoxes"
:key="box.requestId"
:box="box"
@reply="session.replyMessageBox(box.requestId, $event)"
/>
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
</div>
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
<div class="output">
<div
v-for="(d, i) in state.diagnostics"
:key="`d${i}`"
:class="['diag', d.severity]"
title="点击跳到出错的代码"
@click="showDiagnostic(d.file, d.line, d.column)"
>
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
</div>
<pre v-for="(e, i) in state.errors" :key="`e${i}`" class="error">{{ e }}</pre>
<pre v-if="state.console" class="console">{{ state.console }}</pre>
</div>
</section>
<RunArea :session="session" @diagnostic="showDiagnostic" />
</main>
</div>
</template>
@@ -623,67 +591,4 @@ function run() {
.new-form-error {
color: #dc2626;
}
.run-area {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
min-width: 0;
overflow: auto;
}
.desktop {
position: relative;
flex: 1;
min-height: 320px;
background: #2d6aa3;
border-radius: 4px;
/* 窗体比桌面区域大时可以滚动查看 */
overflow: auto;
}
.desktop-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgb(255 255 255 / 0.7);
}
.output {
max-height: 180px;
overflow: auto;
font-size: 12px;
}
.diag {
cursor: pointer;
}
.diag:hover {
text-decoration: underline;
}
.diag.error,
.error {
color: #b91c1c;
}
.diag.warning {
color: #a16207;
}
.hint {
color: #2563eb;
padding-left: 1em;
}
.console {
background: #111827;
color: #e5e7eb;
padding: 6px;
margin: 0;
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
// 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。
// 实验页和老师的评阅页共用。
import TrainingBoard from '../scene/TrainingBoard.vue'
import FormWindow from '../winforms/FormWindow.vue'
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
import type { RunSession } from './session'
const props = defineProps<{ session: RunSession }>()
const emit = defineEmits<{ diagnostic: [file: string, line: number, column: number] }>()
const state = props.session.state
</script>
<template>
<section class="run-area">
<div class="desktop">
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
:form="form.root"
:modal="form.modal"
:offset="index"
:act="session.act"
/>
<MessageBoxDialog
v-for="box in state.messageBoxes"
:key="box.requestId"
:box="box"
@reply="session.replyMessageBox(box.requestId, $event)"
/>
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
</div>
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
<div class="output">
<div
v-for="(d, i) in state.diagnostics"
:key="`d${i}`"
:class="['diag', d.severity]"
title="点击跳到出错的代码"
@click="emit('diagnostic', d.file, d.line, d.column)"
>
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
</div>
<pre v-for="(e, i) in state.errors" :key="`e${i}`" class="error">{{ e }}</pre>
<pre v-if="state.console" class="console">{{ state.console }}</pre>
</div>
</section>
</template>
<style scoped>
.run-area {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
min-width: 0;
overflow: auto;
}
.desktop {
position: relative;
flex: 1;
min-height: 320px;
background: #2d6aa3;
border-radius: 4px;
/* 窗体比桌面区域大时可以滚动查看 */
overflow: auto;
}
.desktop-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgb(255 255 255 / 0.7);
}
.output {
max-height: 180px;
overflow: auto;
font-size: 12px;
}
.diag {
cursor: pointer;
}
.diag:hover {
text-decoration: underline;
}
.diag.error,
.error {
color: #b91c1c;
}
.diag.warning {
color: #a16207;
}
.hint {
color: #2563eb;
padding-left: 1em;
}
.console {
background: #111827;
color: #e5e7eb;
padding: 6px;
margin: 0;
}
</style>
+6 -1
View File
@@ -187,7 +187,12 @@ export function createRunSession() {
}
}
return { state, connect, run, stop, act, replyMessageBox, setBoard, resetBoard }
/** 离开页面时断开连接:服务器随即结束正在运行的程序 */
function close() {
socket?.close()
}
return { state, connect, run, stop, act, replyMessageBox, setBoard, resetBoard, close }
}
export type RunSession = ReturnType<typeof createRunSession>