简介:面向VB初学者及课程设计、毕业设计阶段学生的学籍管理系统学习资料,围绕学生信息、成绩、出勤与权限等常见业务,梳理从需求分析到详细设计的完整实现思路。包内为1份doc文档,约523KB,以论文式结构组织,涵盖系统需求分析、可行性分析、系统设计、数据库设计与详细设计等章节,并列出主窗体、用户管理、学籍管理、班级管理、课程设置、成绩管理等模块的创建过程。文档结合流程图、E-R图与数据表设计,说明VB窗体控件、ADO数据库连接及增删改查功能的落地方式,可帮助读者理解学籍管理系统的模块划分、表间关系与界面交互逻辑。已有436人学习,适合需要参照课程设计框架、整理设计文档或查漏补缺的VB与数据库应用开发学习者。
1. 一份 2006 年的 VB 学籍系统源码,现在还能拿来干什么
翻出这份 vb学籍管理系统源代码,起因很朴素:一个做培训的朋友,学生名册用三张 Excel 互相引用,转班、退课、补考各改一处,对账永远对不上。压缩包里是一份完整的毕业设计工程加文档,功能五个模块——用户管理、学籍管理、班级管理、课程设置、成绩管理,VB6 拖界面、ADO 直连 SQL Server 2000 存数据,主窗体是多文档结构,登录通过才进得去。它不适合直接搬到线上,但适合三类人:要交课程设计、需要一个结构完整能跑通的数据库课设的学生;接手老 C/S 系统、需要读懂窗体与表对应关系的维护者;以及想看清单函数管所有 SQL 这种早期写法利弊的开发者。下面按能不能跑、怎么改的顺序拆。
2. 架构与工程结构:VB6 窗体层 + ADO 直连 SQL Server 的 C/S 骨架
2.1 选型:当年摆在桌面上的几条路,为什么走了这一条
这套系统对数据的要求其实不复杂:都是学生,字段之间关联性强,计算过程无非是成绩求和、排名、按班统计,没有复杂算法。真正卡人的是并发和权限——多个机房同时录入,几十个学生同时改成绩,Access 那种文件型数据库一上共享目录就开始锁表报错。所以数据库层选 SQL Server 2000,客户端用 VB6,走的是标准两层 C/S:界面在客户端,数据和约束在服务端。
VB6 在那个年代的优势是实打实的。界面不用手写坐标,拖控件、设属性,编译器自动生成界面代码,只留业务那一小块自己写;4.0 之后支持面向对象,窗体、模块、类模块都当对象封装,属性方法挂在对象上。对课程设计这种一个人两三周要出成品的场景,投入产出比最高。Delphi 编译出来更快、PowerBuilder 做报表更强,但对刚接触数据库编程的人来说,VB 的调试器和控件生态门槛最低。
数据访问层用 ADO,不用更老的 DAO 和 RDO,理由有三个:ADO 走 OLE DB,同一个接口能连 SQL Server、Access、Oracle,换库不用重写;支持键集游标和乐观锁,adOpenKeyset + adLockOptimistic这组参数不用把整表锁死就能翻页改数据;再就是记录集能脱离连接持有数据,这在网络不稳的机房环境里很关键。这套源码里所有数据库操作,最终都收敛到一个ExecuteSQL函数上,后面的章节会重点拆它。
提示:两层 C/S 的直连模式意味着连接串里必然带账号密码,客户端一抓包或者一反编译就能拿到。这在当年是行业通病,现在拿来练手没问题,真要用必须换成中间层或者至少做连接串加密。
2.2 工程文件结构:一个 .vbp 里到底装了什么
VB6 的工程不是一堆散文件,.vbp是总纲,记录了窗体清单、外部引用(References)和编译输出名。这份源码把工程命名为 Student-MIS,标准 EXE 类型,主窗体设成 MDI Form。理解这套结构,你才知道改代码该动哪个文件。
| 文件后缀 | 在工程里的角色 | 这份源码里对应的东西 |
|---|---|---|
| .vbp | 工程文件,记录窗体清单、引用与编译选项 | 整个 Student-MIS 工程 |
| .frm | 窗体,界面布局与事件代码都在这 | 登录窗体、MDI 主窗体、各业务子窗体 |
| .frx | 窗体的二进制资源,存图片、图标这类不可编辑属性 | 随 .frm 自动生成,不要手动改 |
| .bas | 标准模块,存放全局变量和公用函数 | Module.bas,ExecuteSQL和连接串都在这 |
| .cls | 类模块,可选项 | 这份工程基本没用,数据校验都写在窗体里 |
| .ocx | ActiveX 控件,必须在系统里注册过才能加载 | 表格控件、日期控件一类 |
| .res | 资源文件,图标与字符串 | 可选,用于换皮肤或做多语言 |
选 MDI 而不是 SDI,是为了让子窗体都待在主窗体里,菜单栏统一、切换不闪屏。代价是子窗体之间的通信要靠主窗体或者全局变量中转,耦合度高,这也是后面改造时最先要动的地方。
.bas模块是这个工程的中枢。VB6 里标准模块的变量默认是全局的,工程里任何窗体都能直接读写,省掉了传参的麻烦,但也埋下了"谁都能改连接串"的隐患。我刚看这份代码时就吃过一次亏:在某个子窗体里临时改了ConnectString做测试,忘了改回来,结果后面所有窗体都连到测试库上去了,查了半天才发现是全局变量的锅。
2.3 模块与数据表的对应关系:一个菜单项背后是几张表
功能模块和数据表不是一一对应的,理清这层映射,读代码时才能知道某个窗体点下去会动哪几张表。
| 功能模块 | 主要操作 | 落到的表 |
|---|---|---|
| 用户管理 | 登录校验、新增用户、修改密码 | user_Info |
| 学籍管理 | 学生基本信息的增、删、改、多条件查 | student_Info(班号指向 class_Info) |
| 班级管理 | 班级设置、年级、班主任、教室 | class_Info |
| 课程设置 | 课程信息维护、按年级开课 | course_Info、gradecourse_Info |
| 成绩管理 | 按班或按人录成绩、查询、统计 | result_Info(关联学生与课程) |
一次典型的调用链路是这样的:程序启动加载登录窗体,ExecuteSQL拿用户名密码去user_Info里比对,通过后把用户信息塞进全局变量并Unload登录窗体,接着frmMain.Show把 MDI 主窗体亮出来。用户点菜单"学籍管理 → 学籍添加",主窗体Load出对应子窗体,子窗体在Form_Load事件里先把class_Info查出来填充班级下拉框,等用户填完点保存,再拼一条 INSERT 交给ExecuteSQL。
这里有个设计上的取舍值得说:子窗体的数据加载全放在Form_Load里,意味着每次打开都是一次全量查询,不会复用上次的结果。数据量小时无所谓,学生上万之后每次开窗都卡一下,体验就很差。常见的优化是加个静态标志位,只在第一次加载时查,后续靠刷新按钮手动触发。
2.4 在新机器上把工程跑起来的最小步骤
老 VB 工程跑不起来,九成不是代码问题,是环境问题。按下面的顺序来,能少走很多弯路。
第一步,装 VB6 开发环境并打上 SP6 补丁,否则 ADO 引用和一些控件的属性页会异常。第二步准备数据库,SQL Server 2000 在现在的系统上装不上了,可以退而求其次用更高版本建库,把兼容级别调到 80,或者干脆用 SQL Server Express 建同结构库。第三步执行建表脚本,脚本在第 3 章会给全。第四步改连接串,这一步最容易翻车。
# 注册工程依赖的 ActiveX 控件,缺任何一个都会在打开工程时报"部件未找到" regsvr32 /s msflxgrd.ocx # 表格控件,成绩列表要用 regsvr32 /s comctl32.ocx # 通用控件,状态栏、工具栏 regsvr32 /s msstdfmt.dll # 格式化控件,数值显示要用注册完控件,回到 VB6 里打开 .vbp,在"工程 → 引用"里确认勾选了 Microsoft ActiveX Data Objects 2.x Library,版本号要和代码里用的常量对得上(adOpenKeyset、adLockOptimistic这些常量就来自这个引用)。少了这个引用,代码里所有ADODB.开头的声明都会报"用户定义类型未定义"。
' 登录成功后给全局连接串赋值,ExecuteSQL 全靠它 ConnectString = "Provider=SQLOLEDB.1;Persist Security Info=False;" & _ "User ID=sa;Password=你的密码;" & _ "Initial Catalog=student_MIS;Data Source=127.0.0.1"Provider指定用 SQL Server 的 OLE DB 驱动,别写成SQLNCLI,那是后来版本的驱动名,老系统上不一定有。Initial Catalog是库名,要和建库语句里的一致。Data Source写127.0.0.1比写机器名稳,改机器名或者切网络环境时不会失效。密码别用空串,SQL Server 默认是禁止 sa 空密码登录的,报"用户 'sa' 登录失败"多半就是这个原因。
配置完按 F5 就能跑起来。如果登录后主窗体一闪就退,先看是不是Unload Me写在了frmMain.Show前面,模态窗体没起来,整个程序就退出了,这个问题在新手改代码时非常高频。
3. 数据库设计落地:六张表从 E-R 图到建表脚本的完整走法
3.1 先定实体再定关系:六个实体的取舍
这份源码的数据模型走的是标准流程:需求分析定数据项,概念设计画 E-R 图,逻辑设计转成关系表,物理设计落到 SQL Server。
实体一共四个:学生、班级、课程、年级。关系上,一个班级包含多个学生,是一对多;一个年级下设多个班级,也是一对多;课程和年级之间是多对多,因为同一门课可能在多个年级开设,同一个年级也要开多门课,所以必须拆出一张gradecourse_Info做中间表。成绩是学生和课程的关系实体,一个学生对一门课有一条成绩记录,学生和成绩之间是一对多,课程和成绩之间也是一对多。
除了这四个业务实体,还有两个表是工程需要而非业务需要:user_Info存登录账号,和业务完全解耦;result_Info虽然是关系实体,但因为要存考试编号、班号、姓名这些冗余字段,实际上已经被当成一张宽表在用。这种冗余在查询时确实省事——查成绩不用连表就能显示学生姓名——但代价是学生改名之后,历史成绩里的姓名不会跟着变,数据一致性全靠应用层自己保证。
理解这一点很关键。你去看result_Info的字段会发现它同时存了student_ID和student_Name,这在第三范式里是冗余,在报表场景里却是常规做法。要不要保留,取决于你后续是把它当流水表还是当快照表。
3.2 建表脚本逐张拆解
下面这套脚本是从文档里整理出来的,原稿在排版上有折行错位,字段类型、长度、排序规则都没动。执行前先建库,字符集选 Chinese_PRC_CI_AS,否则带中文排序规则的建表语句会直接报错。
-- 1. 系统用户表,登录校验用 CREATE TABLE [dbo].[user_Info] ( [user_ID] [char](10) COLLATE Chinese_PRC_CI_AS NOT NULL, -- 登录账号,定长 10 [user_PWD] [char](10) COLLATE Chinese_PRC_CI_AS NULL, -- 明文密码,后面必须改 [user_Des] [char](10) COLLATE Chinese_PRC_CI_AS NULL -- 用户描述,兼作角色标记 ) -- 2. 学生基本信息表,学籍管理的核心 CREATE TABLE [dbo].[student_Info] ( [student_ID] [int] NOT NULL, -- 学号,整型,没有主键约束 [student_Name] [char](10) COLLATE Chinese_PRC_CI_AS NULL, [student_Sex] [char](2) COLLATE Chinese_PRC_CI_AS NULL, [born_Date] [datetime] NULL, -- 出生日期 [class_NO] [int] NULL, -- 所属班号,逻辑上指向 class_Info [tele_Number] [char](10) COLLATE Chinese_PRC_CI_AS NULL, [ru_Date] [datetime] NULL, -- 入校日期 [address] [varchar](50) COLLATE Chinese_PRC_CI_AS NULL, [comment] [varchar](200) COLLATE Chinese_PRC_CI_AS NULL ) -- 3. 班级信息表 CREATE TABLE [dbo].[class_Info] ( [class_No] [int] NOT NULL, -- 班号 [grade] [char](10) COLLATE Chinese_PRC_CI_AS NULL, -- 所在年级 [director] [char](10) COLLATE Chinese_PRC_CI_AS NULL, -- 班主任 [classroom_No] [char](10) COLLATE Chinese_PRC_CI_AS NULL -- 教室号 ) -- 4. 课程基本信息表 CREATE TABLE [dbo].[course_Info] ( [course_No] [int] NOT NULL, -- 课程号 [course_Name] [char](10) COLLATE Chinese_PRC_CI_AS NULL, [course_Type] [char](10) COLLATE Chinese_PRC_CI_AS NULL, -- 必修/选修 [course_Des] [char](50) COLLATE Chinese_PRC_CI_AS NULL ) -- 5. 年级课程设置表,课程与年级的多对多中间表 CREATE TABLE [dbo].[gradecourse_Info] ( [grade] [char](10) COLLATE Chinese_PRC_CI_AS NULL, [course_Name] [char](10) COLLATE Chinese_PRC_CI_AS NULL ) -- 6. 学生成绩表,宽表设计,姓名和班号冗余存储 CREATE TABLE [dbo].[result_Info] ( [exam_No] [char](10) COLLATE Chinese_PRC_CI_AS NOT NULL, -- 考试编号 [student_ID] [int] NOT NULL, [student_Name] [char](10) COLLATE Chinese_PRC_CI_AS NULL, [class_No] [int] NULL, [course_Name] [char](10) COLLATE Chinese_PRC_CI_AS NULL, [result] [float] NULL -- 分数,浮点型 )字段说明里最值得注意的是student_ID用了int。如果学号是纯数字,用整型省空间、排序快、做区间查方便;但现实里的学号常常带字母前缀或者前导零(比如 0501 这种表示 2005 级),存成 int 之后前导零就丢了,显示出来变成 501。这是拿这份源码做二次开发时第一个要改的地方。
result_Info里exam_No是定长字符串且非空,意味着每录一条成绩都必须生成一个考试编号。源码里的做法是按"班号 + 课程号 + 日期"自己拼一个,简单但不保证唯一,并发录入时会撞号。合格一点的做法是用数据库自增列或者序列,把主键生成交给服务端。
3.3 字段类型与约束的取舍:那些当时合理、现在难受的地方
先把这套建表脚本的三个硬伤摆出来,这也是你拿它当模板之前必须知道的事。
第一,全表没有主键。student_Info没有 PRIMARY KEY,class_Info、course_Info、result_Info也都只有NOT NULL,没有约束。后果是同一个学号能插进去十条记录,删除时按学号删会一次删掉十条,明细和主表之间也无法建立外键。改进只需要在每张表的业务主键上加PRIMARY KEY,result_Info再加个自增列做代理键。
第二,char用得太随意。char(10)是定长,存"张三"这两个字也会占满 10 个字符,尾部补空格。别小看这个补空格:WHERE student_Name = '张三'在 SQL Server 里对 char 字段比较时会做空格填充语义,通常还能匹配上,但在客户端拼LIKE '%张三%'或者把值取出来在 VB 里再用=比较,就会因为尾部空格对不上而判断失败——这个坑会在第 5 章展开讲。姓名、课程名这类长度不固定的字段,用varchar或nvarchar更合适;nvarchar还能解决生僻字存成问号的问题。
第三,分数用float。浮点数存 85 分没问题,但一涉及求平均、求和再和阈值比较,就会出现59.999999这种结果,判断及格时翻车。教学系统里的分数、绩点、学分,一律用decimal(5,2)这类定点数最稳。
注意:改字段类型不是执行一条 ALTER 就完事,
result_Info.result从 float 改成 decimal 会触发全表数据转换,历史数据如果有 NaN 或者超范围的值会直接报错。改之前先备份,并且用SELECT COUNT(*) FROM result_Info WHERE result > 100 OR result < 0洗一遍脏数据。
还有个细节:脚本里每张表都显式写了COLLATE Chinese_PRC_CI_AS。这在中文环境的 SQL Server 上没问题,但换到英文排序规则的实例上,CREATE TABLE会报"排序规则无效"。稳妥的写法是不写 COLLATE,继承数据库默认排序规则,只在特殊字段(比如要按拼音排序的姓名)上单独指定。
4. 核心模块拆解:Module.bas 里的 ExecuteSQL 与四个业务窗体
4.1 公用模块:一个函数接管全工程的数据访问
这份源码里最值得读的代码不在窗体里,在Module.bas。所有增删改查最终都调同一个函数,这是理解整个工程的钥匙。
' Module.bas —— 全工程共用的数据访问入口 Public ConnectString As String ' 全局连接串,登录成功后由登录窗体赋值 Public Function ExecuteSQL(ByVal SQL As String, MsgString As String) As ADODB.Recordset Dim CNN As ADODB.Connection Dim rst As ADODB.Recordset Dim sTokens() As String On Error GoTo ExecuteSQL_Error sTokens = Split(SQL) ' 取第一个词判断语句类型 Set CNN = New ADODB.Connection CNN.Open ConnectString ' 每次调用都新开一个连接 If InStr("INSERT,DELETE,UPDATE", UCase$(sTokens(0))) Then ' 非查询语句:直接执行,不返回结果集 CNN.Execute SQL MsgString = sTokens(0) & " query successful" Else ' 查询语句:返回记录集 Set rst = New ADODB.Recordset rst.Open Trim$(SQL), CNN, adOpenKeyset, adLockOptimistic ' rst.MoveLast ' 源码里这行是注释掉的,后果见第 5 章 Set ExecuteSQL = rst MsgString = "查询到" & rst.RecordCount & " 条记录" End If ExecuteSQL_Exit: Set rst = Nothing ' 只是减引用计数,返回出去的对象不受影响 Set CNN = Nothing Exit Function ExecuteSQL_Error: MsgString = "查询错误: " & Err.Description Resume ExecuteSQL_Exit End Function先说参数。SQL加了ByVal,传的是字符串副本,函数内部Trim$不会影响调用方;MsgString没加修饰符,VB6 默认按ByRef传引用,所以调用方传一个空字符串变量进来,函数里赋值之后外面就能拿到提示文本。这是整个函数最容易被忽略的设计——它不是返回值,是出参。调用方写法必须是这样:
Dim msg As String Dim rst As ADODB.Recordset Set rst = ExecuteSQL("SELECT * FROM student_Info", msg) If rst Is Nothing Then MsgBox msg ' 走的是 INSERT/UPDATE 分支或者出错再说分支判断。Split(SQL)按空格切分,取第一个词转大写,看是不是在INSERT,DELETE,UPDATE里。这个判断很粗糙:注释语句、带WITH的查询、SELECT前面多一个换行,都可能让判断失效,走了错误的执行分支。但它的意图是清楚的——写操作不需要结果集,读操作需要。更稳的做法是加个ByVal IsQuery As Boolean参数让调用方明说,或者统一都返回记录集、由调用方判断。
最后说Set rst = Nothing这行。很多人第一次读会以为它把返回的记录集销毁了,其实不会。VB6 的对象变量是引用计数,ExecuteSQL = rst已经把对象引用赋给了函数返回值,调用方拿到之后计数至少是 2,函数内Set rst = Nothing只是把局部变量这一份引用减掉,对象本身还活着,调用方照样能rst.MoveNext。真正被释放是调用方也Set rst = Nothing或者离开作用域的时候。
至于Set CNN = Nothing,记录集打开时已经通过ActiveConnection持有连接的引用,所以连接对象不会立刻销毁。但要注意:adOpenKeyset是服务端游标,连接被真正关掉之后,某些操作(比如重新查询、更新)就会失败。这份源码因为每次调用都重开连接,反而避开了这个问题,代价是性能。
4.2 连接管理:全局连接串该放在哪
原文把ConnectString声明在标准模块里做全局变量,登录成功后赋值。这种写法在课程设计里够用,但放到真实项目里有三个问题:程序一启动到登录成功之前,任何窗体如果误调ExecuteSQL都会因为连接串为空而报错;连接串里带着明文密码,编译进 EXE 之后用十六进制编辑器就能搜出来;换测试库要重新编译。
常见的改法是把连接信息外置到同目录的 ini 或者注册表,启动时读一次,用类模块封装,禁止业务窗体直接访问。
' modConfig.bas —— 启动时读一次配置,替代硬编码 Public Function LoadConnString() As String Dim sServer As String, sDb As String, sUser As String, sPwd As String sServer = GetINI("DB", "Server", "127.0.0.1") ' 读 ini,缺省本机 sDb = GetINI("DB", "Database", "student_MIS") sUser = GetINI("DB", "User", "sa") sPwd = GetINI("DB", "Password", "") LoadConnString = "Provider=SQLOLEDB.1;Data Source=" & sServer & _ ";Initial Catalog=" & sDb & _ ";User ID=" & sUser & ";Password=" & sPwd End FunctionGetINI用 Windows API 的GetPrivateProfileString实现,或者直接读文本文件按行解析。这样做的好处是运维改配置不用重新编译,坏处是密码还是明文躺在 ini 里,只能靠 NTFS 权限挡一下。真要安全,得走 Windows 集成验证,把User ID和Password换成Integrated Security=SSPI,让数据库按当前登录的域账号鉴权。这一步改造对老系统来说改动量不小,因为它要求每个客户端用户都在数据库里有对应账号或者角色。
4.3 学籍查询窗体:多条件拼装的写法与代价
学籍管理是使用频率最高的模块,查询条件的拼装方式直接决定了这个模块好不好用。源码里的做法是典型的字符串拼接加WHERE 1=1技巧。
Private Sub cmdQuery_Click() Dim sSQL As String, sWhere As String, msg As String Dim rst As ADODB.Recordset sWhere = " WHERE 1=1 " ' 占位,后面全部用 AND 接 If Trim$(txtName.Text) <> "" Then sWhere = sWhere & " AND student_Name LIKE '%" & Trim$(txtName.Text) & "%'" End If If Trim$(cboClass.Text) <> "" Then sWhere = sWhere & " AND class_NO = " & Val(cboClass.ItemData(cboClass.ListIndex)) End If If chkDate.Value = 1 Then ' 按入校日期区间过滤 sWhere = sWhere & " AND ru_Date BETWEEN '" & _ Format(dtpFrom.Value, "yyyy-mm-dd") & "' AND '" & _ Format(dtpTo.Value, "yyyy-mm-dd") & "'" End If sSQL = "SELECT student_ID, student_Name, student_Sex, class_NO, ru_Date FROM student_Info" & sWhere Set rst = ExecuteSQL(sSQL, msg) If rst Is Nothing Then MsgBox msg, vbExclamation Exit Sub End If Set grdStudent.DataSource = rst ' 绑到表格控件直接显示 End Sub逐段说。WHERE 1=1是个老技巧,作用是让后面每个条件都能无脑用AND开头,不用判断"这是不是第一个条件"。代价是生成的 SQL 多了一个恒真条件,对执行计划几乎没影响,可读性反而更好,我在很多维护项目里都保留了这个写法。
LIKE '%...%'是模糊匹配,前后都带通配符意味着索引失效、全表扫描。学生表几千行无所谓,上十万行就得改成前缀匹配LIKE '张%'或者上全文索引。cboClass.ItemData取的是下拉框绑定项的隐藏值(班号),Val转成数字再拼进 SQL——如果这里直接用cboClass.Text拼字符串,遇到班号是数字倒没事,遇到文本主键就会拼出语法错误。
日期区间用BETWEEN加Format(..., "yyyy-mm-dd"),格式化成 ISO 顺序是为了避开区域设置差异:yyyy-mm-dd在绝大多数语言环境下都能被正确解析,而mm/dd/yyyy在中文系统上可能被当成dd/mm/yyyy,查出来的结果是错的。这是我在跨区域部署时踩过的坑,看起来数据没问题,实际日期全错位了一天。
这段代码最大的问题是 SQL 注入。用户在姓名框里输入' OR 1=1 --,拼出来的语句就变成AND student_Name LIKE '%' OR 1=1 --%',注释掉后面的部分,条件恒真,全表数据都出来了。课程设计不关心这个,但只要这套系统要放到内网给几十个人用,就必须换成参数化查询,第 6 章给具体写法。
4.4 成绩录入:从单条保存到批量提交
成绩录入是唯一需要一次写多行的模块。一个班五十个人,一门课五十条记录,如果每行都调一次ExecuteSQL,就是五十次开连接、五十次提交,慢不说,中途断网还会写进去一半,留下脏数据。
源码里的做法是循环调用ExecuteSQL,逐条 INSERT。能跑通,但没有事务保护。合格一点的写法是自己开连接、开事务、批量执行、统一提交。
Private Sub cmdSaveAll_Click() Dim conn As ADODB.Connection Dim i As Integer, sSQL As String Dim nSaved As Integer On Error GoTo Save_Error Set conn = New ADODB.Connection conn.Open ConnectString conn.BeginTrans ' 开启事务,全成功或全回滚 For i = 1 To grdResult.Rows - 1 ' 第 0 行是表头,从 1 开始 If Trim$(grdResult.TextMatrix(i, 2)) <> "" Then ' 分数列非空才写 sSQL = "INSERT INTO result_Info(exam_No, student_ID, student_Name, class_No, course_Name, result) " & _ "VALUES('" & txtExamNo.Text & "'," & _ grdResult.TextMatrix(i, 0) & ",'" & _ grdResult.TextMatrix(i, 1) & "'," & _ Val(txtClassNo.Text) & ",'" & _ cboCourse.Text & "'," & _ Val(grdResult.TextMatrix(i, 2)) & ")" conn.Execute sSQL nSaved = nSaved + 1 End If Next i conn.CommitTrans ' 全部成功才落地 MsgBox "已保存 " & nSaved & " 条成绩", vbInformation conn.Close Set conn = Nothing Exit Sub Save_Error: conn.RollbackTrans ' 任何一条失败,整批撤销 MsgBox "保存失败,已回滚:" & Err.Description, vbCritical conn.Close Set conn = Nothing End Sub关键点在BeginTrans/CommitTrans/RollbackTrans这三个调用。事务把五十条 INSERT 打包成一个原子操作,中间任何一条因为主键冲突、类型不匹配、字段超长报错,整个批次全部撤销,数据库回到点保存之前的状态。用户改完再点一次就行,不会出现"前二十个人有成绩、后三十个没有"这种半截数据。
grdResult用的是MSHFlexGrid,行号从 0 开始,第 0 行是固定表头,所以循环从 1 到Rows - 1。取值用TextMatrix(行, 列),第一个参数是行、第二个是列,和大多数表格控件的习惯相反,第一次写很容易写反。分数列做空值判断是为了跳过没录的空行,Val把文本转数字,非数字输入会转成 0 而不是报错——这意味着用户误输入"A"会变成 0 分存进去。更严谨的做法是先IsNumeric校验一遍,不通过就高亮该单元格让用户改。
还有一个细节:exam_No直接用了文本框的值,同一批次所有记录共用一个考试编号。如果两次点保存、文本框没变,就会插进去重复的exam_No,因为表上没主键,数据库不会拦。这是第 3 章说的"缺主键"问题的具体表现,加个唯一索引就能挡住。
5. 避坑与排查:老 VB 工程在新机器上跑不起来的五类现场
5.1 环境与部署类:还没进代码就卡住了
现象一:双击 .vbp 打开工程,弹窗"未找到指定的部件,或者该部件未注册",然后一堆控件变成红叉。
原因是工程引用了 ActiveX 控件(.ocx),而 .vbp 里只记录了控件的 GUID,不记录路径。新机器上没有注册过这些控件,VB6 就加载不了。解决分两步:先确认 .ocx 文件本身在不在,通常随安装包或者开发机一起拷贝过来;再以管理员身份运行regsvr32 完整路径\msflxgrd.ocx。注册成功会弹"已成功",失败多半是权限不够或者位数不对——32 位控件必须用C:\Windows\SysWOW64\regsvr32.exe注册,在 64 位系统上直接敲regsvr32调的是 64 位版本,会报"模块加载失败"。
现象二:登录一点就报"SQL Server 不存在或访问被拒绝",或者"未找到提供程序,可能未正确安装"。
这两句话指向两个不同的问题。前一句是网络层连不上:服务名写错、实例名没带、TCP/IP 协议没启用、防火墙挡了 1433。SQL Server Express 默认是命名实例,连接串里必须写Data Source=.\SQLEXPRESS,只写127.0.0.1连不上。后一句是驱动层:Provider=SQLOLEDB.1这个驱动在某些精简系统上没装,换成Provider=SQLNCLI11或者装一下 SQL Server Native Client 就行。排查顺序建议是先telnet 127.0.0.1 1433看端口通不通,通就是账号密码问题,不通就是服务或者协议问题。
5.2 代码与数据类:能跑起来,但结果不对
现象三:查询出来的列表是空的,状态栏却显示"查询到 -1 条记录"。
这是这份源码里最经典的一个坑,根子在ExecuteSQL。adOpenKeyset游标下,RecordCount只有在游标走到底之后才有准确值,没走到底时返回 -1。源码里rst.MoveLast那一行被注释掉了(原意可能是避免大结果集一次性拉到内存),于是RecordCount永远是 -1。解决有两个方向:要么打开记录集之后加If Not rst.EOF Then rst.MoveLast,再rst.MoveFirst回开头,代价是大结果集慢;要么改用adOpenStatic静态游标,它打开时就把数据全部取到客户端,RecordCount立刻准确,代价是内存占用和无法看到别人的实时修改。做报表、做列表展示,我一般选后者。
现象四:库里明明有这个学生,按姓名精确查却查不到。
这多半是char(10)的锅。char是定长,存"张三"会在后面补 8 个空格。SQL Server 内部比较两个 char 时会自动忽略尾部空格,所以WHERE student_Name = '张三'通常能查到。但如果代码里是把值取出来在 VB 里比较,或者拼的是LIKE '%张三%',尾部空格就参与了匹配,对不上。诊断方法是直接跑一句SELECT student_Name, LEN(student_Name), DATALENGTH(student_Name) FROM student_Info,如果LEN是 2 而DATALENGTH是 10,就确认是定长补空格。解决要么把字段改成varchar,要么在所有比较和显示的地方套一层RTRIM()。
现象五:删掉一个班级,学生表里那些学生还在,成绩表里的记录也还在,成了孤儿数据。
原因是这套建表脚本里一个外键约束都没有。删除主表记录时,数据库根本不知道有从表在引用它,自然也不会拦截或者级联。这不算 bug,算设计缺失。补救分两种思路:保守做法是加外键并设ON DELETE NO ACTION,应用层删除前先查一遍从表,有引用就提示"该班级下还有学生,不能删";激进做法是设ON DELETE CASCADE级联删除,省事但危险,误删一个班就把全班学生和成绩一起带走。教学系统我倾向于前者,多写几行校验代码,换一个后悔药。
提示:加外键之前先跑一遍孤儿数据检查,
SELECT * FROM student_Info s WHERE NOT EXISTS (SELECT 1 FROM class_Info c WHERE c.class_No = s.class_NO),把查出来的脏数据先清掉或者补上正确班号,否则加约束会直接报错失败。
6. 二次开发改造:把毕业设计级别的代码推到能交付的水位
这份源码要在真实场景里用,改动量不大但必须改对地方。我按优先级列出来,从"不改就出安全事故"到"改了体验更好",你自己按场景取舍。
| 优先级 | 改造项 | 具体动作 | 预期收益 |
|---|---|---|---|
| 最高 | SQL 注入 | 所有字符串拼接换成 Command 参数化 | 堵住拖库风险 |
| 最高 | 密码明文 | user_PWD存 MD5 加盐,登录时比对哈希 | 泄露不等于丢号 |
| 高 | 缺主键外键 | 六张表补 PRIMARY KEY,从表补 FOREIGN KEY | 挡住重复与孤儿数据 |
| 高 | 连接串硬编码 | 抽到 ini 或注册表,启动时读 | 换库不用重编译 |
| 中 | 分数浮点 | result改decimal(5,2) | 统计不出精度误差 |
| 中 | 定长字符 | 姓名、课程名改nvarchar | 生僻字不乱码 |
参数化查询是最值得先动的一刀,改法不复杂,但能把整条注入链路掐断。
' 用 Command 对象代替字符串拼接,用户输入再花也不会拼断 SQL Public Function QueryByName(ByVal sName As String) As ADODB.Recordset Dim cmd As ADODB.Command Dim conn As ADODB.Connection Set conn = New ADODB.Connection conn.Open ConnectString Set cmd = New ADODB.Command With cmd .ActiveConnection = conn .CommandType = adCmdText .CommandText = "SELECT student_ID, student_Name FROM student_Info WHERE student_Name LIKE ?" ' 参数长度按 nvarchar(50) 给,值里带单引号也只是普通字符 .Parameters.Append .CreateParameter("pName", adVarWChar, adParamInput, 50, "%" & sName & "%") End With Set QueryByName = cmd.Execute End FunctionCreateParameter的四个参数分别是名称、类型、方向、长度,后面还可以跟默认值。这里用adVarWChar对应nvarchar,和字段类型对上;长度给 50 是给LIKE拼接留的余量,值超过长度会被截断,所以别给太小。改成参数化之后有个附带好处:日期、数字类型的输入由驱动负责转换,再也不会因为用户输了个单引号就整条语句语法错误。
另一件我一直建议做的事,是把业务逻辑从窗体事件里抽出来。现在每个cmdSave_Click里都是 SQL、校验、界面提示混在一起,想写个单元测试都无从下手。抽一个clsStudentService类模块,把AddStudent、QueryStudents、DeleteStudent做成方法,窗体只负责收集参数和展示结果,后面想换界面或者搬到 Web 上,业务层可以原样带走。
验证改造效果的方法很土但管用:准备一份两千条左右的测试数据,把增删改查每条路径都跑一遍,重点测三种输入——空值、超长字符串(三百个字的名字)、带单引号的内容。这三类能过,剩下的基本都是界面问题。数据库每一步都开事务,出问题直接回滚重来,别舍不得那点性能。
从那以后我每次接手这种老工程,第一件事都是先把连接串从代码里抽出来、把库完整备份一份、在虚拟机里把控件注册跑通,再去动一行业务代码。吃过一次在原库上直接改字段类型的亏,改到一半报错,回滚不回去,两百多条成绩重录了一下午。希望帮到你。
本文还有配套的精品资源,点击获取