Files
IOT_SIM/web/src/pages/WorkbenchPage.vue
T
xuyueandClaude Opus 5.5 f304fc1ffc 窄屏工具栏不换行;数据库页面提示更新
- 屏幕宽度不到 1600 时不显示编译、往返时间(给开发看的),平台名、保存状态、链接不换行,
  1366 宽的机房电脑上工具栏是一行
- 数据库页面的提示:运行要用数据库的实验时自动建库,"恢复实验初始数据"用来恢复成初始数据

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:44:04 +08:00

773 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
// 三种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分);
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage } from 'naive-ui'
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
import ResourcesDialog from '../designer/ResourcesDialog.vue'
import { classPathError, formNameTaken, isFormDesigner, newClassFile, 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 RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue'
import SelfCheckPanel from '../grading/SelfCheckPanel.vue'
import { captureForms } from '../grading/screenshots'
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
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) => isFormDesigner(f.path))
.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`))
})
/**
* 标签页按窗体分组:每个窗体依次是 [设计]、代码;其他文件(Program.cs 等)放最后。
* Designer.cs 由设计器生成,平时不显示(和 VS 里藏在窗体文件下面一样),
* 在设计器里点"查看生成的代码"或点到其中的编译错误时才临时出现,切走后收起。
* 项目资源生成的 Properties/Resources.Designer.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 },
...(activeTab.value === f.designer ? [{ name: f.designer, label: f.designer }] : []),
]),
...files.value
.filter((f) => !grouped.has(f.path) && (f.path !== RESOURCES_FILE || activeTab.value === RESOURCES_FILE))
.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 message = useMessage()
/** 当前工程的作品保存在服务器上的哪个 key */
const workKey = ref<string>()
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 })
session.close()
})
onMounted(async () => {
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
void session.connect()
if (props.assignmentId) {
await loadAssignment()
return
}
if (props.submissionId) {
await loadSubmission()
return
}
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)
})
/** 打开一个工程:服务器上有保存的作品就用作品,没有就用原始代码;discardWork 表示丢弃作品恢复原始代码 */
async function openProject(key: string, original: () => Promise<SourceFile[]>, discardWork: boolean) {
await flushSave()
workKey.value = key
designerHistory.clear()
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 original())
// 和 VS 一样,默认打开主窗体的设计器
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.cs')?.path ?? files.value[0]?.path
}
async function loadSample(id: string, discardWork = false) {
sampleId.value = id
void router.replace({ query: { sample: id } })
sampleChecks.value = []
void api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
.then((titles) => {
if (sampleId.value === id) sampleChecks.value = titles
})
.catch(() => {})
await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork)
}
// ---------- 评价项和自测 ----------
const sampleChecks = ref<string[]>([])
const selfCheckOpen = ref(false)
/** 作业用布置时的评分规则,自由练习用实验的评分规则 */
const selfCheck = computed(() => {
if (props.assignmentId) {
const a = assignment.value
return a ? { titles: a.checkTitles, target: `assignment:${a.id}`, url: `/api/assignments/${a.id}/selfcheck`, points: a.scoring.auto } : null
}
const id = sampleId.value
return id && sampleChecks.value.length ? { titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` } : null
})
// ---------- 作业 ----------
const assignment = ref<Assignment | null>(null)
const submission = ref<Submission | null>(null)
const review = ref<Review | null>(null)
const resultOpen = ref(false)
const submitting = ref(false)
const pastDeadline = computed(() => !!assignment.value?.deadline && Date.now() > assignment.value.deadline)
const autoTotal = computed(() => assignment.value?.scoring.auto.reduce((a, b) => a + b, 0) ?? 0)
const manualTotal = computed(() => assignment.value?.scoring.manual.reduce((a, m) => a + m.points, 0) ?? 0)
/** 工具栏上的得分:老师评阅后显示总分,之前只显示自动评分 */
const scoreText = computed(() => {
const s = submission.value
if (!s) return ''
if (s.status === 'pending') return '评分中…'
if (s.status === 'error') return '评分出错'
if (!review.value) return `自动评分 ${s.autoScore} / ${autoTotal.value}`
const manual = review.value.scores.reduce<number>((a, x) => a + (x ?? 0), 0)
return `总分 ${(s.autoScore ?? 0) + manual} / ${autoTotal.value + manualTotal.value}`
})
let pollTimer: ReturnType<typeof setTimeout> | undefined
async function loadAssignment(discardWork = false) {
const detail = await api<{ assignment: Assignment; template: SourceFile[]; latest: Submission | null; review: Review | null }>(
'GET',
`/api/assignments/${props.assignmentId}`,
).catch((e: Error) => {
message.error(e.message)
return null
})
if (!detail) return
assignment.value = detail.assignment
submission.value = detail.latest
review.value = detail.review
if (detail.latest?.status === 'pending') pollSubmission()
await openProject(`assignment:${props.assignmentId}`, async () => detail.template.map((f) => ({ ...f })), discardWork)
}
// ---------- 老师运行学生提交的程序 ----------
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string } | null>(null)
async function loadSubmission() {
try {
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string }>(
'GET',
`/api/teacher/submissions/${props.submissionId}`,
)
viewing.value = result
files.value = result.files
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
} catch (e) {
message.error((e as Error).message)
}
}
async function submit() {
if (!assignment.value || submitting.value) return
submitting.value = true
try {
await flushSave()
// 每个窗体截一张图,老师评阅和 AI 评"窗体界面"时用;截图失败也照常提交
const screenshots = await captureForms(files.value).catch(() => [])
submission.value = await api<Submission>('POST', `/api/assignments/${assignment.value.id}/submit`, { files: files.value, screenshots })
resultOpen.value = true
pollSubmission()
} catch (e) {
message.error((e as Error).message)
} finally {
submitting.value = false
}
}
/** 评分中时每 2 秒查一次结果 */
function pollSubmission() {
clearTimeout(pollTimer)
pollTimer = setTimeout(async () => {
const latest = await api<Submission | null>('GET', `/api/assignments/${props.assignmentId}/submission`).catch(() => submission.value)
submission.value = latest
if (latest?.status === 'pending') pollSubmission()
}, 2000)
}
onBeforeUnmount(() => clearTimeout(pollTimer))
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
// ---------- 自动保存到服务器 ----------
/** 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' || !workKey.value) return
saveState.value = 'saving'
const snapshot = files.value.map((f) => ({ ...f }))
try {
await saveWork(workKey.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
// 老师试运行学生的提交时没有保存位置,不保存
if (workKey.value) scheduleSave()
}
// ---------- 项目资源 ----------
void loadGallery()
const resourcesOpen = ref(false)
const resourceNames = computed(() => projectResources(files.value))
/** 设计器里图片属性的选项:项目资源里的图片(声音不能当图片用) */
const designerResources = computed(() => ({
names: resourceNames.value.filter((name) => gallery.value.find((g) => g.name === name)?.kind !== 'audio'),
prefix: resourcePrefix(projectNamespace(files.value)),
}))
/** 保存勾选的项目资源:重新生成 Properties/Resources.Designer.cs(一个都没有时删掉) */
function saveResources(names: string[]) {
const file = resourcesFile(projectNamespace(files.value), names)
const rest = files.value.filter((f) => f.path !== RESOURCES_FILE)
files.value = file ? [...rest, file] : rest
filesChanged()
}
// ---------- 新建、删除类 ----------
const newClassOpen = ref(false)
const newClassName = ref('')
const newClassError = computed(() => classPathError(files.value, newClassName.value))
function openNewClass(show: boolean) {
newClassOpen.value = show
if (show) newClassName.value = ''
}
/** 新建类:和 VS 的"添加 → 类"一样,可以带文件夹(SQLHelpers/SQLHelper) */
function createClass() {
if (newClassError.value) return
const created = newClassFile(projectNamespace(files.value), newClassName.value)
files.value.push(created)
filesChanged()
newClassOpen.value = false
activeTab.value = created.path
}
/** 当前标签页的普通类文件能否删除:窗体文件、Program.cs、平台生成的资源类不在这里删 */
const deletableClass = computed(() => {
const path = activeTab.value
if (!path || activeForm.value || path === 'Program.cs' || path.startsWith('Properties/') || !path.endsWith('.cs')) return undefined
return forms.value.some((f) => f.code === path || f.designer === path) ? undefined : path
})
function deleteClass() {
const path = deletableClass.value
if (!path) return
files.value = files.value.filter((f) => f.path !== path)
filesChanged()
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
}
// ---------- 新建、删除窗体 ----------
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 (props.assignmentId) void loadAssignment(true)
else if (sampleId.value) void loadSample(sampleId.value, true)
}
// ---------- 收起运行区(设计大窗体、写代码时左栏占满宽度;运行程序时自动展开) ----------
const RUN_COLLAPSED_KEY = 'iot-sim:run-collapsed'
const runCollapsed = ref(readFlag(RUN_COLLAPSED_KEY))
function readFlag(key: string) {
try {
return localStorage.getItem(key) === '1'
} catch {
return false
}
}
function toggleRunArea(collapsed = !runCollapsed.value) {
runCollapsed.value = collapsed
try {
localStorage.setItem(RUN_COLLAPSED_KEY, collapsed ? '1' : '0')
} catch {
// 浏览器不让存时只在这次有效
}
}
function run() {
if (running.value) return
toggleRunArea(false)
session.run(
files.value.map((f) => ({ ...f })),
props.assignmentId ? assignment.value?.sampleId : sampleId.value,
)
}
</script>
<template>
<div class="page">
<header class="toolbar">
<template v-if="viewing">
<strong class="assignment-title">{{ viewing.studentName }}({{ viewing.username }})的提交:{{ viewing.assignmentTitle }}</strong>
<span class="deadline">可以运行和试改,改动不会保存</span>
</template>
<template v-else-if="assignmentId">
<RouterLink to="/assignments" class="nav-link">← 我的作业</RouterLink>
<strong class="assignment-title">{{ assignment?.title }}</strong>
<span v-if="assignment?.deadline" class="deadline" :class="{ past: pastDeadline }">
{{ pastDeadline ? '已截止' : '截止' }} {{ formatTime(assignment.deadline) }}
</span>
</template>
<template v-else>
<strong>物联网编程仿真平台</strong>
<NSelect
class="sample-select"
:value="sampleId"
:options="samples"
placeholder="选择示例工程"
@update:value="loadSample"
/>
</template>
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
<template v-if="assignment">
<NButton
type="warning"
:loading="submitting"
:disabled="submission?.status === 'pending' || (pastDeadline && currentUser?.role === 'student')"
title="提交后自动评分,截止前可以改了再交"
@click="submit"
>
提交
</NButton>
<NButton v-if="submission" size="small" quaternary @click="resultOpen = true">
{{ scoreText }}
</NButton>
</template>
<NButton v-if="selfCheck && !viewing" size="small" title="这个任务自动检查哪些功能;可以自己先检查一遍(不计分)" @click="selfCheckOpen = true">
评价项
</NButton>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
</template>
{{ assignmentId ? '你在这个作业上写的代码将全部丢弃(已提交的不受影响),确定恢复吗?' : '你对这个示例的修改将全部丢弃,确定恢复吗?' }}
</NPopconfirm>
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
<span v-if="!assignmentId && !viewing" class="metrics">
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
{{ state.metrics.lastRoundTripMs }} ms
</span>
<span class="spacer" />
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
<RouterLink v-if="!viewing" to="/database" target="_blank" class="nav-link" title="在新标签页打开数据库(代替 SSMS)">数据库</RouterLink>
<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>
<NModal
v-if="assignment && submission"
v-model:show="resultOpen"
preset="card"
:title="`评分结果:${assignment.title}`"
style="width: 560px; max-width: calc(100vw - 32px)"
>
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
</NModal>
<SelfCheckPanel
v-if="selfCheck"
v-model:show="selfCheckOpen"
:titles="selfCheck.titles"
:target="selfCheck.target"
:url="selfCheck.url"
:points="selfCheck.points"
:files="files"
:before-start="flushSave"
/>
<main class="workspace" :class="{ designing: activeForm, 'run-collapsed': runCollapsed }">
<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>
<NPopover trigger="click" placement="bottom-end" :show="newClassOpen" @update:show="openNewClass">
<template #trigger>
<NButton size="small" quaternary>+ 新建类</NButton>
</template>
<div class="new-form">
<div>类名(也是文件名;放进文件夹时写成 SQLHelpers/SQLHelper)</div>
<NInput v-model:value="newClassName" size="small" placeholder="例如:AppSet" @keydown.enter="createClass" />
<div v-if="newClassError && newClassName.trim()" class="new-form-error">{{ newClassError }}</div>
<NButton size="small" type="primary" :disabled="!!newClassError" @click="createClass">创建</NButton>
</div>
</NPopover>
<NPopconfirm v-if="deletableClass" positive-text="删除" negative-text="取消" @positive-click="deleteClass">
<template #trigger>
<NButton size="small" quaternary>删除类</NButton>
</template>
将删除 {{ deletableClass }},代码里用到它的地方需要自己修改。确定删除吗?
</NPopconfirm>
<NButton size="small" quaternary title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字" @click="resourcesOpen = true">
项目资源{{ resourceNames.length ? `(${resourceNames.length})` : '' }}
</NButton>
<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"
@open-designer-file="activeTab = activeForm.designer"
:resources="designerResources"
@add-resource="resourcesOpen = true"
/>
<ResourcesDialog v-model:show="resourcesOpen" :selected="resourceNames" @save="saveResources" />
<CodeEditor
v-show="!activeForm"
ref="editorRef"
class="code"
:files="files"
:active-file="activeFile"
:diagnostics="state.diagnostics"
@change="updateFile"
/>
</section>
<button
class="run-toggle"
:title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'"
@click="toggleRunArea()"
>
{{ runCollapsed ? '◂' : '▸' }}
</button>
<RunArea v-show="!runCollapsed" :session="session" @diagnostic="showDiagnostic" />
</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 {
color: #6b7280;
font-size: 12px;
}
/* 编译、往返时间是给开发看的,屏幕窄时(机房常见 1366 宽)不显示,免得工具栏挤成两行 */
@media (max-width: 1599px) {
.metrics {
display: none;
}
}
.spacer {
margin-left: auto;
}
.assignment-title {
white-space: nowrap;
}
.deadline {
color: #6b7280;
font-size: 12px;
white-space: nowrap;
}
.deadline.past {
color: #dc2626;
}
.save-state {
color: #6b7280;
font-size: 12px;
white-space: nowrap;
}
.toolbar > strong {
white-space: nowrap;
}
.save-state.error {
color: #dc2626;
}
.user {
font-size: 13px;
white-space: nowrap;
}
.nav-link {
color: #2563eb;
font-size: 13px;
white-space: nowrap;
text-decoration: none;
white-space: nowrap;
}
.workspace {
flex: 1;
display: grid;
grid-template-columns: minmax(360px, 2fr) 14px 3fr;
min-height: 0;
}
/* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */
.workspace.designing {
grid-template-columns: minmax(0, 3fr) 14px minmax(360px, 2fr);
}
.workspace.run-collapsed {
grid-template-columns: minmax(0, 1fr) 14px;
}
/* 编辑区和运行区之间的竖条:点一下收起、展开运行区 */
.run-toggle {
padding: 0;
border: none;
border-right: 1px solid #e5e7eb;
background: #f3f4f6;
color: #6b7280;
font-size: 10px;
cursor: pointer;
}
.run-toggle:hover {
background: #e5e7eb;
color: #111827;
}
.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;
}
</style>