代码编辑器换成 Monaco:C# 高亮、错误波浪线与跳转、F5 运行、草稿保存

- 编译错误以波浪线标在代码里,点下方错误信息跳到对应行
- F5 运行、Shift+F5 停止,在整个页面生效并阻止浏览器刷新
- 改过的代码保存在浏览器本地,刷新不丢,可一键恢复原始代码
- Designer.cs 顶部提示由设计器生成
- Monaco 按需加载(编辑功能 + C# 高亮),打包后约 1.2 MB(gzip)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 10:56:34 +08:00
1 parent fbb8a334c3
commit 03e80ce2e2
8 files changed
+405 -153

No files matched your search

+76 -29
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
// 第 0 阶段的验证页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体。
import { computed, onMounted, ref } from 'vue'
import { NButton, NConfigProvider, NSelect, NTabPane, NTabs, NTag, zhCN } from 'naive-ui'
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { NButton, NConfigProvider, NPopconfirm, NSelect, NTab, NTabs, NTag, zhCN } from 'naive-ui'
import type { SourceFile } from '@iot/shared'
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'
@@ -15,11 +17,30 @@ const samples = ref<{ label: string; value: string }[]>([])
const sampleId = ref<string>()
const files = ref<SourceFile[]>([])
const activeFile = ref<string>()
/** 当前代码是否和示例原始代码不同(来自浏览器里保存的草稿) */
const modified = ref(false)
const editorRef = ref<InstanceType<typeof CodeEditor>>()
let saveTimer: ReturnType<typeof setTimeout> | undefined
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
const running = computed(() => state.status === 'compiling' || state.status === 'running')
/**
* F5 运行、Shift+F5 停止,与 Visual Studio 一致。在整个页面上生效,
* 并阻止浏览器默认的"F5 刷新页面"(否则学生按习惯按下 F5,正在运行的程序和窗体都会丢失)。
*/
function onKeyDown(event: KeyboardEvent) {
if (event.key !== 'F5') return
event.preventDefault()
event.stopPropagation()
if (event.shiftKey) session.stop()
else run()
}
onMounted(() => window.addEventListener('keydown', onKeyDown, { capture: true }))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture: true }))
onMounted(async () => {
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
void session.connect()
@@ -31,10 +52,13 @@ onMounted(async () => {
if (initial) await loadSample(initial.id)
})
async function loadSample(id: string) {
async function loadSample(id: string, discardDraft = false) {
sampleId.value = id
history.replaceState(null, '', `?sample=${encodeURIComponent(id)}`)
files.value = await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json()
if (discardDraft) clearDraft(id)
const draft = discardDraft ? null : loadDraft(id)
modified.value = draft !== null
files.value = draft ?? (await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json())
// 默认打开窗体的代码文件(不一定叫 Form1.cs,如教材的 frm_analog.cs)
activeFile.value =
files.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? files.value[0]?.path
@@ -42,10 +66,19 @@ async function loadSample(id: string) {
function updateFile(path: string, content: string) {
const file = files.value.find((f) => f.path === path)
if (file) file.content = content
if (!file || file.content === content) return
file.content = content
modified.value = true
clearTimeout(saveTimer)
saveTimer = setTimeout(() => sampleId.value && saveDraft(sampleId.value, files.value), 500)
}
function restoreOriginal() {
if (sampleId.value) void loadSample(sampleId.value, true)
}
function run() {
if (running.value) return
session.run(files.value.map((f) => ({ ...f })))
}
</script>
@@ -62,8 +95,14 @@ function run() {
placeholder="选择示例工程"
@update:value="loadSample"
/>
<NButton type="primary" :disabled="running" @click="run">▶ 运行</NButton>
<NButton :disabled="!running" @click="session.stop">■ 停止</NButton>
<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 · 最近一次操作往返
@@ -74,15 +113,16 @@ function run() {
<main class="workspace">
<section class="editor">
<NTabs v-model:value="activeFile" type="card" size="small">
<NTabPane v-for="file in files" :key="file.path" :name="file.path" :tab="file.path">
<textarea
class="code"
spellcheck="false"
:value="file.content"
@input="updateFile(file.path, ($event.target as HTMLTextAreaElement).value)"
/>
</NTabPane>
<NTab v-for="file in files" :key="file.path" :name="file.path">{{ file.path }}</NTab>
</NTabs>
<CodeEditor
ref="editorRef"
class="code"
:files="files"
:active-file="activeFile"
:diagnostics="state.diagnostics"
@change="updateFile"
/>
</section>
<section class="run-area">
@@ -105,7 +145,13 @@ function run() {
</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]">
<div
v-for="(d, i) in state.diagnostics"
:key="`d${i}`"
:class="['diag', d.severity]"
title="点击跳到出错的代码"
@click="editorRef?.reveal(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>
@@ -158,24 +204,17 @@ body {
}
.editor {
display: flex;
flex-direction: column;
min-height: 0;
padding: 8px;
border-right: 1px solid #e5e7eb;
overflow: hidden;
}
.code {
width: 100%;
height: calc(100vh - 120px);
box-sizing: border-box;
font-family: "JetBrains Mono", "Cascadia Code", Consolas, "Noto Sans Mono CJK SC", monospace;
font-size: 13px;
line-height: 1.5;
tab-size: 4;
/* 关闭连字:避免 >= 显示成 ≥,学生照着输入时出错 */
font-variant-ligatures: none;
border: 1px solid #e5e7eb;
padding: 8px;
resize: none;
flex: 1;
min-height: 0;
}
.run-area {
@@ -212,6 +251,14 @@ body {
font-size: 12px;
}
.diag {
cursor: pointer;
}
.diag:hover {
text-decoration: underline;
}
.diag.error,
.error {
color: #b91c1c;
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
// 代码编辑器:每个文件一个 Monaco 模型(各自保留撤销记录),切换标签页时换模型。
// 编译错误以波浪线标在代码里。F5 / Shift+F5 由页面统一处理(见 App.vue),这里不再单独绑定。
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import type { Diagnostic, SourceFile } from '@iot/shared'
import { monaco } from './monaco'
const props = defineProps<{
files: SourceFile[]
activeFile?: string
diagnostics: Diagnostic[]
}>()
const emit = defineEmits<{
change: [path: string, content: string]
}>()
const container = ref<HTMLElement>()
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>()
const models = new Map<string, monaco.editor.ITextModel>()
function modelFor(file: SourceFile) {
let model = models.get(file.path)
if (!model) {
model = monaco.editor.createModel(file.content, 'csharp', monaco.Uri.file(file.path))
model.onDidChangeContent(() => emit('change', file.path, model!.getValue()))
models.set(file.path, model)
} else if (model.getValue() !== file.content) {
// 外部换了内容(切换示例、恢复原始代码)
model.setValue(file.content)
}
return model
}
/** 文件列表变化时:为新文件建模型,删掉已不存在的文件的模型 */
function syncModels() {
const paths = new Set(props.files.map((f) => f.path))
for (const [path, model] of models) {
if (!paths.has(path)) {
model.dispose()
models.delete(path)
}
}
for (const file of props.files) modelFor(file)
}
function showActive() {
const file = props.files.find((f) => f.path === props.activeFile)
if (!editor.value || !file) return
const model = modelFor(file)
if (editor.value.getModel() !== model) editor.value.setModel(model)
}
function applyMarkers() {
for (const [path, model] of models) {
const markers = props.diagnostics
.filter((d) => d.file === path)
.map((d) => ({
severity: d.severity === 'error' ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
message: d.hint ? `${d.message}\n提示:${d.hint}` : d.message,
code: d.code,
startLineNumber: d.line,
startColumn: d.column,
endLineNumber: d.line,
// 编译器只给出起点;标到该行的下一个词结尾,至少一个字符
endColumn: (model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1),
}))
monaco.editor.setModelMarkers(model, 'csharp-compiler', markers)
}
}
/** 跳到某个文件的某一行(点击下方的错误信息时调用) */
function reveal(path: string, line: number, column: number) {
const file = props.files.find((f) => f.path === path)
if (!editor.value || !file) return
editor.value.setModel(modelFor(file))
editor.value.setPosition({ lineNumber: line, column })
editor.value.revealLineInCenter(line)
editor.value.focus()
}
defineExpose({ reveal })
onMounted(() => {
editor.value = monaco.editor.create(container.value!, {
language: 'csharp',
theme: 'vs',
automaticLayout: true,
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, "Noto Sans Mono CJK SC", monospace',
fontSize: 14,
// 关闭连字:避免 >= 显示成 ≥,学生照着输入时出错
fontLigatures: false,
tabSize: 4,
insertSpaces: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderWhitespace: 'selection',
unicodeHighlight: { ambiguousCharacters: false },
})
syncModels()
showActive()
applyMarkers()
})
watch(() => props.files, () => {
syncModels()
showActive()
applyMarkers()
})
watch(() => props.activeFile, showActive)
watch(() => props.diagnostics, applyMarkers)
onBeforeUnmount(() => {
editor.value?.dispose()
for (const model of models.values()) model.dispose()
models.clear()
})
</script>
<template>
<div class="code-editor">
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">
此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。
</div>
<div ref="container" class="monaco-host" />
</div>
</template>
<style scoped>
.code-editor {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border: 1px solid #e5e7eb;
}
.designer-note {
padding: 4px 8px;
background: #fffbe6;
border-bottom: 1px solid #f5e6a8;
color: #8a6d00;
font-size: 12px;
}
.monaco-host {
flex: 1;
min-height: 0;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
// 学生改过的代码保存在浏览器本地(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 {
// 忽略
}
}
+12
View File
@@ -0,0 +1,12 @@
// 按需加载 Monaco(0.57 的模块布局):编辑器 API + 全部编辑功能(查找、折叠、括号匹配等)+ C# 语法高亮,
// 不加载其他几十种语言和 JS/TS/CSS 语言服务,体积小很多。
import * as monaco from 'monaco-editor/editor/editor.api'
import 'monaco-editor/features/register.all'
import 'monaco-editor/languages/definitions/csharp/register'
import EditorWorker from 'monaco-editor/editor/editor.worker?worker'
self.MonacoEnvironment = {
getWorker: () => new EditorWorker(),
}
export { monaco }