第 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:
xuyueandClaude Opus 5.5 committed 2026-10-22 17:36:11 +08:00
1 parent a00622be15
commit 7cec273d58
31 files changed
+1776 -382

No files matched your search

+2 -1
View File
@@ -11,7 +11,8 @@
"@iot/shared": "workspace:*",
"monaco-editor": "^0.57.0",
"naive-ui": "^2.41.0",
"vue": "^3.5.0"
"vue": "^3.5.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/bun": "^1.2.0",
-85
View File
@@ -1,85 +0,0 @@
<script setup lang="ts">
// 访问口令页:输对后刷新页面进入平台(口令由服务器记在 cookie 里,之后不用再输)。
import { ref } from 'vue'
import { NButton, NConfigProvider, NInput, zhCN } from 'naive-ui'
const password = ref('')
const error = ref('')
const busy = ref(false)
async function submit() {
if (!password.value || busy.value) return
busy.value = true
error.value = ''
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password.value }),
})
if (response.ok) {
location.reload()
return
}
error.value = ((await response.json().catch(() => ({}))) as { message?: string }).message ?? '登录失败'
} catch {
error.value = '连不上服务器,请稍后再试'
} finally {
busy.value = false
}
}
</script>
<template>
<NConfigProvider :locale="zhCN">
<div class="gate">
<form class="card" @submit.prevent="submit">
<h1>物联网编程仿真平台</h1>
<p>请输入老师给的访问口令</p>
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="访问口令" autofocus />
<div v-if="error" class="error">{{ error }}</div>
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!password">进入</NButton>
</form>
</div>
</NConfigProvider>
</template>
<style scoped>
.gate {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 16px;
box-sizing: border-box;
background: #f3f4f6;
}
.card {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 320px;
padding: 28px 24px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
h1 {
margin: 0;
font-size: 18px;
}
p {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.error {
color: #dc2626;
font-size: 13px;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-ui'
</script>
<template>
<NConfigProvider :locale="zhCN">
<NMessageProvider>
<NDialogProvider>
<RouterView />
</NDialogProvider>
</NMessageProvider>
</NConfigProvider>
</template>
<style>
body {
margin: 0;
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
// 调用服务器接口。出错时抛出 ApiError,message 是服务器给的中文说明;
// 登录过期(401)时回到登录页。
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
}
}
/** 登录过期时的处理,由 router.ts 设置(避免这里依赖路由) */
export const apiHooks = { onUnauthorized: () => {} }
export async function api<T>(method: string, path: string, json?: unknown, init?: RequestInit): Promise<T> {
let response: Response
try {
response = await fetch(path, {
method,
headers: json === undefined ? undefined : { 'Content-Type': 'application/json' },
body: json === undefined ? undefined : JSON.stringify(json),
...init,
})
} catch {
throw new ApiError('连不上服务器,请检查网络', 0)
}
const data = (await response.json().catch(() => ({}))) as T & { message?: string }
if (response.status === 401 && path !== '/api/login') apiHooks.onUnauthorized()
if (!response.ok) throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status)
return data
}
+30
View File
@@ -0,0 +1,30 @@
// 当前登录的用户。页面启动时先问一次服务器,之后登录、退出时更新。
import { ref } from 'vue'
import { api } from './api'
export interface User {
id: number
username: string
name: string
role: 'student' | 'teacher'
classId: number | null
className: string | null
}
export const currentUser = ref<User | null>(null)
/** 页面启动时调用:已登录(cookie 有效)就取到用户 */
export async function loadCurrentUser() {
currentUser.value = await api<{ user: User }>('GET', '/api/me')
.then((r) => r.user)
.catch(() => null)
}
export async function login(username: string, password: string) {
currentUser.value = (await api<{ user: User }>('POST', '/api/login', { username, password })).user
}
export async function logout() {
await api('POST', '/api/logout').catch(() => {})
currentUser.value = null
}
-31
View File
@@ -1,31 +0,0 @@
// 学生改过的代码保存在浏览器本地(localStorage),刷新页面不丢失。
// 这只是方便功能:隐私窗口、清除网站数据等情况下可能读写失败,失败时当作没有草稿。
// 作业的正式保存以后放到服务器上。
import type { SourceFile } from '@iot/shared'
const PREFIX = 'iot-sim:draft:'
export function loadDraft(sampleId: string): SourceFile[] | null {
try {
const raw = localStorage.getItem(PREFIX + sampleId)
return raw ? (JSON.parse(raw) as SourceFile[]) : null
} catch {
return null
}
}
export function saveDraft(sampleId: string, files: SourceFile[]) {
try {
localStorage.setItem(PREFIX + sampleId, JSON.stringify(files))
} catch {
// 存储不可用或已满:忽略
}
}
export function clearDraft(sampleId: string) {
try {
localStorage.removeItem(PREFIX + sampleId)
} catch {
// 忽略
}
}
+21
View File
@@ -0,0 +1,21 @@
// 学生的作品保存在服务器上(按账号),换电脑登录也能接着做。
// key 标识是哪个实验,如 "sample:p2-t1-LED显示屏"。
import type { SourceFile } from '@iot/shared'
import { api } from '../api'
export const sampleWorkKey = (sampleId: string) => `sample:${sampleId}`
const url = (key: string) => `/api/works/${encodeURIComponent(key)}`
export async function loadWork(key: string): Promise<SourceFile[] | null> {
return (await api<{ files: SourceFile[] } | null>('GET', url(key)))?.files ?? null
}
/** keepalive:页面关闭时也把请求发出去 */
export async function saveWork(key: string, files: SourceFile[], keepalive = false) {
await api('PUT', url(key), { files }, { keepalive })
}
export async function deleteWork(key: string) {
await api('DELETE', url(key))
}
+6 -7
View File
@@ -1,11 +1,10 @@
import { createApp } from 'vue'
import AccessGate from './AccessGate.vue'
import App from './App.vue'
import { loadCurrentUser } from './auth'
import Root from './Root.vue'
import { router } from './router'
import './winforms/winforms.css'
// 服务器设置了访问口令而浏览器还没输过时,先显示口令页
const auth = await fetch('/api/auth')
.then((response) => response.json() as Promise<{ ok: boolean }>)
.catch(() => ({ ok: true }))
// 先确认是否已登录,路由守卫据此决定进登录页还是实验页
await loadCurrentUser()
createApp(auth.ok ? App : AccessGate).mount('#app')
createApp(Root).use(router).mount('#app')
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
// 登录页:学生用老师发的账号密码登录,老师用自己的账号登录。
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput } from 'naive-ui'
import { login } from '../auth'
const route = useRoute()
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const busy = ref(false)
async function submit() {
if (!username.value || !password.value || busy.value) return
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 : '/'
await router.replace(redirect)
} catch (e) {
error.value = (e as Error).message
} finally {
busy.value = false
}
}
</script>
<template>
<div class="gate">
<form class="card" @submit.prevent="submit">
<h1>物联网编程仿真平台</h1>
<p>用老师发给你的账号和密码登录</p>
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
<NInput
v-model:value="password"
type="password"
show-password-on="click"
placeholder="密码"
:input-props="{ autocomplete: 'current-password' }"
/>
<div v-if="error" class="error">{{ error }}</div>
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
</form>
</div>
</template>
<style scoped>
.gate {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 16px;
box-sizing: border-box;
background: #f3f4f6;
}
.card {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 320px;
padding: 28px 24px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
h1 {
margin: 0;
font-size: 18px;
}
p {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.error {
color: #dc2626;
font-size: 13px;
}
</style>
+480
View File
@@ -0,0 +1,480 @@
<script setup lang="ts">
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生。
import { computed, h, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
interface ClassInfo {
id: number
name: string
studentCount: number
}
interface Student {
id: number
username: string
name: string
password: string
}
interface ImportResult {
created: number
updated: number
errors: { row: number; message: string }[]
}
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const classes = ref<ClassInfo[]>([])
const selectedId = ref<number>()
const selected = computed(() => classes.value.find((c) => c.id === selectedId.value))
const students = ref<Student[]>([])
/** 出错时弹出服务器给的说明 */
async function attempt<T>(action: () => Promise<T>): Promise<T | undefined> {
try {
return await action()
} catch (e) {
message.error((e as Error).message)
}
}
async function loadClasses() {
classes.value = (await attempt(() => api<ClassInfo[]>('GET', '/api/teacher/classes'))) ?? []
if (!selected.value) selectedId.value = classes.value[0]?.id
}
async function loadStudents() {
students.value = selectedId.value
? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? [])
: []
}
async function selectClass(id: number) {
selectedId.value = id
await loadStudents()
}
onMounted(async () => {
await loadClasses()
await loadStudents()
})
// ---------- 班级 ----------
const newClassName = ref('')
const newClassOpen = ref(false)
async function createClass() {
const created = await attempt(() => api<{ id: number }>('POST', '/api/teacher/classes', { name: newClassName.value }))
if (!created) return
newClassName.value = ''
newClassOpen.value = false
await loadClasses()
await selectClass(created.id)
}
const renameValue = ref('')
const renameOpen = ref(false)
async function renameClass() {
if (!selected.value) return
const ok = await attempt(() => api('PATCH', `/api/teacher/classes/${selected.value!.id}`, { name: renameValue.value }))
if (!ok) return
renameOpen.value = false
await loadClasses()
}
async function deleteClass() {
if (!selected.value) return
const ok = await attempt(() => api('DELETE', `/api/teacher/classes/${selected.value!.id}`))
if (!ok) return
selectedId.value = undefined
await loadClasses()
await loadStudents()
}
// ---------- 学生 ----------
async function resetPassword(student: Student) {
const result = await attempt(() => api<{ password: string }>('POST', `/api/teacher/students/${student.id}/reset-password`))
if (!result) return
message.success(`${student.name} 的新密码:${result.password}`)
await loadStudents()
}
function deleteStudent(student: Student) {
dialog.warning({
title: '删除学生',
content: `将删除 ${student.name}(${student.username})和他保存的所有作品,不能恢复。确定删除吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await attempt(() => api('DELETE', `/api/teacher/students/${student.id}`))
await loadClasses()
await loadStudents()
},
})
}
const columns: DataTableColumns<Student> = [
{ title: '账号', key: 'username', sorter: 'default' },
{ title: '姓名', key: 'name' },
{ title: '密码', key: 'password', render: (s) => h('code', s.password) },
{
title: '操作',
key: 'actions',
width: 180,
render: (s) =>
h('div', { class: 'row-actions' }, [
h(NButton, { size: 'tiny', onClick: () => resetPassword(s) }, () => '重置密码'),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteStudent(s) }, () => '删除'),
]),
},
]
// ---------- 导入、导出名单 ----------
const fileInput = ref<HTMLInputElement>()
const preview = ref<RosterRow[] | null>(null)
const previewValid = computed(() => preview.value?.filter((r) => !r.problem) ?? [])
const importing = ref(false)
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const rows = toRoster(parseCsv(decodeCsv(await file.arrayBuffer())))
if (rows.length === 0) {
message.warning('文件里没有读到学生')
return
}
preview.value = rows
}
async function confirmImport() {
if (!selected.value || previewValid.value.length === 0) return
importing.value = true
const rows = previewValid.value.map(({ username, name, password }) => ({ username, name, password }))
const result = await attempt(() => api<ImportResult>('POST', `/api/teacher/classes/${selected.value!.id}/import`, { rows }))
importing.value = false
if (!result) return
preview.value = null
await loadClasses()
await loadStudents()
const summary = `新增 ${result.created} 人,更新 ${result.updated} 人`
if (result.errors.length === 0) message.success(summary)
else
dialog.warning({
title: '部分学生没有导入',
content: () =>
h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
positiveText: '知道了',
})
}
/** 下载 CSV:带 BOM,Excel 打开时中文不乱码;账号前加制表符,长学号不会变成科学计数法 */
function downloadCsv(filename: string, rows: string[][]) {
const quote = (cell: string) => (/[",\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell)
const text = '' + rows.map((r) => r.map(quote).join(',')).join('\r\n') + '\r\n'
const link = document.createElement('a')
link.href = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }))
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
function exportRoster() {
if (!selected.value) return
downloadCsv(`${selected.value.name}-名单.csv`, [
['学号', '姓名', '密码'],
...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`]),
])
}
function downloadTemplate() {
downloadCsv('学生名单模板.csv', [
['学号', '姓名', '密码'],
['\t2024001', '张三', ''],
])
}
async function signOut() {
await logout()
await router.push('/login')
}
</script>
<template>
<div class="page">
<header class="toolbar">
<strong>物联网编程仿真平台</strong>
<span class="title">班级管理</span>
<RouterLink to="/" class="nav-link">返回实验</RouterLink>
<span class="spacer" />
<span class="user">{{ currentUser?.name }}</span>
<NButton quaternary size="small" @click="signOut">退出</NButton>
</header>
<main class="content">
<aside class="classes">
<div class="classes-head">
<span>班级</span>
<NPopover v-model:show="newClassOpen" trigger="click" placement="bottom-end">
<template #trigger>
<NButton size="small" quaternary>+ 新建</NButton>
</template>
<form class="popover-form" @submit.prevent="createClass">
<NInput v-model:value="newClassName" size="small" placeholder="班级名,如 物联网 2401" autofocus />
<NButton size="small" type="primary" attr-type="submit" :disabled="!newClassName.trim()">创建</NButton>
</form>
</NPopover>
</div>
<button
v-for="c in classes"
:key="c.id"
class="class-item"
:class="{ active: c.id === selectedId }"
@click="selectClass(c.id)"
>
<span>{{ c.name }}</span>
<span class="count">{{ c.studentCount }} 人</span>
</button>
<NEmpty v-if="classes.length === 0" description="还没有班级,先新建一个" size="small" class="empty" />
</aside>
<section v-if="selected" class="students">
<div class="students-head">
<h2>{{ selected.name }}</h2>
<NPopover v-model:show="renameOpen" trigger="click" @update:show="(v: boolean) => v && (renameValue = selected!.name)">
<template #trigger>
<NButton size="small" quaternary>改名</NButton>
</template>
<form class="popover-form" @submit.prevent="renameClass">
<NInput v-model:value="renameValue" size="small" autofocus />
<NButton size="small" type="primary" attr-type="submit" :disabled="!renameValue.trim()">保存</NButton>
</form>
</NPopover>
<NPopconfirm positive-text="删除" negative-text="取消" @positive-click="deleteClass">
<template #trigger>
<NButton size="small" quaternary>删除班级</NButton>
</template>
只能删除没有学生的班级。确定删除"{{ selected.name }}"吗?
</NPopconfirm>
<span class="spacer" />
<NButton size="small" type="primary" @click="fileInput?.click()">导入名单(CSV)</NButton>
<NButton size="small" :disabled="students.length === 0" @click="exportRoster">导出名单(含密码)</NButton>
<NButton size="small" quaternary @click="downloadTemplate">下载名单模板</NButton>
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
</div>
<p class="tip">
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
</p>
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
</section>
<section v-else class="students">
<NEmpty description="选择或新建一个班级" />
</section>
</main>
<NModal
:show="preview !== null"
preset="card"
:title="`导入到 ${selected?.name ?? ''}`"
style="width: 640px; max-width: calc(100vw - 32px)"
@update:show="(v: boolean) => !v && (preview = null)"
>
<p>
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length">,
<span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
>。
</p>
<div class="preview">
<table>
<thead>
<tr><th>学号</th><th>姓名</th><th>密码</th><th /></tr>
</thead>
<tbody>
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
<td>{{ r.username }}</td>
<td>{{ r.name }}</td>
<td>{{ r.password || '(自动生成)' }}</td>
<td class="problem">{{ r.problem }}</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="modal-actions">
<NButton @click="preview = null">取消</NButton>
<NButton type="primary" :loading="importing" :disabled="previewValid.length === 0" @click="confirmImport">
导入 {{ previewValid.length }} 人
</NButton>
</div>
</template>
</NModal>
</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;
}
.title {
color: #374151;
}
.spacer {
flex: 1;
}
.nav-link {
color: #2563eb;
font-size: 13px;
text-decoration: none;
}
.user {
font-size: 13px;
}
.content {
flex: 1;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
min-height: 0;
}
.classes {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
border-right: 1px solid #e5e7eb;
overflow: auto;
}
.classes-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px 6px;
color: #6b7280;
font-size: 13px;
}
.class-item {
display: flex;
justify-content: space-between;
padding: 8px 10px;
background: none;
border: none;
border-radius: 4px;
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
}
.class-item:hover {
background: #f3f4f6;
}
.class-item.active {
background: #e0ecff;
color: #1d4ed8;
}
.count {
color: #9ca3af;
font-size: 12px;
}
.empty {
margin-top: 24px;
}
.students {
padding: 12px 20px;
overflow: auto;
}
.students-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.students-head h2 {
margin: 0 4px 0 0;
font-size: 18px;
}
.tip {
color: #6b7280;
font-size: 12px;
}
.popover-form {
display: flex;
gap: 6px;
width: 260px;
}
:deep(.row-actions) {
display: flex;
gap: 6px;
}
.preview {
max-height: 50vh;
overflow: auto;
}
.preview table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.preview th,
.preview td {
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
text-align: left;
white-space: nowrap;
}
.preview td.problem {
white-space: normal;
}
.preview tr.bad td {
background: #fef2f2;
}
.problem {
color: #dc2626;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -1,17 +1,20 @@
<script setup lang="ts">
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { NButton, NConfigProvider, NInput, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, zhCN } from 'naive-ui'
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 { clearDraft, loadDraft, saveDraft } from './editor/drafts'
import { createRunSession } from './run/session'
import TrainingBoard from './scene/TrainingBoard.vue'
import FormWindow from './winforms/FormWindow.vue'
import MessageBoxDialog from './winforms/MessageBoxDialog.vue'
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
@@ -51,11 +54,12 @@ const fileText = (path: string) => files.value.find((f) => f.path === path)?.con
const designerHistory = createDesignerHistory({ read: fileText, write: (path, content) => updateFile(path, content) })
// 开发模式下暴露页面状态,供浏览器自动化测试读取
if (import.meta.env.DEV) Object.assign(window, { __iot: { files, fileText, designerHistory, activeTab } })
/** 当前代码是否和示例原始代码不同(来自浏览器里保存的草稿) */
if (import.meta.env.DEV) Object.assign(window, { __iot: { files, fileText, designerHistory, activeTab, updateFile } })
/** 当前代码是否和示例原始代码不同(服务器上有这个人的作品) */
const modified = ref(false)
const editorRef = ref<InstanceType<typeof CodeEditor>>()
let saveTimer: ReturnType<typeof setTimeout> | undefined
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')
@@ -79,26 +83,68 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture
onMounted(async () => {
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
void session.connect()
const list: { id: string; title: string }[] = await (await fetch('/api/samples')).json()
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
const requested = new URLSearchParams(location.search).get('sample')
const initial = list.find((s) => s.id === requested) ?? list[0]
const initial = list.find((s) => s.id === route.query.sample) ?? list[0]
if (initial) await loadSample(initial.id)
})
async function loadSample(id: string, discardDraft = false) {
async function loadSample(id: string, discardWork = false) {
await flushSave()
sampleId.value = id
history.replaceState(null, '', `?sample=${encodeURIComponent(id)}`)
if (discardDraft) clearDraft(id)
void router.replace({ query: { sample: id } })
designerHistory.clear()
const draft = discardDraft ? null : loadDraft(id)
modified.value = draft !== null
files.value = draft ?? (await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json())
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
@@ -108,8 +154,7 @@ function updateFile(path: string, content: string) {
function filesChanged() {
modified.value = true
clearTimeout(saveTimer)
saveTimer = setTimeout(() => sampleId.value && saveDraft(sampleId.value, files.value), 500)
scheduleSave()
}
// ---------- 新建、删除窗体 ----------
@@ -197,124 +242,121 @@ function run() {
</script>
<template>
<NConfigProvider :locale="zhCN">
<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>
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
<span class="metrics">
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
{{ state.metrics.lastRoundTripMs }} ms
</span>
</header>
<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>
<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>
<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>
</NConfigProvider>
</template>
</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>
<style>
body {
margin: 0;
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
}
</style>
<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 {
@@ -341,6 +383,27 @@ body {
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;
+27
View File
@@ -0,0 +1,27 @@
// 页面路由:登录页、实验页(学生和老师都用)、老师的班级管理页。
import { createRouter, createWebHistory } from 'vue-router'
import { apiHooks } from './api'
import { currentUser } from './auth'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } },
{ path: '/', component: () => import('./pages/WorkbenchPage.vue') },
{ path: '/teacher', component: () => import('./pages/TeacherPage.vue'), meta: { teacher: true } },
{ path: '/:rest(.*)*', redirect: '/' },
],
})
router.beforeEach((to) => {
if (to.meta.public) return currentUser.value && to.path === '/login' ? '/' : true
if (!currentUser.value) return { path: '/login', query: to.fullPath === '/' ? {} : { redirect: to.fullPath } }
if (to.meta.teacher && currentUser.value.role !== 'teacher') return '/'
return true
})
// 登录过期(比如老师重置了密码):回到登录页
apiHooks.onUnauthorized = () => {
currentUser.value = null
if (router.currentRoute.value.path !== '/login') void router.push('/login')
}
+43
View File
@@ -0,0 +1,43 @@
/// <reference types="bun" />
import { describe, expect, test } from 'bun:test'
import { decodeCsv, parseCsv, toRoster } from './roster'
describe('decodeCsv', () => {
test('UTF-8(带 BOM,Excel 的"CSV UTF-8")', () => {
const bytes = new TextEncoder().encode('学号,姓名\n2024001,张三\n')
expect(decodeCsv(bytes.buffer)).toBe('学号,姓名\n2024001,张三\n')
})
test('GBK(中文 Windows 上 Excel 的"CSV(逗号分隔)")', () => {
// "学号,姓名" 的 GBK 编码
const gbk = new Uint8Array([0xd1, 0xa7, 0xba, 0xc5, 0x2c, 0xd0, 0xd5, 0xc3, 0xfb])
expect(decodeCsv(gbk.buffer)).toBe('学号,姓名')
})
})
describe('parseCsv', () => {
test('引号里的逗号、换行和两个双引号;CRLF 换行;忽略空行', () => {
expect(parseCsv('a,"b,c","d ""e""\nf"\r\n\r\n1,2,3\r\n,,\r\n')).toEqual([
['a', 'b,c', 'd "e"\nf'],
['1', '2', '3'],
])
})
})
describe('toRoster', () => {
test('有表头时按表头找列,列的顺序可以不同', () => {
expect(toRoster(parseCsv('姓名,学号,密码\n张三,2024001,abc\n李四,2024002,\n'))).toEqual([
{ username: '2024001', name: '张三', password: 'abc' },
{ username: '2024002', name: '李四', password: '' },
])
})
test('没有表头时按"账号、姓名、密码"的顺序', () => {
expect(toRoster(parseCsv('2024001,张三\n'))).toEqual([{ username: '2024001', name: '张三', password: '' }])
})
test('科学计数法的学号、空账号、空姓名标出问题', () => {
const rows = toRoster(parseCsv('学号,姓名\n2.02401E+11,张三\n,李四\n2024003,\n'))
expect(rows.map((r) => r.problem)).toEqual([expect.stringContaining('科学计数法'), '账号为空', '姓名为空'])
})
})
+91
View File
@@ -0,0 +1,91 @@
// 读取老师上传的学生名单(CSV)。名单通常由 Excel 导出:
// 中文 Windows 上"CSV(逗号分隔)"是 GBK 编码,"CSV UTF-8"带 BOM,两种都要支持;
// 第一行可能是表头(学号、姓名、密码),没有表头时按"账号、姓名、密码"的顺序读。
export interface RosterRow {
username: string
name: string
password: string
/** 这一行的问题;有问题的行不导入 */
problem?: string
}
/** 文件内容转成文字:先按 UTF-8 解码,不是合法的 UTF-8 就按 GBK(GB18030)解码 */
export function decodeCsv(bytes: ArrayBuffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^/, '')
} catch {
return new TextDecoder('gb18030').decode(bytes)
}
}
/** 按 CSV 规则拆成表格:支持双引号包起来的字段(里面可以有逗号、换行、两个双引号表示一个) */
export function parseCsv(text: string): string[][] {
const table: string[][] = []
let row: string[] = []
let field = ''
let quoted = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
field += '"'
i++
} else if (ch === '"') {
quoted = false
} else {
field += ch
}
} else if (ch === '"') {
quoted = true
} else if (ch === ',') {
row.push(field)
field = ''
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++
row.push(field)
table.push(row)
row = []
field = ''
} else {
field += ch
}
}
if (field || row.length) {
row.push(field)
table.push(row)
}
// 去掉空行(Excel 导出的文件常在末尾多出空行)
return table.filter((r) => r.some((cell) => cell.trim()))
}
const HEADERS = {
username: ['账号', '帐号', '用户名', '学号', 'username', 'account'],
name: ['姓名', '名字', '真实姓名', 'name'],
password: ['密码', 'password'],
}
/** 表格转成名单:识别表头,检查每行的问题 */
export function toRoster(table: string[][]): RosterRow[] {
if (table.length === 0) return []
const header = table[0].map((cell) => cell.trim().toLowerCase())
const column = (aliases: string[]) => header.findIndex((cell) => aliases.includes(cell))
const hasHeader = column(HEADERS.username) >= 0 && column(HEADERS.name) >= 0
const index = hasHeader
? { username: column(HEADERS.username), name: column(HEADERS.name), password: column(HEADERS.password) }
: { username: 0, name: 1, password: 2 }
return table.slice(hasHeader ? 1 : 0).map((cells) => {
const cell = (i: number) => (i >= 0 ? (cells[i] ?? '').trim() : '')
const row: RosterRow = { username: cell(index.username), name: cell(index.name), password: cell(index.password) }
// Excel 会把长数字(如 12 位学号)显示成科学计数法,导出 CSV 时也照样写出去
if (/^\d+(\.\d+)?e\+\d+$/i.test(row.username)) {
row.problem = '账号变成了科学计数法:在 Excel 里把这一列设为"文本"格式、重新输入后再导出'
} else if (!row.username) {
row.problem = '账号为空'
} else if (!row.name) {
row.problem = '姓名为空'
}
return row
})
}