任务说明补齐 23 份;设计器支持窗体改名
- 其余 21 个实验的 task.md:控件名照示例代码,"在平台上怎么试"和评分步骤一致,列出书上的印刷错误和控件名不一致 - 窗体改名:属性窗口里选中窗体可以改名称,类名、构造函数、new X()、Program.cs 和两个文件名一起改;方法名和字符串不变 - 11 个主窗体不叫 Form1 的实验,任务说明提示先改窗体名再双击生成 Load 事件 - 测试:表头只认分隔行上面那一行,数据行里出现"名称"不再被当成表头 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90b739a2c7
commit
d4b8bf9241
29 files changed
+1236
-31
No files matched your search
@@ -35,6 +35,8 @@ const props = defineProps<{
|
||||
canRedo: boolean
|
||||
/** 项目资源(图片属性的选项) */
|
||||
resources?: { names: string[]; prefix: string }
|
||||
/** 窗体改名时检查和工程里其他窗体、类有没有重名 */
|
||||
checkFormName?: (name: string) => string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -48,6 +50,8 @@ const emit = defineEmits<{
|
||||
redo: []
|
||||
/** 属性窗口里要添加项目资源 */
|
||||
addResource: []
|
||||
/** 窗体本身改名(涉及多个文件,由实验页处理) */
|
||||
renameForm: [to: string]
|
||||
}>()
|
||||
|
||||
/** Ctrl+Z 撤销,Ctrl+Y / Ctrl+Shift+Z 重做;在属性窗口的输入框里打字时不拦截(输入框有自己的撤销) */
|
||||
@@ -551,9 +555,11 @@ const noop = () => {}
|
||||
:doc="doc"
|
||||
:selected="selected"
|
||||
:resources="resources"
|
||||
:check-form-name="checkFormName"
|
||||
@set-prop="onSetProp"
|
||||
@set-items="onSetItems"
|
||||
@rename="onRename"
|
||||
@rename-form="emit('renameForm', $event)"
|
||||
@open-event="openEvent"
|
||||
@clear-event="clearEvent"
|
||||
@add-resource="emit('addResource')"
|
||||
|
||||
@@ -15,12 +15,16 @@ const props = defineProps<{
|
||||
selected: string
|
||||
/** 项目资源(图片属性从这里选)和引用资源的前缀 global::命名空间.Properties.Resources. */
|
||||
resources?: { names: string[]; prefix: string }
|
||||
/** 窗体改名时检查新名字和工程里其他窗体、类有没有重名(没有问题返回 null) */
|
||||
checkFormName?: (name: string) => string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
setProp: [target: string, path: string, value: Value | null]
|
||||
setItems: [target: string, items: string[]]
|
||||
rename: [from: string, to: string]
|
||||
/** 窗体本身改名:类名和文件名一起改 */
|
||||
renameForm: [to: string]
|
||||
/** 双击事件或点"生成":绑定(必要时生成)事件处理方法并跳到代码 */
|
||||
openEvent: [target: string, event: string, handler?: string]
|
||||
clearEvent: [target: string, event: string]
|
||||
@@ -80,7 +84,13 @@ watch([() => props.selected, () => props.doc.formName], () => {
|
||||
|
||||
function commitName() {
|
||||
const name = nameDraft.value.trim()
|
||||
if (!props.selected || name === props.selected) return
|
||||
if (!props.selected) {
|
||||
if (name === props.doc.className) return
|
||||
nameError.value = checkName(props.doc, name, props.doc.formName) ?? props.checkFormName?.(name) ?? null
|
||||
if (!nameError.value) emit('renameForm', name)
|
||||
return
|
||||
}
|
||||
if (name === props.selected) return
|
||||
nameError.value = checkName(props.doc, name, props.selected)
|
||||
if (!nameError.value) emit('rename', props.selected, name)
|
||||
}
|
||||
@@ -171,9 +181,10 @@ function renameHandler(event: string, value: string) {
|
||||
<div class="row">
|
||||
<label title="Name:控件的名称,代码里用这个名字访问它">名称</label>
|
||||
<div class="editor">
|
||||
<input v-model="nameDraft" :disabled="selected === ''" @change="commitName" @keydown.enter="commitName" />
|
||||
<input v-model="nameDraft" @change="commitName" @keydown.enter="commitName" />
|
||||
<div v-if="nameError" class="error">{{ nameError }}</div>
|
||||
<div v-else-if="selected !== ''" class="note">改名后,代码里用到旧名字的地方要自己修改</div>
|
||||
<div v-else class="note">改名后,类名、文件名和 Program.cs 里的 new 跟着改;Form1_Load 这样的方法名不变</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+47
-23
@@ -45,32 +45,56 @@ export function classClosingBrace(code: string, className: string): number {
|
||||
if (i < 0) return -1
|
||||
let depth = 0
|
||||
while (i < code.length) {
|
||||
const c = code[i]
|
||||
if (code.startsWith('//', i)) {
|
||||
const end = code.indexOf('\n', i)
|
||||
i = end < 0 ? code.length : end
|
||||
const skipped = skipLiteral(code, i)
|
||||
if (skipped >= 0) {
|
||||
i = skipped
|
||||
continue
|
||||
}
|
||||
if (code.startsWith('/*', i)) {
|
||||
const end = code.indexOf('*/', i + 2)
|
||||
i = end < 0 ? code.length : end + 2
|
||||
continue
|
||||
}
|
||||
if (c === '@' && code[i + 1] === '"') {
|
||||
i += 2
|
||||
while (i < code.length && !(code[i] === '"' && code[i + 1] !== '"')) i += code[i] === '"' ? 2 : 1
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
i++
|
||||
while (i < code.length && code[i] !== c && code[i] !== '\n') i += code[i] === '\\' ? 2 : 1
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '{') depth++
|
||||
else if (c === '}' && --depth === 0) return i
|
||||
if (code[i] === '{') depth++
|
||||
else if (code[i] === '}' && --depth === 0) return i
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** i 处是注释、字符串或字符时返回它结束后的位置,否则返回 -1 */
|
||||
function skipLiteral(code: string, i: number): number {
|
||||
const c = code[i]
|
||||
if (code.startsWith('//', i)) {
|
||||
const end = code.indexOf('\n', i)
|
||||
return end < 0 ? code.length : end
|
||||
}
|
||||
if (code.startsWith('/*', i)) {
|
||||
const end = code.indexOf('*/', i + 2)
|
||||
return end < 0 ? code.length : end + 2
|
||||
}
|
||||
if (c === '@' && code[i + 1] === '"') {
|
||||
i += 2
|
||||
while (i < code.length && !(code[i] === '"' && code[i + 1] !== '"')) i += code[i] === '"' ? 2 : 1
|
||||
return i + 1
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
i++
|
||||
while (i < code.length && code[i] !== c && code[i] !== '\n') i += code[i] === '\\' ? 2 : 1
|
||||
return i + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** 把代码里的标识符 from 换成 to(整词,跳过字符串、字符和注释):Form1_Load、"Form1" 都不动 */
|
||||
export function renameIdentifier(code: string, from: string, to: string): string {
|
||||
const word = new RegExp(`(?<![\\p{L}\\p{N}_@])${from}(?![\\p{L}\\p{N}_])`, 'gu')
|
||||
let out = ''
|
||||
let start = 0
|
||||
let i = 0
|
||||
while (i < code.length) {
|
||||
const skipped = skipLiteral(code, i)
|
||||
if (skipped < 0) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out += code.slice(start, i).replace(word, to) + code.slice(i, skipped)
|
||||
start = i = skipped
|
||||
}
|
||||
return out + code.slice(start).replace(word, to)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from 'bun:test'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
import { addControl } from './edit'
|
||||
import { controlDef } from './catalog'
|
||||
import { assignmentTemplate, classPathError, formNameTaken, newClassFile, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
|
||||
import { assignmentTemplate, classPathError, formNameTaken, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from './templates'
|
||||
|
||||
const files = [
|
||||
{ path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' },
|
||||
@@ -80,3 +80,27 @@ test('新建类:可以带文件夹,文件夹也是命名空间的一段;
|
||||
expect(classPathError(files, '1abc')).toContain('不能以数字开头')
|
||||
expect(classPathError(files, 'Properties/X')).toContain('平台管理')
|
||||
})
|
||||
|
||||
test('窗体改名:类名、构造函数、Program.cs 和两个文件名一起改,方法名和字符串不动', async () => {
|
||||
const blank = assignmentTemplate(await sample('p8-t1-主界面'), 'blank')
|
||||
const code = blank.find((f) => f.path === 'Form1.cs')!
|
||||
code.content = code.content.replace('InitializeComponent();', 'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();')
|
||||
const files = renameForm(blank, 'Form1.Designer.cs', 'Main')
|
||||
expect(files.map((f) => f.path).sort()).toEqual(['Main.Designer.cs', 'Main.cs', 'Program.cs'])
|
||||
expect(files.find((f) => f.path === 'Program.cs')!.content).toContain('Application.Run(new Main())')
|
||||
const main = files.find((f) => f.path === 'Main.cs')!.content
|
||||
expect(main).toContain('public partial class Main : Form')
|
||||
expect(main).toContain('public Main()')
|
||||
expect(main).toContain('MessageBox.Show("Form1"); // Form1')
|
||||
expect(main).toContain('private void Form1_Load(object sender, EventArgs e)')
|
||||
expect(main).toContain('Main other = new Main();')
|
||||
const designer = files.find((f) => f.path === 'Main.Designer.cs')!.content
|
||||
const doc = parseDesigner(designer)
|
||||
expect(doc.className).toBe('Main')
|
||||
expect(doc.formName).toBe('Main')
|
||||
expect(designer).toContain(' // \n // Main\n // \n')
|
||||
expect(designer).toContain('this.Name = "Main";')
|
||||
// 窗体标题是 Text,VS 改名时也不改
|
||||
expect(designer).toContain('this.Text = "Form1";')
|
||||
expect(startupForm(files)).toBe('Main')
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件),以及布置作业时学生的起点工程。
|
||||
// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件)、窗体改名,以及布置作业时学生的起点工程。
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import { renameIdentifier } from './code'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
import { renderValue } from './values'
|
||||
|
||||
/** 工程的命名空间:取 Program.cs(没有就取任意 .cs)里的 namespace */
|
||||
export function projectNamespace(files: SourceFile[]): string {
|
||||
@@ -171,5 +173,34 @@ export function assignmentTemplate(files: SourceFile[], mode: StartMode): Source
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗体改名(属性窗口里窗体的"名称"):和 VS 一样,类名、构造函数和各文件里的 new X()、X 类型的变量都改,
|
||||
* Designer.cs 里窗体的块头注释和 this.Name 也改;两个文件跟着改名。
|
||||
* 事件处理方法名(如 Form1_Load)和字符串里的文字不动,VS 也不改。
|
||||
*/
|
||||
export function renameForm(files: SourceFile[], designerPath: string, to: string): SourceFile[] {
|
||||
const designer = files.find((f) => f.path === designerPath)
|
||||
if (!designer) return files
|
||||
const from = parseDesigner(designer.content).className
|
||||
const codePath = designerPath.replace(/\.Designer\.cs$/, '.cs')
|
||||
const dir = designerPath.slice(0, designerPath.lastIndexOf('/') + 1)
|
||||
return files.map((f) => {
|
||||
if (!f.path.endsWith('.cs')) return f
|
||||
const content = renameIdentifier(f.content, from, to)
|
||||
if (f.path === codePath) return { path: `${dir}${to}.cs`, content }
|
||||
if (f.path !== designerPath) return content === f.content ? f : { ...f, content }
|
||||
const doc = parseDesigner(content)
|
||||
const formBlock = doc.blocks.at(-1)
|
||||
if (formBlock) {
|
||||
formBlock.name = to
|
||||
formBlock.stmts = formBlock.stmts.map((s) =>
|
||||
s.kind === 'prop' && s.target === '' && s.path === 'Name' ? { ...s, value: renderValue({ kind: 'string', value: to }), text: undefined } : s,
|
||||
)
|
||||
}
|
||||
doc.formName = to
|
||||
return { path: `${dir}${to}.Designer.cs`, content: generateDesigner(doc) }
|
||||
})
|
||||
}
|
||||
|
||||
/** 窗体的 Designer.cs(Properties/ 下平台生成的 Resources.Designer.cs 不是窗体) */
|
||||
export const isFormDesigner = (path: string) => path.endsWith('.Designer.cs') && !path.startsWith('Properties/')
|
||||
@@ -10,7 +10,7 @@ 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 { classPathError, formNameTaken, isFormDesigner, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from '../designer/templates'
|
||||
import CodeEditor from '../editor/CodeEditor.vue'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
@@ -417,6 +417,18 @@ function deleteForm() {
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
|
||||
}
|
||||
|
||||
const checkFormName = (name: string) => (formNameTaken(files.value, name) ? `已经有名为“${name}”的窗体或类` : null)
|
||||
|
||||
/** 属性窗口里给窗体改名:类名、引用和两个文件名一起改(这一步不进设计器的撤销历史) */
|
||||
function renameActiveForm(name: string) {
|
||||
const form = activeForm.value
|
||||
if (!form) return
|
||||
files.value = renameForm(files.value, form.designer, name)
|
||||
designerHistory.clear(form.designer)
|
||||
filesChanged()
|
||||
activeTab.value = DESIGN + form.designer.replace(/[^/]*\.Designer\.cs$/, `${name}.Designer.cs`)
|
||||
}
|
||||
|
||||
/** 设计器改动了 Designer.cs(以及插入事件处理方法时的代码文件) */
|
||||
function onDesignerUpdate(designerText: string, codeText?: string) {
|
||||
const form = activeForm.value
|
||||
@@ -627,7 +639,9 @@ function run() {
|
||||
@open-code="openCode"
|
||||
@open-designer-file="activeTab = activeForm.designer"
|
||||
:resources="designerResources"
|
||||
:check-form-name="checkFormName"
|
||||
@add-resource="resourcesOpen = true"
|
||||
@rename-form="renameActiveForm"
|
||||
/>
|
||||
<ResourcesDialog v-model:show="resourcesOpen" :selected="resourceNames" @save="saveResources" />
|
||||
<CodeEditor
|
||||
|
||||
Reference in new issue
Block a user