代码编辑器换成 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:
1 parent
fbb8a334c3
commit
03e80ce2e2
8 files changed
+405
-153
No files matched your search
+76
-29
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user