第 2 阶段:账号与班级,作品保存在服务器
- SQLite(bun:sqlite,编号迁移):班级、用户、会话(只存令牌哈希)、作品 - 登录取代统一访问口令:老师账号由配置设定;接口和 WebSocket 都要登录;按 IP 限制输错次数 - 班级管理页:建班、改名、删除(有学生时不能删);上传 CSV 名单导入(兼容 GBK / UTF-8, 识别表头,标出被 Excel 变成科学计数法的学号),密码留空生成 6 位数字;查看、重置密码, 导出带密码的名单 - 学生作品自动保存到服务器(按账号和实验),显示保存状态;离开页面前立即保存 - 前端加路由:登录页、实验页、班级管理页 - 部署:TEACHER_PASSWORD 必填,数据库放在数据卷 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a00622be15
commit
7cec273d58
31 files changed
+1776
-382
No files matched your search
@@ -0,0 +1,518 @@
|
||||
<script setup lang="ts">
|
||||
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NInput, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag } from 'naive-ui'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import FormDesigner from '../designer/FormDesigner.vue'
|
||||
import { createDesignerHistory } from '../designer/history'
|
||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
|
||||
import CodeEditor from '../editor/CodeEditor.vue'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
|
||||
import { createRunSession } from '../run/session'
|
||||
import TrainingBoard from '../scene/TrainingBoard.vue'
|
||||
import FormWindow from '../winforms/FormWindow.vue'
|
||||
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
||||
|
||||
const session = createRunSession()
|
||||
const { state } = session
|
||||
|
||||
const samples = ref<{ label: string; value: string }[]>([])
|
||||
const sampleId = ref<string>()
|
||||
const files = ref<SourceFile[]>([])
|
||||
/** 当前标签页:文件路径,或 "设计:X.Designer.cs" 表示窗体 X 的设计器 */
|
||||
const activeTab = ref<string>()
|
||||
const DESIGN = '设计:'
|
||||
|
||||
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
|
||||
const forms = computed(() => {
|
||||
const startup = startupForm(files.value)
|
||||
return files.value
|
||||
.filter((f) => f.path.endsWith('.Designer.cs'))
|
||||
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
|
||||
// 启动窗体排在最前
|
||||
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
|
||||
})
|
||||
/** 标签页按窗体分组:每个窗体依次是 [设计]、代码、Designer.cs;其他文件(Program.cs 等)放最后 */
|
||||
const tabs = computed(() => {
|
||||
const grouped = new Set(forms.value.flatMap((f) => [f.code, f.designer]))
|
||||
return [
|
||||
...forms.value.flatMap((f) => [
|
||||
{ name: f.tab, label: `${f.code} [设计]` },
|
||||
{ name: f.code, label: f.code },
|
||||
{ name: f.designer, label: f.designer },
|
||||
]),
|
||||
...files.value.filter((f) => !grouped.has(f.path)).map((f) => ({ name: f.path, label: f.path })),
|
||||
]
|
||||
})
|
||||
const activeForm = computed(() => forms.value.find((f) => f.tab === activeTab.value))
|
||||
/** 代码编辑器显示的文件:设计器打开时保持上一次的代码文件 */
|
||||
const activeFile = computed(() => (activeForm.value ? undefined : activeTab.value))
|
||||
const fileText = (path: string) => files.value.find((f) => f.path === path)?.content ?? ''
|
||||
const designerHistory = createDesignerHistory({ read: fileText, write: (path, content) => updateFile(path, content) })
|
||||
|
||||
// 开发模式下暴露页面状态,供浏览器自动化测试读取
|
||||
if (import.meta.env.DEV) Object.assign(window, { __iot: { files, fileText, designerHistory, activeTab, updateFile } })
|
||||
/** 当前代码是否和示例原始代码不同(服务器上有这个人的作品) */
|
||||
const modified = ref(false)
|
||||
const editorRef = ref<InstanceType<typeof CodeEditor>>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
|
||||
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
|
||||
const running = computed(() => state.status === 'compiling' || state.status === 'running')
|
||||
|
||||
/**
|
||||
* F5 运行、Shift+F5 停止,与 Visual Studio 一致。在整个页面上生效,
|
||||
* 并阻止浏览器默认的"F5 刷新页面"(否则学生按习惯按下 F5,正在运行的程序和窗体都会丢失)。
|
||||
*/
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== 'F5') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.shiftKey) session.stop()
|
||||
else run()
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown, { capture: true }))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture: true }))
|
||||
|
||||
onMounted(async () => {
|
||||
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
|
||||
void session.connect()
|
||||
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
||||
const initial = list.find((s) => s.id === route.query.sample) ?? list[0]
|
||||
if (initial) await loadSample(initial.id)
|
||||
})
|
||||
|
||||
async function loadSample(id: string, discardWork = false) {
|
||||
await flushSave()
|
||||
sampleId.value = id
|
||||
void router.replace({ query: { sample: id } })
|
||||
designerHistory.clear()
|
||||
const key = sampleWorkKey(id)
|
||||
if (discardWork) await deleteWork(key).catch(() => {})
|
||||
const work = discardWork ? null : await loadWork(key).catch(() => null)
|
||||
modified.value = work !== null
|
||||
saveState.value = work ? 'saved' : 'idle'
|
||||
files.value = work ?? (await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`))
|
||||
// 和 VS 一样,默认打开主窗体的设计器
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.cs')?.path ?? files.value[0]?.path
|
||||
}
|
||||
|
||||
// ---------- 自动保存到服务器 ----------
|
||||
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
|
||||
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const SAVE_TEXT = { idle: '', pending: '未保存', saving: '保存中…', saved: '已保存', error: '保存失败,请检查网络' } as const
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function scheduleSave() {
|
||||
saveState.value = 'pending'
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => void flushSave(), 1000)
|
||||
}
|
||||
|
||||
/** 立即保存还没保存的改动(切换示例、离开页面之前) */
|
||||
async function flushSave(keepalive = false) {
|
||||
clearTimeout(saveTimer)
|
||||
if (saveState.value !== 'pending' || !sampleId.value) return
|
||||
saveState.value = 'saving'
|
||||
const snapshot = files.value.map((f) => ({ ...f }))
|
||||
try {
|
||||
await saveWork(sampleWorkKey(sampleId.value), snapshot, keepalive)
|
||||
// 保存期间又有改动时保持 pending,等下一次保存
|
||||
if (saveState.value === 'saving') saveState.value = 'saved'
|
||||
} catch {
|
||||
saveState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
const onPageHide = () => void flushSave(true)
|
||||
onMounted(() => window.addEventListener('pagehide', onPageHide))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pagehide', onPageHide)
|
||||
void flushSave()
|
||||
})
|
||||
|
||||
async function signOut() {
|
||||
await flushSave()
|
||||
await logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
|
||||
function updateFile(path: string, content: string) {
|
||||
const file = files.value.find((f) => f.path === path)
|
||||
if (!file || file.content === content) return
|
||||
file.content = content
|
||||
filesChanged()
|
||||
}
|
||||
|
||||
function filesChanged() {
|
||||
modified.value = true
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
// ---------- 新建、删除窗体 ----------
|
||||
const newFormOpen = ref(false)
|
||||
const newFormName = ref('')
|
||||
const newFormError = computed(() => {
|
||||
const name = newFormName.value.trim()
|
||||
if (!name) return '请输入窗体名'
|
||||
if (!/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name)) return '只能用字母、汉字、数字、下划线,且不能以数字开头'
|
||||
if (formNameTaken(files.value, name)) return `已经有名为“${name}”的窗体或类`
|
||||
return null
|
||||
})
|
||||
|
||||
function openNewForm(show: boolean) {
|
||||
newFormOpen.value = show
|
||||
if (show) newFormName.value = nextFormName(files.value)
|
||||
}
|
||||
|
||||
/** 新建窗体:生成 X.cs 和 X.Designer.cs,打开它的设计器 */
|
||||
function createForm() {
|
||||
if (newFormError.value) return
|
||||
const created = newFormFiles(projectNamespace(files.value), newFormName.value.trim())
|
||||
files.value.push(...created)
|
||||
filesChanged()
|
||||
newFormOpen.value = false
|
||||
activeTab.value = DESIGN + created[1].path
|
||||
}
|
||||
|
||||
/** 当前设计器里的窗体能否删除:启动窗体(Program.cs 里 Application.Run 的那个)不能删 */
|
||||
const deletableForm = computed(() => {
|
||||
const form = activeForm.value
|
||||
if (!form) return undefined
|
||||
const className = /partial\s+class\s+([\p{L}\p{N}_]+)/u.exec(fileText(form.designer))?.[1]
|
||||
return className && className !== startupForm(files.value) ? form : undefined
|
||||
})
|
||||
|
||||
function deleteForm() {
|
||||
const form = deletableForm.value
|
||||
if (!form) return
|
||||
files.value = files.value.filter((f) => f.path !== form.designer && f.path !== form.code)
|
||||
designerHistory.clear(form.designer)
|
||||
filesChanged()
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
|
||||
}
|
||||
|
||||
/** 设计器改动了 Designer.cs(以及插入事件处理方法时的代码文件) */
|
||||
function onDesignerUpdate(designerText: string, codeText?: string) {
|
||||
const form = activeForm.value
|
||||
if (!form) return
|
||||
// 没有实际改动(如双击已绑定的事件,只是跳到代码)不记入历史
|
||||
const codeChanged = codeText !== undefined && codeText !== fileText(form.code)
|
||||
if (designerText === fileText(form.designer) && !codeChanged) return
|
||||
designerHistory.record(form.designer, {
|
||||
designerBefore: fileText(form.designer),
|
||||
designerAfter: designerText,
|
||||
...(codeText !== undefined ? { codeBefore: fileText(form.code), codeAfter: codeText } : {}),
|
||||
})
|
||||
updateFile(form.designer, designerText)
|
||||
if (codeText !== undefined) updateFile(form.code, codeText)
|
||||
}
|
||||
|
||||
/** 设计器里双击控件或事件:切到代码文件并跳到事件处理方法 */
|
||||
async function openCode(line: number) {
|
||||
const form = activeForm.value
|
||||
if (!form) return
|
||||
activeTab.value = form.code
|
||||
await nextTick()
|
||||
editorRef.value?.reveal(form.code, line, 13)
|
||||
}
|
||||
|
||||
async function showDiagnostic(file: string, line: number, column: number) {
|
||||
activeTab.value = file
|
||||
await nextTick()
|
||||
editorRef.value?.reveal(file, line, column)
|
||||
}
|
||||
|
||||
function restoreOriginal() {
|
||||
if (sampleId.value) void loadSample(sampleId.value, true)
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (running.value) return
|
||||
session.run(files.value.map((f) => ({ ...f })))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<NSelect
|
||||
class="sample-select"
|
||||
:value="sampleId"
|
||||
:options="samples"
|
||||
placeholder="选择示例工程"
|
||||
@update:value="loadSample"
|
||||
/>
|
||||
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
||||
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
||||
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small">恢复原始代码</NButton>
|
||||
</template>
|
||||
你对这个示例的修改将全部丢弃,确定恢复吗?
|
||||
</NPopconfirm>
|
||||
<span class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
||||
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
||||
<span class="metrics">
|
||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||
{{ state.metrics.lastRoundTripMs }} ms
|
||||
</span>
|
||||
<span class="user">
|
||||
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
|
||||
</span>
|
||||
<RouterLink v-if="currentUser?.role === 'teacher'" to="/teacher" class="nav-link">班级管理</RouterLink>
|
||||
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||
</header>
|
||||
|
||||
<main class="workspace" :class="{ designing: activeForm }">
|
||||
<section class="editor">
|
||||
<div class="tab-row">
|
||||
<NTabs v-model:value="activeTab" type="card" size="small" class="file-tabs">
|
||||
<NTab v-for="tab in tabs" :key="tab.name" :name="tab.name">{{ tab.label }}</NTab>
|
||||
</NTabs>
|
||||
<NPopover trigger="click" placement="bottom-end" :show="newFormOpen" @update:show="openNewForm">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>+ 新建窗体</NButton>
|
||||
</template>
|
||||
<div class="new-form">
|
||||
<div>窗体名(也是类名和文件名)</div>
|
||||
<NInput v-model:value="newFormName" size="small" @keydown.enter="createForm" />
|
||||
<div v-if="newFormError" class="new-form-error">{{ newFormError }}</div>
|
||||
<NButton size="small" type="primary" :disabled="!!newFormError" @click="createForm">创建</NButton>
|
||||
</div>
|
||||
</NPopover>
|
||||
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>删除窗体</NButton>
|
||||
</template>
|
||||
将删除 {{ deletableForm.code }} 和 {{ deletableForm.designer }},代码里用到这个窗体的地方需要自己修改。确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<FormDesigner
|
||||
v-if="activeForm"
|
||||
class="code"
|
||||
:designer-text="fileText(activeForm.designer)"
|
||||
:code-text="fileText(activeForm.code)"
|
||||
:can-undo="designerHistory.canUndo(activeForm.designer)"
|
||||
:can-redo="designerHistory.canRedo(activeForm.designer)"
|
||||
@update="onDesignerUpdate"
|
||||
@undo="designerHistory.undo(activeForm.designer, activeForm.code)"
|
||||
@redo="designerHistory.redo(activeForm.designer, activeForm.code)"
|
||||
@open-code="openCode"
|
||||
/>
|
||||
<CodeEditor
|
||||
v-show="!activeForm"
|
||||
ref="editorRef"
|
||||
class="code"
|
||||
:files="files"
|
||||
:active-file="activeFile"
|
||||
:diagnostics="state.diagnostics"
|
||||
@change="updateFile"
|
||||
/>
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.sample-select {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
margin-left: auto;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-state.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 2fr) 3fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */
|
||||
.workspace.designing {
|
||||
grid-template-columns: minmax(0, 3fr) minmax(360px, 2fr);
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.new-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in new issue
Block a user