☰
C语言课设:四重十字交叉链表实现教务信息管理系统
2026/11/2 3:43:06 网站建设 项目流程

简介:面向C语言课程设计学习者的教务信息管理系统实验报告,以高校《C语言程序设计》课程设计报告形式呈现,围绕模拟辅导员管理年级成绩表格的场景展开,适合需要完成课程设计、理解综合程序设计流程的学生参考。压缩包内为1个doc文档,约361KB,集中收录实验报告正文,包含源代码、流程图及较详细的设计说明。正文以十字交叉链表组织班级、学生、课程与成绩四重链,讲解结构体定义、模块化编译、文件存取、排序查找、统计班级与课程均分、不及格信息处理以及模拟打印等实现思路,并附菜单操作与数据输入规则说明。已有1121人学习,可帮助读者梳理从需求分析、数据结构设计到功能模块划分和报告撰写的完整脉络,也便于对照源码与流程图理解链表嵌套、工程文件组织及TC环境下的编译运行方式。

1. 一份 2006 年的课设:四重十字交叉链表撑起的教务信息管理系统

很多人对 C 语言课程设计的印象,是“一个结构体数组加六个 switch 菜单”。眼前这份教务信息管理系统走了另一条路:班级、学生、课程、成绩各成一条链,再按从属关系逐层挂接,形成四重十字交叉链表;九个.c文件按功能域拆开、用.prj工程串起来编译,运行环境是 Turbo C 2.0。全中文菜单、方向键操作、模拟打印、加权排名、按学号或姓名断点查找,全部在这套结构里跑通。它适合两类人:正在做课设、需要一个能直接跑通增删改查、排序、统计的完整骨架;以及写惯了现代框架、想回头看看在没有 STL、没有动态数组、只有 64KB 数据段的年代,链表是怎么被“逼”出工程化的。下面按建模、编译、交互、排查、验证的顺序拆开讲。

2. 四重十字交叉链表怎么建:四个结构体、三个从属字段和一次排序

2.1 四个结构体与三级从属关系:fall1、fall2、fall3 到底存了什么

这套数据模型的关键不在“四层”,而在每一层都用冗余的从属字段记录自己属于谁。班级链只有班号和一个学生链头指针;学生链里挂课程链头指针,同时用fall1[2]记下自己属于哪个班;课程链里挂成绩链头指针,用fall2[3]记下属于哪个班、哪个学生;成绩链用fall3[4]记下属于哪个班、哪个学生、哪门课。冗余的好处是:给定任意一个成绩节点,不需要回溯父节点就能定位它在整张网里的坐标,删除、修改、打印都不用二次遍历。

/* 班级链:整张网的入口,只有一个头指针在全局挂着 */ typedef struct classes { char class_no[5]; /* 班号,如 "0510",4 位 + '\0' */ struct classes *next1; /* 下一个班级 */ struct students *sthead; /* 本班学生链头指针 */ } CLASS; /* 学生链:基本信息 + 课程链入口 + 班级从属 */ struct students { char fall1[2]; /* fall1[0] = 所属班级序号(字符 '1' 起) */ char student_no[13]; /* 学号,12 位 + '\0' */ char name[17]; /* 姓名,最长 16 字节,够存一个中文名或拼音 */ char sex[2]; /* "0" 男 / "1" 女 */ char age[3]; /* 年龄,2 位 + '\0' */ char phone[12]; /* 寝室电话 */ char com_phone[12]; /* 手机,11 位 + '\0' */ char jia_quan[7]; /* 加权分,买菜式写法 "85.32" */ char all_credit[4]; /* 已得总学分 */ struct students *next2; /* 同班下一个学生 */ struct subjects *suhead; /* 本人课程链头指针 */ }; /* 课程链:挂在学生下面,课程信息 + 成绩链入口 */ struct subjects { char fall2[3]; /* fall2[0]=班级序号 fall2[1]=学生序号 */ char course_no[11]; /* 课程号,10 位 + '\0' */ char course_name[17]; /* 课程名 */ char credit[4]; /* 学分,如 "2.5" */ struct subjects *next3; /* 下一个学生节点 */ struct scores *chead; /* 本门课的成绩链头指针 */ }; /* 成绩链:挂在课程下面,一次考试一条 */ struct scores { char fall3[4]; /* [0]=班级 [1]=学生 [2]=课程,三个字符定位 */ char number[2]; /* 第几次考试,'1' 起 —— 只能到 9,这就是边界 */ char exam_score[4]; /* 卷面分 */ char usual_score[4]; /* 平时分 */ char coll_score[4]; /* 综合分,录入时算好写进来 */ char date[11]; /* 考试学期,如 "2005-2006",9 字符 + '\0' */ struct scores *next4; /* 同门课的下一次考试(重修就挂在这条链上) */ };

节点字段长度是按照实际数据长度确定的:学号 12 位对应char[13],手机 11 位对应char[12],姓名留 16 字节。这种做法在小内存环境下是必要的,但每个字段的长度都是不能超限,多输入一位就会覆盖相邻字段。number[2]只能存一位数字,同一门课考到第 10 次就会溢出;jia_quan[7]存不下 “100.00” 这种三位整数分。如果要改,第一件事就是把这些字段放宽到 8 或 16 字节,代价是每个学生节点多占十几字节,后面 2.5 节会计算这笔账。

2.2 全字段定长字符串:能用记事本修改的数据长什么样

这套设计里没有一个int或float字段,所有数值都以字符串形式保存,写文件时直接fprintf输出。这意味着保存出来的.txt打开后能直接看懂,成绩在文件里就是53,不是二进制读写后出现的乱码。修改数据只要保证字段长度不变、分隔符位置不变,程序再次打开文件就能正确读回来,调试时直接用记事本改两行数据看程序反应,省去了反复重新录入。

代价是所有比较和计算都要现场转换。排序按加权分比,得atof;统计均分要累加,得atof;判断不及格,得atoi。这里有个隐蔽的坑:atof对空串返回 0,对"abc"也返回 0,手改文件时不小心多敲一个空格,程序不会报错,只会安静地把这个学生算成 0 分。我一般会在读文件的入口加一道校验:字段非空且atof结果在合理区间内才接受,否则打印行号并跳过。

/* 读一个学生节点时的最小校验:空字段和非法数字都要挡在外面 */ static int valid_score(const char *s) { double v; if (s == NULL || s[0] == '\0') return 0; v = atof(s); if (v < 0.0 || v > 100.0) return 0; return 1; } /* 调用侧:任一分项非法就把整条成绩链丢弃并记一行日志 */ if (!valid_score(sc->exam_score) || !valid_score(sc->usual_score)) { fprintf(log_fp, "line %d: score out of range, skipped\n", line_no); continue; }

valid_score只做区间判断,不判断格式字符串里是否混入了字母:atof("5a3")会返回 5,程序会认为是合法分。要严格,得自己逐字符检查isdigit或小数点,这对课设来说通常够用,但交给实际使用之前必须补上,否则用户在键盘上误按一下,数据就会被静默丢弃。

2.3 建链顺序与 “00” 哨兵:四级联动的退出规则

建链的顺序是固定的:先建班级,在班级里建学生,在学生里建课程,在课程里建成绩。每层用"00"作为退出信号,返回上一级。这套规则的好处是不用记“现在在第几层”,只要看到"00"就return,天然支持任意深度回退;坏处是学号、课程号里只要真出现"00"开头(比如以 00 开头的两位编号),就会被当成退出信号吞掉。

/* 建课程链:输入 "00" 结束本学生的课程录入,返回建学生链的循环 */ struct subjects *build_subjects(const char *class_ord, const char *stu_ord) { struct subjects *head = NULL, *tail = NULL, *node; char buf[32]; while (1) { printf("课程号(00 结束):"); read_line(buf, sizeof buf); if (strcmp(buf, "00") == 0) break; /* 哨兵:退回学生层 */ node = (struct subjects *)malloc(sizeof(struct subjects)); if (node == NULL) { printf("内存不足\n"); break; } memset(node, 0, sizeof *node); node->fall2[0] = class_ord[0]; node->fall2[1] = stu_ord[0]; strncpy(node->course_no, buf, sizeof node->course_no - 1); printf("课程名:"); read_line(node->course_name, sizeof node->course_name); printf("学分:"); read_line(node->credit, sizeof node->credit); node->chead = build_scores(node->fall3, node->fall2, node->course_no); node->next3 = NULL; if (head == NULL) head = node; else tail->next3 = node; tail = node; } return head; }

strncpy的第三个参数写成sizeof - 1,是为了给'\0'留位置:strncpy在源串长度不够时不会自动补结束符,后面printf("%s")就会一直打印到相邻字段,这是这套代码里最常见的翻车方式,第 5 章还会遇到。哨兵判断放在strncpy之前,是为了避免把"00"写进节点后又继续往下走。memset(node, 0, ...)不能省,malloc回来的内存是脏的,字段没清零时后面所有strcmp都可能读到垃圾。

成绩链的创建多一步重修处理:首次录入如果综合分不及格,程序会问要不要录重修,要就在chead后面再挂一个节点,number递增,直到及格或用户主动停。这就是next4存在的意义:同一门课可以有多条成绩记录,统计均分时按需取“最高一次”还是“全部计入”,取决于业务口径,课设里通常是全部计入。

2.4 班级内排序:交换数据域而不是交换节点

排序的需求是“在班级内部按学号或加权分排”。链表的物理排序要改指针,涉及前驱、后继、头指针三种情况,写错一个就是断链或死循环。更稳妥的做法是只交换数据域,节点位置不动:

/* 只交换数据字段,next2 保持在原位;suhead 必须跟着走 */ static void swap_stu_data(struct students *a, struct students *b) { struct students t = *a; /* 先整份留底,后面逐字段搬 */ struct subjects *tp; memcpy(a->student_no, b->student_no, sizeof a->student_no); memcpy(a->name, b->name, sizeof a->name); memcpy(a->sex, b->sex, sizeof a->sex); memcpy(a->age, b->age, sizeof a->age); memcpy(a->phone, b->phone, sizeof a->phone); memcpy(a->com_phone, b->com_phone, sizeof a->com_phone); memcpy(a->jia_quan, b->jia_quan, sizeof a->jia_quan); memcpy(a->all_credit, b->all_credit, sizeof a->all_credit); memcpy(a->fall1, b->fall1, sizeof a->fall1); memcpy(b->student_no, t.student_no, sizeof b->student_no); memcpy(b->name, t.name, sizeof b->name); memcpy(b->sex, t.sex, sizeof b->sex); memcpy(b->age, t.age, sizeof b->age); memcpy(b->phone, t.phone, sizeof b->phone); memcpy(b->com_phone, t.com_phone, sizeof b->com_phone); memcpy(b->jia_quan, t.jia_quan, sizeof b->jia_quan); memcpy(b->all_credit, t.all_credit, sizeof b->all_credit); memcpy(b->fall1, t.fall1, sizeof b->fall1); /* 课程链属于学生本人,必须一起换,否则名字和成绩对不上 */ tp = a->suhead; a->suhead = b->suhead; b->suhead = tp; } /* 选择排序:班级几十人,O(n^2) 完全够用 */ static void sort_by_weight(struct students *head) { struct students *i, *j; for (i = head; i != NULL; i = i->next2) for (j = i->next2; j != NULL; j = j->next2) if (atof(j->jia_quan) > atof(i->jia_quan)) swap_stu_data(i, j); }

这段代码最容易漏的就是最后那三行suhead交换。漏了之后程序不崩,排序结果看着也对,但一旦去统计学生均分,就会发现“张三的名字配着李四的课程”,统计出来的均分和人对不上。这类错误没有报错信息,只能靠比对原始数据反查,我一般会在排序后立刻调一次打印函数,人工看头两行是否对得上,当作一道检查。

按学号排序把比较换成strcmp(j->student_no, i->student_no) < 0即可,但学号是字符串,"0120050137"和"120050137"长度不同时strcmp结果和数值比较不一致,这点在混入学号位数不同的数据时要特别注意。

2.5 内存上限:small 模式下的 64KB 与节点数

把节点大小算一遍:学生节点约 2+13+17+2+3+12+12+7+4 = 72 字节加两个近指针 4 字节,约 76;课程节点约 3+11+17+4+2+2 = 39 字节;成绩节点约 4+2+4+4+4+11+2 = 31 字节。按 500 名学生、每人 2 门课、每门课 1.5 次考试计,光数据就约 125KB。

Turbo C 2.0 的默认编译模式是 small:数据段 64KB,所有near指针只能寻址这一段的当前段,malloc也从这一段里分配空间。125KB 必然请求失败,典型现象是录入到第二十几个学生时程序突然返回主菜单,或者直接malloc返回 NULL 后没有判断,接着->解引用就崩溃。解法不是优化代码,而是改编译模式:把Options → Compiler → Model改成 Large,同时把Far数据指针打开。Large 模式下数据指针是 far,堆挪到 far 空间,能撑到几百 KB,代价是每次寻址慢一点,对课设完全无感。

提示:改模式之后记得把工程里所有.obj删掉重新编,混用不同模式的目标文件链接时会报一堆Segment ... exceeds 64K,看着像代码问题,其实只是残留。

3. 把九个 .c 编成一个工程:模块拆分、全局头指针和 jiaowu.prj

3.1 九个源文件按功能域切开:每个文件只对外暴露一组函数

原始工程拆成main2.c、files.c、in_out.c、edit.c、tongji.c、da_yin.c、search.c、help.c、qita.c。拆分的依据不是数据类型,而是用户能看到的那几个菜单项:文件相关的归files.c,编辑相关归edit.c,统计归tongji.c,打印归da_yin.c,查找归search.c,帮助归help.c,杂项归qita.c,输入输出这类被到处调用的底层函数放in_out.c,main2.c只留main()和全局状态。

为什么一定要拆?原文的经历很典型:单文件写到 2000 行 TC2.0 编不动,改 Win-TC 撑到 4000 行,再换 TC3.0 到 5000 行又不行。这不是玄学,TC 系列的编译器对单个编译单元有上限,代码段和数据段都受 64KB 约束,函数一多,跳转表、字符串常量表都会爆。拆成九个文件后,每个.c单独编译成.obj,各自不超过 64KB,链接器再把它们拼起来,问题自然消失。

拆的时候有个原则:edit.c里修改完链表数据后要立即把全局的“未保存”标记置位,这个动作不能漏,否则退出时不会提示保存。各模块之间尽量不互相调用,共享的东西一律走全局变量和in_out.c里的公共函数,比如read_line、print_one_student、ask_yes_no。

3.2 全局链表头的定义与 extern:一个 main2.c 说了算

九个文件都要访问班级链头,只能有一处定义、其余处声明。C 的规则是:定义(分配内存)只能出现一次,声明可以出现多次。工程里最稳的写法是在main2.c里定义,其他文件通过一个公共头文件data.h拿到extern。

/* data.h —— 只放类型声明和全局 extern,不放函数体 */ #ifndef DATA_H #define DATA_H #include <stdio.h> #include <stdlib.h> #include <string.h> #include <alloc.h> /* TC 下 farmalloc 在这里 */ typedef struct classes CLASS; typedef struct students STU; typedef struct subjects SUB; typedef struct scores SCO; /* 结构体定义写在这里,顺序不能乱:先 CLASS 才能声明 sthead */ struct classes { char class_no[5]; struct classes *next1; struct students *sthead; }; /* students / subjects / scores 的定义同 2.1 节,此处省略 */ extern CLASS *cls_head; /* 全局班级链头,定义在 main2.c */ extern char cur_base[64]; /* 当前文件的基名,不带后缀 */ extern int dirty; /* 0 = 无改动,1 = 有未保存修改 */ extern int file_open; /* 0 = 未建立/打开任何数据 */ int read_line(char *buf, int cap); int ask_yes_no(const char *prompt); void mark_dirty(void); #endif
/* main2.c —— 全局量在这里落地,全程序只此一份 */ #include "data.h" CLASS *cls_head = NULL; char cur_base[64] = ""; int dirty = 0; int file_open = 0; void mark_dirty(void) { dirty = 1; } int main(void) { int rc; init_screen(); /* 进入图形/文本模式,画欢迎页 */ rc = menu_loop(); /* 主事件循环,返回 -1 表示退出 */ if (dirty && ask_yes_no("当前数据未保存,保存后退出?")) file_save(cur_base); restore_screen(); return rc; }

data.h里把结构体定义和extern放一起,是为了避免“每个.c各写一份结构体定义,某天改了字段忘了同步”。但要注意,TC2.0 对typedef struct xxx CLASS;这种前置声明支持不全,稳妥写法是把typedef写在结构体定义之后,或者干脆用struct classes *全称引用,不用CLASS简写。这是老编译器和现代编译器差异最大的地方之一。

mark_dirty()包一层函数而不是让各模块直接写dirty = 1,是为了以后想加日志或改成计数时只改一处。小工程也会因此受益,因为漏埋脏标记是这类程序最常见的问题:改完学生信息直接关窗口,数据全丢,用户只会觉得“这软件吃了我的数据”。

3.3 jiaowu.prj 的写法与 Ctrl+F9 的构建链路

Turbo C 的工程文件其实就是一个纯文本清单,一行一个源文件,顺序就是编译和链接顺序:

main2.c files.c in_out.c edit.c tongji.c da_yin.c search.c help.c qita.c

把上面九行存成jiaowu.prj(后缀必须是.prj),在 TC 里走Project → Open Project打开它,然后按Ctrl+F9就是 Make & Run。TC 的 Make 是增量式的:哪个.c的修改时间比.obj新就重编哪个,其余直接复用.obj。这也解释了为什么改完data.h后必须手动Project → Remove或者删掉所有.obj,TC2.0 不追踪头文件依赖,改了头文件它不会自动重编。

不方便进 IDE 的场合,可以直接命令行等价:

# -ml = Large 模式,与 IDE 里 Options→Compiler→Model 选 Large 等价 tcc -ml main2.c files.c in_out.c edit.c tongji.c da_yin.c search.c help.c qita.c

-ml是必须的,沿用默认 small 模式会在运行期挂掉,而且挂的位置随机,看着像代码 bug。命令行编出来的是main2.exe,想叫jiaowu.exe得改main()所在的文件名,或者在 TC 的Options → Application → Output里指定。

还有一条:工程文件里的路径是相对当前工程目录的,如果把.prj放在D:\WORK而源文件在D:\WORK\SRC,那清单里得写成SRC\main2.c。路径写错时 TC 的报错是Unable to open include file或者干脆找不到源文件,跟头文件缺失的报错长得很像,别一上来就怀疑data.h。

3.4 从 TC2.0 一路搬到 TC3.0:编译容量撞墙后的迁移路径

原文那条迁移曲线值得复述一遍,因为它就是一份现实中的容量测试报告:2000 行时 TC2.0 编不动,换 Win-TC;4000 行时 Win-TC 编不动,换 TC3.0;5000 行时 TC3.0 也编不动,最后靠模块化解决。这里每一道坎都不是语法问题,而是编译单元规模问题。

迁移时有三件事必须一起做,缺一件就白折腾:

第一,注释风格。TC2.0 只认/* */,//会被当成除号,报出一堆莫名其妙的语法错误。从现代编辑器粘贴代码进来时,先把//全部替换掉。

第二,函数原型。TC2.0 对 ANSI 原型的支持很有限,像int foo(void)这种写法在较老版本里要退化成int foo(),参数类型写在函数定义处。工程里统一用 K&R 风格声明最省事。

第三,库函数差异。alloc.h提供farmalloc/farfree,conio.h提供getch/textcolor/gotoxy,graphics.h提供图形模式初始化;这些在 Win-TC 上基本兼容,在 TC3.0 上更完整但要注意BGI驱动路径。程序启动时如果initgraph找不到EGAVGA.BGI,屏幕会黑一下然后退回文本模式,看着像“程序没反应”,其实只是图形驱动路径没配。

注意:换编译器之后一定要把旧的.obj和.exe全删。不同版本编出的目标文件混链,报错信息会指向完全无关的行号,白花半天。

4. 没有鼠标的菜单系统:方向键扫描码、三级事件循环和脏标记

4.1 方向键扫描码解析:0x00 与 0xE0 两种前缀都要接

DOS 下按方向键,getch()不会一次返回一个字符,而是返回两个字节:第一个是扩展前缀,第二个才是真正的扫描码。前缀在不同环境下不一样:传统 DOS/BIOS 是0x00,部分 Win-TC 版本和某些兼容层给的是0xE0。只判断0x00,程序在 Win-TC 里方向键就完全没反应,用户以为键盘坏了。

/* 统一键值:普通键直接返回 ASCII,扩展键映射到 0x100 以上避免冲突 */ #define K_UP 0x148 #define K_DOWN 0x150 #define K_LEFT 0x14B #define K_RIGHT 0x14D #define K_PGUP 0x149 #define K_PGDN 0x151 #define K_HOME 0x147 #define K_END 0x14F int get_key(void) { int c = getch(); if (c == 0x00 || c == 0xE0) { /* 扩展键前缀,两种都要接 */ c = getch(); /* 第二字节是扫描码 */ switch (c) { case 72: return K_UP; /* 0x48 */ case 80: return K_DOWN; /* 0x50 */ case 75: return K_LEFT; /* 0x4B */ case 77: return K_RIGHT; /* 0x4D */ case 73: return K_PGUP; /* 0x49 */ case 81: return K_PGDN; /* 0x51 */ case 71: return K_HOME; /* 0x47 */ case 79: return K_END; /* 0x4F */ } return 0; /* 其它扩展键吞掉,不污染菜单 */ } return c; /* 普通键:回车 13、Esc 27、退格 8 */ }

返回统一键值的好处是菜单层完全不用关心扫描码,只认K_UP这类常量。case 72这些数字是扫描码的十进制写法,跟0x48是一回事,写成十进制纯粹是因为当年查的资料表就这么列。return 0那行不能省,某些扩展键(比如Alt+字母)也会走前缀分支,不吞掉就会变成菜单里的“非法输入”,触发一次多余的重绘。

4.2 三级菜单怎么共用一个事件循环

菜单是三级结构:主菜单(文件、编辑、打印、统计、查找、帮助、退出)→ 子菜单(比如编辑下分插入、删除、修改、排序)→ 具体操作。如果把每级菜单都写成独立的while循环,Esc返回上层的逻辑就得在每层重复一遍,而且“左右键跳到别的菜单”这种跨级移动几乎没法实现。

实际有效的写法是一个循环加一个深度变量:

/* 深度 1 = 主菜单,2 = 子菜单,3 = 操作执行;返回 -1 表示退出程序 */ int menu_loop(void) { int depth = 1, top = 0, sub = 0, key; while (1) { draw_menu(depth, top, sub); /* 只负责画,不改状态 */ key = get_key(); if (key == 27) { /* Esc:逐级回退 */ if (depth == 1) { if (confirm_exit()) return -1; } else depth--; continue; } if (key == 13) { /* 回车:进入下一级或执行 */ if (depth == 1) { depth = 2; sub = 0; } else if (depth == 2) { depth = 3; } else { run_action(top, sub); depth = 2; } continue; } if (key == K_UP || key == K_DOWN) { if (depth == 1) top = step(top, key == K_UP ? -1 : 1, TOP_N); else sub = step(sub, key == K_UP ? -1 : 1, sub_count(top)); continue; } if (key == K_LEFT || key == K_RIGHT) { if (depth >= 2) { /* 左右切换主菜单,子菜单清零 */ top = step(top, key == K_LEFT ? -1 : 1, TOP_N); sub = 0; } continue; } } }

step()负责环形取模,保证上下键到底后回到第一项。draw_menu纯绘制、run_action纯执行,状态全部留在menu_loop的局部变量里,这样加一个菜单项只需要改TOP_N和对应的字符串表,不用动控制流。depth == 3时执行完把深度退回 2,是为了让用户执行完一个操作后还停在同一级子菜单,可以连续做第二次插入,这种小体验决定了课设演示时顺不顺手。

4.3 链表输出的分屏查看:屏幕缓冲与坐标裁剪

一个班几十号人,屏幕上放不下,所以输出链表信息时用方向键上下左右平移视图。实现上不是真的滚动终端,而是维护一个视口左上角坐标(vx, vy),绘制时只画落在视口内的行,每行的第vx个字符之后才输出。

/* 视口:vx/vy 是视口左上角在逻辑画布中的坐标 */ static int vx = 0, vy = 0; void draw_students(CLASS *cls) { STU *p = cls->sthead; int row = 0, col; while (p != NULL) { if (row >= vy && row < vy + VIEW_H) { /* 只画视口内的行 */ gotoxy(1, row - vy + 2); col = printf("%-4s%-13s%-17s%s", p->fall1, p->student_no, p->name, p->sex); (void)col; } p = p->next2; row++; } /* 底部提示当前是第几行到第几行 */ gotoxy(1, VIEW_H + 3); printf("第 %d - %d 行 / 共 %d 行,方向键移动,Esc 返回", vy + 1, vy + VIEW_H > row ? row : vy + VIEW_H, row); }

gotoxy(1, row - vy + 2)里的+2是给标题行和边框留位置,这类硬编码坐标在换分辨率时全要重算。VIEW_H是常量,等于屏幕行数减去固定头尾。移动视口时的边界处理极容易翻车:vy允许的最大值是row - VIEW_H,如果row < VIEW_H(比如一个班只有三个学生),这个值就是负数,此时必须把vy固定为 0,否则画面会整屏空白,用户以为数据丢了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询