试用前走查的修改:报错看得见、学生找得到作业、运行区和设计器的若干体验问题
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 运行区:编译错误、运行异常、控制台输出和保存的图片移到窗体下面,不再被实训台挤成 0 高;缩放和放大按钮移到桌面上方,不压窗体的关闭按钮;放大后也能停止程序;点停止后清掉窗体
- 运行异常:常见 .NET 异常配中文说明,显示出错的文件、行号和方法,点击跳到代码
- 运行中的下拉列表挂到页面最外层,能伸出窗体和分组框(小窗体也能选到 COM4、COM5)
- 学生登录后进"我的作业";任务面板从工具栏下面开始,不挡导航;记住上次做的实验,从数据库页面返回、恢复数据时默认用它
- 代码页和设计页左栏都占 3/5,文件标签放不下时换行;实验选择框加宽;关闭粘性滚动
- 设计器:双击生成的事件方法带缩进;双击工具箱放进选中控件所在的容器并错开位置;新控件比容器宽时按比例缩小;无边框窗体不画标题栏
- 中文界面:代码编辑器(查找替换等)、属性窗口的颜色选项
- SQL:DATEADD、DATEDIFF、DATEPART 的日期单位(day、hour)解析成 IdentifierLiteral 时报"不支持",补上
- 项目 8 的预置环境数据改成建库那天之前的 7 天;p6-t3 评分项改为"按日期查询环境数据"
- 没有 AI 建议时不显示"全部采纳 AI 建议"和相关说明;导出 CSV 只给纯数字的文字加制表符

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 08:49:42 +08:00
1 parent da623fa648
commit f6024dc113
30 files changed
+537 -255

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ export async function login(username: string, password: string) {
const result = await api<Tokens & { user: User }>('POST', '/api/login', { username, password })
setTokens(result)
currentUser.value = result.user
return result.user
}
export async function logout() {
+34 -9
View File
@@ -373,15 +373,24 @@ function onDrop(event: DragEvent) {
place(d, controlDef(type), x, y)
}
/** 双击工具箱:放在选中的容器(或窗体)左上角 */
/** 双击工具箱:放在选中的容器(选中的是普通控件时放进它所在的容器)左上角,已有控件在那里就往右下错开 */
function onToolDoubleClick(def: ControlDef) {
const d = parsed.value.doc
if (!d) return
// 直接放进选中的容器,不按坐标判断(左上角可能正好压在另一个分组框上)
const container = !def.component && selected.value && isContainerType(typeOf(d, selected.value) ?? '') ? selected.value : ''
const result = addControl(d, def, container, { x: 12, y: container ? 20 : 12 })
// 直接放进容器,不按坐标判断(左上角可能正好压在另一个分组框上)
let container = ''
if (!def.component && selected.value) {
container = isContainerType(typeOf(d, selected.value) ?? '') ? selected.value : (listControls(d).find((c) => c.name === selected.value)?.parent ?? '')
}
const location = { x: 12, y: container ? 20 : 12 }
const taken = childrenOf(d, container).map((child) => localRect(d, child))
while (taken.some((r) => r.x === location.x && r.y === location.y)) {
location.x += 12
location.y += 12
}
const result = addControl(d, def, container, location)
selected.value = result.name
commit(result.doc)
commit(fitInside(result.doc, result.name, container))
}
function place(d: DesignerDocument, def: ControlDef, x: number, y: number) {
@@ -389,7 +398,15 @@ function place(d: DesignerDocument, def: ControlDef, x: number, y: number) {
const origin = container ? absRect(d, container) : { x: 0, y: 0 }
const result = addControl(d, def, container, { x: x - origin.x, y: y - origin.y })
selected.value = result.name
commit(result.doc)
commit(def.component ? result.doc : fitInside(result.doc, result.name, container))
}
/** 新控件比容器宽(LED 显示屏默认 800 宽,新窗体只有 300):按比例缩小到右边留出边距,不用学生再去改大小 */
function fitInside(d: DesignerDocument, name: string, container: string): DesignerDocument {
const rect = localRect(d, name)
const room = (container ? localRect(d, container).width : clientSize.value.width) - rect.x - 12
if (rect.width <= room || room < 40) return d
return setProp(d, name, 'Size', { kind: 'size', width: room, height: Math.max(20, Math.round((rect.height * room) / rect.width)) })
}
// ---------- 属性窗口的回调 ----------
@@ -469,6 +486,7 @@ function onComponentDoubleClick(name: string) {
}
const formTitle = computed(() => String(tree.value?.props.Text ?? ''))
const borderless = computed(() => tree.value?.props.FormBorderStyle === 'None')
const formStyle = computed(() => ({
width: `${clientSize.value.width}px`,
height: `${clientSize.value.height}px`,
@@ -511,7 +529,9 @@ const noop = () => {}
<div class="actions">
<button :disabled="!canUndo" title="撤销(Ctrl+Z)" @click="emit('undo')">↶ 撤销</button>
<button :disabled="!canRedo" title="重做(Ctrl+Y)" @click="emit('redo')">↷ 重做</button>
<span class="tips">拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除</span>
<span class="tips" title="拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除"
>拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除</span
>
<select
class="zoom"
title="窗体显示的大小(不影响窗体的实际大小)"
@@ -524,8 +544,9 @@ const noop = () => {}
</select>
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">查看生成的代码</button>
</div>
<div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
<div class="titlebar" @pointerdown="selected = ''">
<div class="window" :class="{ selected: selected === '', borderless }" :style="zoom !== 1 ? { zoom } : undefined">
<!-- 无边框窗体和 VS 设计器里一样不画标题栏,点窗体空白处选中窗体 -->
<div v-if="!borderless" class="titlebar" @pointerdown="selected = ''">
<span class="title">{{ displayText(formTitle) }}</span>
<span class="buttons">─ ☐ ✕</span>
</div>
@@ -707,6 +728,10 @@ const noop = () => {}
box-shadow: 0 2px 10px rgb(0 0 0 / 0.15);
}
.window.borderless {
border-color: transparent;
}
.window.selected {
outline: 1px dashed #2563eb;
outline-offset: 2px;
+2 -2
View File
@@ -7,7 +7,7 @@ import ChartDialog from './ChartDialog.vue'
import type { DesignerDocument } from './document'
import { checkName, getEventHandler, getItems, getPropText, typeOf } from './edit'
import { resourceNameOf } from './resources'
import { NAMED_COLORS, hexToRgb, parseValue, rgb, type Value } from './values'
import { COLOR_LABELS, NAMED_COLORS, hexToRgb, parseValue, rgb, type Value } from './values'
const props = defineProps<{
doc: DesignerDocument
@@ -289,7 +289,7 @@ function renameHandler(event: string, value: string) {
<i class="swatch" :style="{ background: color(prop)?.css ?? 'transparent' }" />
<select :value="color(prop)?.name ?? (color(prop) ? 'custom' : '')" @change="setColor(prop, ($event.target as HTMLSelectElement).value)">
<option value="">(默认)</option>
<option v-for="(_, name) in NAMED_COLORS" :key="name" :value="name">{{ name }}</option>
<option v-for="(_, name) in NAMED_COLORS" :key="name" :value="name" :title="name">{{ COLOR_LABELS[name] ?? name }}</option>
<option v-if="color(prop) && !color(prop)!.name" value="custom" disabled>{{ color(prop)!.css }}</option>
</select>
<input
+3 -3
View File
@@ -9,7 +9,7 @@ test('插入事件处理方法:放在类的最后,与上一个方法空一
const code = await Bun.file(FORM1).text()
const { code: next, line } = ensureHandler(code, 'Form1', 'Form1_Load', 'EventArgs')
const lines = next.split('\n')
expect(lines.slice(line - 4, line + 1)).toEqual(['', ' private void Form1_Load(object sender, EventArgs e)', ' {', '', ' }'])
expect(lines.slice(line - 4, line + 1)).toEqual(['', ' private void Form1_Load(object sender, EventArgs e)', ' {', ' ', ' }'])
// 方法的 "}" 之后就是类的 "}"
expect(lines[line + 1]).toBe(' }')
})
@@ -25,7 +25,7 @@ test('类里还没有成员时不留多余空行', () => {
const code = 'namespace A\n{\n public partial class F : Form\n {\n }\n}\n'
const { code: next, line } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
expect(next).toBe(
'namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n }\n}\n',
'namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n \n }\n }\n}\n',
)
expect(line).toBe(7)
})
@@ -33,5 +33,5 @@ test('类里还没有成员时不留多余空行', () => {
test('字符串和注释里的大括号不影响查找类的结尾', () => {
const code = 'class F\n{\n string s = "}{";\n // }\n char c = \'}\';\n}\n'
const { code: next } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
expect(next.endsWith(" char c = '}';\n\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n}\n")).toBe(true)
expect(next.endsWith(" char c = '}';\n\n private void F_Load(object sender, EventArgs e)\n {\n \n }\n}\n")).toBe(true)
})
+3 -1
View File
@@ -20,7 +20,9 @@ export function ensureHandler(code: string, className: string, handler: string,
// 类里已有成员时,与上一个成员之间空一行
const before = code.slice(0, lineStart)
const hasMembers = !/\{\s*$/.test(before)
const method = [...(hasMembers ? [''] : []), `${indent}private void ${handler}(object sender, ${args} e)`, `${indent}{`, '', `${indent}}`].join(eol)
// 方法体的空行带好缩进(和 VS 一样),光标跳过去就在缩进后面,学生的代码不会顶格写
const body = `${indent} `
const method = [...(hasMembers ? [''] : []), `${indent}private void ${handler}(object sender, ${args} e)`, `${indent}{`, body, `${indent}}`].join(eol)
const next = `${before}${method}${eol}${code.slice(lineStart)}`
return { code: next, line: lineOf(next, before.length) + (hasMembers ? 3 : 2) }
}
+24
View File
@@ -134,6 +134,30 @@ export const NAMED_COLORS: Record<string, string> = {
Cyan: '#00ffff',
}
/** 属性窗口里颜色的中文名,英文名放在悬停提示里 */
export const COLOR_LABELS: Record<string, string> = {
Transparent: '透明',
Black: '黑色',
White: '白色',
Red: '红色',
Green: '绿色',
Blue: '蓝色',
Yellow: '黄色',
Orange: '橙色',
Gray: '灰色',
DimGray: '暗灰色',
Silver: '银色',
LightGray: '浅灰色',
DarkBlue: '深蓝色',
SkyBlue: '天蓝色',
LightGreen: '浅绿色',
Lime: '亮绿色',
Purple: '紫色',
Brown: '棕色',
Pink: '粉红色',
Cyan: '青色',
}
/** 系统颜色(System.Drawing.SystemColors),按 Windows 10 默认主题取值 */
const SYSTEM_COLORS: Record<string, string> = {
Control: '#f0f0f0',
+2
View File
@@ -124,6 +124,8 @@ onMounted(() => {
tabSize: 4,
insertSpaces: true,
minimap: { enabled: false },
// 顶部不固定显示所在的类和方法:点报错跳到文件开头附近的行时,那一行会被它挡住
stickyScroll: { enabled: false },
scrollBeyondLastLine: false,
renderWhitespace: 'selection',
unicodeHighlight: { ambiguousCharacters: false },
+2
View File
@@ -1,5 +1,7 @@
// 按需加载 Monaco(0.57 的模块布局):编辑器 API + 全部编辑功能(查找、折叠、括号匹配等)+ C# 和 SQL 语法高亮,
// 不加载其他几十种语言和 JS/TS/CSS 语言服务,体积小很多。
// 中文界面(查找替换、右键菜单、提示文字):必须在编辑器模块之前加载,编辑器模块加载时就会取界面文字
import 'monaco-editor/nls/lang/zh-cn'
import * as monaco from 'monaco-editor/editor/editor.api'
import 'monaco-editor/features/register.all'
import 'monaco-editor/languages/definitions/csharp/register'
+8
View File
@@ -77,6 +77,7 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
:show-mask="false"
:trap-focus="false"
:block-scroll="false"
class="task-drawer"
@update:show="emit('update:show', $event)"
>
<NDrawerContent title="任务" closable>
@@ -117,6 +118,13 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
</NDrawer>
</template>
<style>
/* 从顶部工具栏下面开始,不挡住"我的作业""数据库""退出"等链接 */
.n-drawer.task-drawer {
top: 49px;
}
</style>
<style scoped>
.task {
font-size: 14px;
+3 -2
View File
@@ -10,7 +10,7 @@ import EditTab from '../database/EditTab.vue'
import ObjectExplorer, { type ExplorerAction } from '../database/ObjectExplorer.vue'
import QueryTab from '../database/QueryTab.vue'
import { execute, loadExplorer, quoteName, type ExplorerDatabase, type ExplorerTable } from '../database/sql'
import { renderSampleTag, sampleOptions } from '../samples'
import { lastSample, renderSampleTag, sampleOptions } from '../samples'
type Tab =
| { key: string; kind: 'query'; title: string; database: string; sql?: string; run?: boolean }
@@ -148,7 +148,8 @@ const restoreBusy = ref(false)
async function openRestore() {
experiments.value = await api('GET', '/api/db/experiments')
restoring.value = experiments.value[0]?.id ?? null
// 默认选刚才做的实验
restoring.value = experiments.value.find((e) => e.id === lastSample())?.id ?? experiments.value[0]?.id ?? null
}
async function restore() {
+2 -2
View File
@@ -28,8 +28,8 @@ import { currentUser } from '../auth'
<h3>2. 自由练习和做作业</h3>
<ul>
<li>登录后进入<b>自由练习</b>:左上角的下拉框选教材上的任务,可以随便改、随便运行。</li>
<li>点右上角的<b>我的作业</b>,看老师布置的作业、截止时间和得分;点作业名开始做。</li>
<li>登录后先看到<b>我的作业</b>:老师布置的作业、截止时间和得分;点作业名开始做。做实验时随时可以点右上角的<b>我的作业</b>回来。</li>
<li>点<b>自由练习</b>:左上角的下拉框选教材上的任务,可以随便改、随便运行;下次打开时回到上次做的任务。</li>
<li>工具栏的<b>任务</b>:任务目标、控件名、实现步骤和在平台上怎么试,第一次打开一个实验时自动弹出。面板不挡住设计器和代码,可以开着做。</li>
<li>
作业的起点有两种:<b>界面已做好</b>(只写事件和代码)、<b>空白工程</b>(从搭界面做起)。控件名照书上的写,自动评分按书上的控件名操作你的程序。
+4 -2
View File
@@ -17,8 +17,10 @@ async function submit() {
busy.value = true
error.value = ''
try {
await login(username.value.trim(), password.value)
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') ? route.query.redirect : '/'
const user = await login(username.value.trim(), password.value)
// 学生登录后先看作业列表,老师进实验页
const fallback = user.role === 'student' ? '/assignments' : '/'
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') ? route.query.redirect : fallback
await router.replace(redirect)
} catch (e) {
error.value = (e as Error).message
+4 -3
View File
@@ -15,7 +15,7 @@ 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'
import { downloadCsv, textCell } from '../teacher/csv'
interface Row {
userId: number
@@ -270,7 +270,7 @@ function exportCsv() {
'评语',
],
...rows.value.map((r) => [
`\t${r.username}`,
textCell(r.username),
r.status ? (r.autoScore ?? '') : '未提交',
...a.scoring.manual.map((_, i) => r.review?.scores[i] ?? ''),
r.status ? (r.autoScore ?? 0) + (r.manualScore ?? 0) : '',
@@ -293,7 +293,8 @@ async function signOut() {
<strong>{{ assignment?.title }}</strong>
<span class="muted">{{ assignment?.className }}</span>
<span class="muted">已提交 {{ rows.filter((r) => r.status).length }} / {{ rows.length }} 人</span>
<NButton size="small" @click="adoptAll">全部采纳 AI 建议</NButton>
<!-- 没配置 AI 评阅时不会有 AI 建议,不显示这个按钮 -->
<NButton v-if="rows.some((r) => r.aiStatus)" size="small" @click="adoptAll">全部采纳 AI 建议</NButton>
<NButton size="small" @click="exportCsv">导出成绩</NButton>
<span class="spacer" />
<span class="user">{{ currentUser?.name }}</span>
+2 -2
View File
@@ -7,7 +7,7 @@ import { api } from '../api'
import { currentUser, logout } from '../auth'
import AssignmentsPanel from '../teacher/AssignmentsPanel.vue'
import GradebookPanel from '../teacher/GradebookPanel.vue'
import { downloadCsv } from '../teacher/csv'
import { downloadCsv, textCell } from '../teacher/csv'
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
interface ClassInfo {
@@ -177,7 +177,7 @@ async function confirmImport() {
function exportRoster() {
if (!selected.value) return
downloadCsv(`${selected.value.name}-名单.csv`, [['用户名', '密码'], ...students.value.map((s) => [`\t${s.username}`, `\t${s.password}`])])
downloadCsv(`${selected.value.name}-名单.csv`, [['用户名', '密码'], ...students.value.map((s) => [textCell(s.username), textCell(s.password)])])
}
function downloadTemplate() {
+13 -11
View File
@@ -30,7 +30,7 @@ import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue'
import TaskPanel from '../grading/TaskPanel.vue'
import { captureForms } from '../grading/screenshots'
import { renderSampleTag, sampleOptions } from '../samples'
import { lastSample, rememberSample, renderSampleTag, sampleOptions } from '../samples'
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
@@ -128,8 +128,8 @@ onMounted(async () => {
}
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
samples.value = sampleOptions(list)
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
const initial = list.find((s) => s.id === route.query.sample) ?? list[0]
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生),否则打开上次做的实验
const initial = list.find((s) => s.id === route.query.sample) ?? list.find((s) => s.id === lastSample()) ?? list[0]
if (initial) await loadSample(initial.id)
})
@@ -150,6 +150,7 @@ async function openProject(key: string, original: () => Promise<SourceFile[]>, d
async function loadSample(id: string, discardWork = false) {
sampleId.value = id
void router.replace({ query: { sample: id } })
rememberSample(id)
sampleChecks.value = []
sampleTask.value = null
void api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
@@ -596,7 +597,7 @@ function run() {
:before-start="flushSave"
/>
<main class="workspace" :class="{ designing: activeForm, 'run-collapsed': runCollapsed }">
<main class="workspace" :class="{ 'run-collapsed': runCollapsed }">
<section class="editor">
<div class="tab-row">
<NTabs v-model:value="activeTab" type="card" size="small" class="file-tabs">
@@ -698,7 +699,7 @@ function run() {
}
.sample-select {
width: 240px;
width: 300px;
}
.metrics {
@@ -758,16 +759,12 @@ function run() {
white-space: nowrap;
}
/* 设计页要放工具箱、窗体和属性窗口,代码页要放得下长代码行,左栏都放宽;切换标签页时布局不跳 */
.workspace {
flex: 1;
display: grid;
grid-template-columns: minmax(360px, 2fr) 14px 3fr;
min-height: 0;
}
/* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */
.workspace.designing {
grid-template-columns: minmax(0, 3fr) 14px minmax(360px, 2fr);
min-height: 0;
}
.workspace.run-collapsed {
@@ -815,6 +812,11 @@ function run() {
min-width: 0;
}
/* 文件多时(项目 8 有十来个)标签换行显示,不藏在看不到滚动条的横向滚动里 */
.file-tabs :deep(.n-tabs-wrapper) {
flex-wrap: wrap;
}
.new-form {
display: flex;
flex-direction: column;
+116 -60
View File
@@ -11,7 +11,9 @@ 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 emit = defineEmits<{
diagnostic: [file: string, line: number, column: number]
}>()
const state = props.session.state
provide(RUN_IMAGES, props.session.images)
@@ -26,7 +28,10 @@ const maximized = ref(false)
const desktop = ref<HTMLElement>()
const desktopSize = ref({ width: 0, height: 0 })
const observer = new ResizeObserver(([entry]) => {
desktopSize.value = { width: entry.contentRect.width, height: entry.contentRect.height }
desktopSize.value = {
width: entry.contentRect.width,
height: entry.contentRect.height,
}
})
onMounted(() => observer.observe(desktop.value!))
onBeforeUnmount(() => observer.disconnect())
@@ -39,7 +44,10 @@ const scale = computed(() => {
state.forms.forEach((form, index) => {
const p = form.root.props
if (p.Visible === false) return
const client = (p.ClientSize as SizeValue | undefined) ?? { width: 300, height: 300 }
const client = (p.ClientSize as SizeValue | undefined) ?? {
width: 300,
height: 300,
}
const start = WINDOW_MARGIN + index * WINDOW_STEP
width = Math.max(width, start + client.width + 2 + WINDOW_MARGIN)
height = Math.max(height, start + client.height + 2 + (p.FormBorderStyle === 'None' ? 0 : TITLEBAR) + WINDOW_MARGIN)
@@ -58,53 +66,54 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<template>
<section class="run-area" :class="{ maximized }">
<div ref="desktop" class="desktop">
<div class="desktop-canvas" :style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined">
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
:form="form.root"
:modal="form.modal"
:offset="index"
:scale="scale"
:act="session.act"
/>
</div>
<MessageBoxDialog v-for="box in state.messageBoxes" :key="box.requestId" :box="box" @reply="session.replyMessageBox(box.requestId, $event)" />
<div v-if="state.connection === 'lost'" class="desktop-hint lost">与服务器的连接断开了,正在重新连接…</div>
<div v-else-if="state.connection === 'restored' && state.forms.length === 0" class="desktop-hint lost">
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
</div>
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
<div class="stage">
<div class="desktop-tools">
<span v-if="scale < 1" class="desktop-scale" title='窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚'> 缩放 {{ Math.round(scale * 100) }}% </span>
<span v-if="scale < 1" class="desktop-scale" title='窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚'>缩放 {{ Math.round(scale * 100) }}%</span>
<button
v-if="maximized && (state.status === 'compiling' || state.status === 'running')"
type="button"
title="结束正在运行的程序(Shift+F5)"
@click="session.stop()"
>
■ 停止
</button>
<button type="button" :title="maximized ? '还原(Esc)' : '运行区占满整个网页'" @click="maximized = !maximized">
{{ maximized ? '还原' : '放大' }}
</button>
</div>
</div>
<TrainingBoard :board="state.board" :playing="state.playing" @set="session.setBoard" @reset="session.resetBoard" />
<div v-if="state.savedImages.length" class="photos">
<div class="photos-title">程序保存的图片({{ state.savedImages.length }})<span>只在这次运行中保留</span></div>
<div class="photos-list">
<figure
v-for="(photo, i) in state.savedImages"
:key="i"
:title="`${photo.path}(点击查看大图)`"
@click="viewing = { path: photo.path, url: session.images.get(photo.image) ?? '' }"
<div ref="desktop" class="desktop">
<div
class="desktop-canvas"
:style="
scale < 1
? {
transform: `scale(${scale})`,
width: `${100 / scale}%`,
height: `${100 / scale}%`,
}
: undefined
"
>
<img :src="session.images.get(photo.image)" alt="" />
<figcaption>{{ photo.path }}</figcaption>
</figure>
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
:form="form.root"
:modal="form.modal"
:offset="index"
:scale="scale"
:act="session.act"
/>
</div>
<MessageBoxDialog v-for="box in state.messageBoxes" :key="box.requestId" :box="box" @reply="session.replyMessageBox(box.requestId, $event)" />
<div v-if="state.connection === 'lost'" class="desktop-hint lost">与服务器的连接断开了,正在重新连接…</div>
<div v-else-if="state.connection === 'restored' && state.forms.length === 0" class="desktop-hint lost">
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
</div>
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
</div>
</div>
<div v-if="viewing" class="photo-viewer" @click="viewing = null">
<figure>
<img :src="viewing.url" alt="" />
<figcaption>{{ viewing.path }}(点击任意处关闭)</figcaption>
</figure>
</div>
<div class="output">
<!-- 编译错误、异常、控制台输出和保存的图片紧跟在桌面下面,不会被很高的实训台挤到看不见的地方 -->
<div v-if="state.diagnostics.length || state.errors.length || state.console" class="output">
<div
v-for="(d, i) in state.diagnostics"
:key="`d${i}`"
@@ -115,9 +124,41 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
{{ 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-for="(e, i) in state.errors"
:key="`e${i}`"
:class="['error', { jump: e.file && e.line }]"
:title="e.file && e.line ? '点击跳到出错的代码' : undefined"
@click="e.file && e.line && emit('diagnostic', e.file, e.line, 1)"
>{{ e.text }}</pre>
<pre v-if="state.console" class="console">{{ state.console }}</pre>
</div>
<div v-if="state.savedImages.length" class="photos">
<div class="photos-title">程序保存的图片({{ state.savedImages.length }})<span>只在这次运行中保留</span></div>
<div class="photos-list">
<figure
v-for="(photo, i) in state.savedImages"
:key="i"
:title="`${photo.path}(点击查看大图)`"
@click="
viewing = {
path: photo.path,
url: session.images.get(photo.image) ?? '',
}
"
>
<img :src="session.images.get(photo.image)" alt="" />
<figcaption>{{ photo.path }}</figcaption>
</figure>
</div>
</div>
<TrainingBoard :board="state.board" :playing="state.playing" @set="session.setBoard" @reset="session.resetBoard" />
<div v-if="viewing" class="photo-viewer" @click="viewing = null">
<figure>
<img :src="viewing.url" alt="" />
<figcaption>{{ viewing.path }}(点击任意处关闭)</figcaption>
</figure>
</div>
</section>
</template>
@@ -131,6 +172,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
overflow: auto;
}
/* 运行区内容超出高度时整体滚动,除了桌面都不被压缩 */
.run-area > :not(.stage) {
flex-shrink: 0;
}
.stage {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.desktop {
position: relative;
flex: 1;
@@ -151,36 +204,31 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
transform-origin: 0 0;
}
/* 放在桌面上方,不压住学生窗体的标题栏按钮 */
.desktop-tools {
position: absolute;
top: 6px;
right: 6px;
z-index: 100001;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
font-size: 12px;
}
.desktop-scale {
padding: 2px 6px;
border-radius: 3px;
background: rgb(0 0 0 / 0.35);
color: #fff;
color: #6b7280;
}
.desktop-tools button {
padding: 2px 10px;
border: 1px solid rgb(255 255 255 / 0.6);
padding: 1px 10px;
border: 1px solid #d1d5db;
border-radius: 3px;
background: rgb(0 0 0 / 0.35);
color: #fff;
background: #fff;
color: #374151;
font-size: 12px;
cursor: pointer;
}
.desktop-tools button:hover {
background: rgb(0 0 0 / 0.55);
border-color: #9ca3af;
}
/* 放大:桌面在左边占满高度,实训台、图片列表和输出在右边一栏 */
@@ -195,13 +243,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
background: #f3f4f6;
}
.run-area.maximized .desktop {
.run-area.maximized .stage {
grid-column: 1;
grid-row: 1 / span 20;
height: calc(100vh - 16px);
}
.run-area.maximized > :not(.desktop) {
.run-area.maximized > :not(.stage) {
grid-column: 2;
}
@@ -293,11 +341,19 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
font-size: 12px;
}
.diag {
/* 长的报错和输出折行显示,不用左右滚动 */
.output pre {
white-space: pre-wrap;
word-break: break-all;
}
.diag,
.error.jump {
cursor: pointer;
}
.diag:hover {
.diag:hover,
.error.jump:hover {
text-decoration: underline;
}
+18 -5
View File
@@ -8,6 +8,12 @@ export interface OpenForm {
}
/** 学生程序保存的图片(如摄像头照片),path 相对程序所在目录 */
export interface RunError {
text: string
file?: string
line?: number
}
export interface SavedImage {
path: string
image: string
@@ -27,7 +33,8 @@ export function createRunSession() {
status: 'idle' as 'idle' | 'compiling' | 'running' | 'stopped',
diagnostics: [] as Diagnostic[],
console: '',
errors: [] as string[],
/** 运行出错、连接断开等提示;运行时异常带着出错的文件和行号,点击跳过去 */
errors: [] as RunError[],
forms: [] as OpenForm[],
messageBoxes: [] as PendingMessageBox[],
/** 虚拟实训台,由服务器推送;连接建立前为 null */
@@ -103,7 +110,7 @@ export function createRunSession() {
ws.onclose = () => {
// 服务器那边的程序随连接一起结束了;窗体留在页面上只会"点了没反应"
if (!closed && (state.forms.length > 0 || state.status === 'compiling' || state.status === 'running')) {
state.errors.push('与服务器的连接断开了(网络不稳或服务器正在更新),程序已经结束。连上之后请重新运行。')
state.errors.push({ text: '与服务器的连接断开了(网络不稳或服务器正在更新),程序已经结束。连上之后请重新运行。' })
}
stopAudio()
state.forms = []
@@ -131,7 +138,7 @@ export function createRunSession() {
async function run(files: SourceFile[], sampleId?: string) {
await connect()
if (socket?.readyState !== WebSocket.OPEN) {
state.errors = ['连不上服务器,请检查网络,稍后再点"运行"。']
state.errors = [{ text: '连不上服务器,请检查网络,稍后再点"运行"。' }]
return
}
state.connection = 'open'
@@ -194,6 +201,12 @@ export function createRunSession() {
switch (message.type) {
case 'status':
state.status = message.state
// 点"停止"时服务器直接结束进程,不再发 exited;窗体留着只会"点了没反应"
if (message.state === 'stopped') {
stopAudio()
state.forms = []
state.messageBoxes = []
}
break
case 'compiled':
state.diagnostics = message.diagnostics
@@ -259,10 +272,10 @@ export function createRunSession() {
state.console += message.text
break
case 'exception':
state.errors.push(`${message.message}\n${message.detail}`)
state.errors.push({ text: `运行出错:${message.message}\n${message.detail}`, file: message.file, line: message.line })
break
case 'serverError':
state.errors.push(message.message)
state.errors.push({ text: message.message })
break
case 'exited':
stopAudio()
+20 -1
View File
@@ -1,4 +1,4 @@
// 实验下拉菜单:按教材的项目分组,组名是项目名,组里每项只写"任务N …";选中后框里显示完整标题
// 实验下拉菜单:按教材的项目分组,组名是项目名,组里每项只写"任务N …";选中后框里显示完整标题。另外记住上次做的实验
import { h } from 'vue'
import type { SelectGroupOption, SelectOption, SelectRenderTag } from 'naive-ui'
@@ -32,3 +32,22 @@ export function sampleOptions(list: { id: string; title: string }[]): SelectGrou
/** 选择框里显示完整标题(带项目号),下拉列表里仍是简短的"任务N …" */
export const renderSampleTag: SelectRenderTag = ({ option }) => h('span', { title: option.title as string }, option.title as string)
// 记住上次做的实验:从数据库页面返回、重新登录后回到这个实验,恢复数据时默认选它
const LAST_SAMPLE_KEY = 'iot-sim:last-sample'
export function lastSample() {
try {
return localStorage.getItem(LAST_SAMPLE_KEY)
} catch {
return null
}
}
export function rememberSample(id: string) {
try {
localStorage.setItem(LAST_SAMPLE_KEY, id)
} catch {
// 浏览器禁用了本地存储:下次打开第一个实验
}
}
+9 -3
View File
@@ -4,7 +4,7 @@ import { computed, h, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NDataTable, NEmpty, useMessage, type DataTableColumns } from 'naive-ui'
import { api } from '../api'
import { downloadCsv } from './csv'
import { downloadCsv, textCell } from './csv'
interface Cell {
submitted: boolean
@@ -61,11 +61,14 @@ const columns = computed<DataTableColumns<Row>>(() => [
{ title: `合计(${fullMarks.value})`, key: 'sum', width: 110, sorter: (a: Row, b: Row) => sum(a) - sum(b), render: (row: Row) => String(sum(row)) },
])
/** 有 AI 建议分时才解释"AI 建议,待确认"(没配置 AI 评阅时不会出现) */
const hasAi = computed(() => book.value.students.some((s) => Object.values(s.scores).some((c) => c.aiManual !== null)))
function exportCsv() {
downloadCsv(`${props.className}-成绩表.csv`, [
['用户名', ...book.value.assignments.map((a) => `${a.title}(${a.total})`), `合计(${fullMarks.value})`],
...book.value.students.map((s) => [
`\t${s.username}`,
textCell(s.username),
...book.value.assignments.map((a) => (s.scores[a.id]?.submitted ? s.scores[a.id].total : '未交')),
sum(s),
]),
@@ -76,7 +79,10 @@ function exportCsv() {
<template>
<div>
<div class="head">
<span class="tip">总分 = 最近一次提交的自动评分 + 老师评分;"AI 建议,待确认"是含 AI 建议分的总分,老师采纳后才算数;"待评"只含自动评分。</span>
<span class="tip"
>总分 = 最近一次提交的自动评分 + 老师评分;<template v-if="hasAi">"AI 建议,待确认"是含 AI 建议分的总分,老师采纳后才算数;</template
>"待评"只含自动评分。</span
>
<NButton size="small" :disabled="book.students.length === 0" @click="exportCsv">导出成绩表(CSV)</NButton>
</div>
<NDataTable
+4 -1
View File
@@ -1,5 +1,5 @@
// 下载 CSV:开头带 BOM,Excel 打开时中文不乱码。
// 账号、密码等长数字前加制表符(cell 以 \t 开头),Excel 不会把它变成科学计数法。
// 账号、密码这类纯数字的文字用 textCell 包一下,Excel 不会把它变成数字(丢掉开头的 0、变成科学计数法)。
export function downloadCsv(filename: string, rows: (string | number | null)[][]) {
const quote = (value: string | number | null) => {
const cell = value === null ? '' : String(value)
@@ -12,3 +12,6 @@ export function downloadCsv(filename: string, rows: (string | number | null)[][]
link.click()
URL.revokeObjectURL(link.href)
}
/** 纯数字时前面加制表符,Excel 按文字处理;姓名等其他文字原样输出,不留多余的空白 */
export const textCell = (value: string) => (/^\d+$/.test(value) ? `\t${value}` : value)
+37 -7
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 按控件类型渲染一个 WinForms 控件及其子控件。
// 用户操作只通过 act() 上报,由运行器按真实 WinForms 的顺序触发事件。
import { computed, inject, provide, ref, type CSSProperties } from 'vue'
import { computed, inject, onBeforeUnmount, provide, ref, watch, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format'
import { backgroundCss, imageSources, RUN_IMAGES, srcset, type ImageValue } from './images'
@@ -49,10 +49,12 @@ const passwordChar = computed(() => {
return ch && ch !== '\0' ? ch : ''
})
// ComboBox
// ComboBox:下拉列表挂到页面最外层(和真实 WinForms 一样可以伸出窗体),按控件在屏幕上的位置和缩放摆放
const dropDownOpen = ref(false)
const items = computed(() => (p.value.Items as string[] | undefined) ?? [])
const editable = computed(() => p.value.DropDownStyle !== 'DropDownList')
const combo = ref<HTMLElement>()
const listStyle = ref<CSSProperties>({})
function openDropDown() {
if (!enabled.value) return
@@ -60,11 +62,37 @@ function openDropDown() {
dropDownOpen.value = false
return
}
const el = combo.value
if (!el) return
const rect = el.getBoundingClientRect()
listStyle.value = {
left: `${rect.left}px`,
top: `${rect.bottom}px`,
width: `${el.offsetWidth}px`,
transform: `scale(${rect.width / el.offsetWidth || 1})`,
font: getComputedStyle(el).font,
}
// 先触发 Click(教材在 Click 里填充串口列表),列表随后按最新的 Items 显示
act({ action: 'dropDown' })
dropDownOpen.value = true
}
/** 点列表以外的地方、滚动页面时收起列表 */
function closeOnOutside(event: Event) {
const target = event.target as Node
if (combo.value?.contains(target) || (target instanceof Element && target.closest('.wf-combobox-list'))) return
dropDownOpen.value = false
}
watch(dropDownOpen, (open) => {
const method = open ? 'addEventListener' : 'removeEventListener'
window[method]('mousedown', closeOnOutside, true)
window[method]('scroll', closeOnOutside, true)
})
onBeforeUnmount(() => {
window.removeEventListener('mousedown', closeOnOutside, true)
window.removeEventListener('scroll', closeOnOutside, true)
})
function selectItem(index: number) {
dropDownOpen.value = false
act({ action: 'select', index })
@@ -130,7 +158,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
</div>
<!-- ComboBox -->
<div v-else-if="node.type === 'ComboBox'" class="wf-control wf-combobox" :style="style">
<div v-else-if="node.type === 'ComboBox'" ref="combo" class="wf-control wf-combobox" :style="style">
<input
:value="String(p.Text ?? '')"
:readonly="!editable"
@@ -139,10 +167,12 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
@input="act({ action: 'text', value: ($event.target as HTMLInputElement).value })"
/>
<span class="wf-combobox-arrow" @mousedown.prevent="openDropDown">▾</span>
<ul v-if="dropDownOpen" class="wf-combobox-list">
<li v-for="(item, index) in items" :key="index" @mousedown.prevent="selectItem(index)">{{ item }}</li>
<li v-if="items.length === 0" class="wf-combobox-empty">&nbsp;</li>
</ul>
<Teleport to="body">
<ul v-if="dropDownOpen" class="wf-combobox-list" :style="listStyle">
<li v-for="(item, index) in items" :key="index" @mousedown.prevent="selectItem(index)">{{ item }}</li>
<li v-if="items.length === 0" class="wf-combobox-empty">&nbsp;</li>
</ul>
</Teleport>
</div>
<!-- ListBox:点空白处只触发 Click,点某一项先选中再触发 Click -->
+5 -5
View File
@@ -136,12 +136,12 @@
background: #fff;
}
/* 挂在 body 下,位置、宽度、缩放和字体由 ControlView.vue 按下拉框算好 */
.wf-combobox-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 5;
position: fixed;
z-index: 2000;
box-sizing: border-box;
transform-origin: 0 0;
margin: 0;
padding: 0;
list-style: none;