第 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
+2
-1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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('科学计数法'), '账号为空', '姓名为空'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user