简介:这是C#开发的学生信息管理系统完整源码包,面向计算机专业学生、毕业设计者以及需要学习WinForm桌面应用开发的初学者。系统采用分层设计,覆盖学生基本信息、成绩、出勤、活动记录等管理模块,核心窗体包括主界面、添加/修改学生用户、学生个人信息及用户信息管理,业务逻辑与数据库操作均有对应.cs代码,可作为课程设计或毕设的完整参考。压缩包共110个文件,以44个.cs源码文件、21组resx/resources资源文件为主,另含DLL依赖、可运行的exe程序、配置文件及项目解决方案sln/csproj,整体约1.26MB,结构清晰便于查看与调试。已有3497人学习下载。通过该资源可学习C#窗体布局设计、ADO.NET或Entity Framework数据访问、用户登录与权限管理,以及如何使用LINQ进行查询等关键技能;代码可直接编译运行,适合快速理解信息管理系统从界面到数据库的完整实现流程。
1. C# 做的学生信息管理系统:一份能跑通全流程的实训源码到底长什么样
带实训课的老师大多见过这一幕:交上来的 C# 学生信息管理系统窗体做得有模有样,点开“学生信息管理”就崩,仔细一看,数据库连接串还是照着网上教程抄的。真正能跑完“录入学生、排课、登成绩、统计排名、毕业离校”整个流程的版本,往往不是 PPT 里的那个,而是被一届届学生反复拆过的实训源码。
这套资源就是那个版本:C# WinForms 做界面,Access 做存储,学生、班级、课程、成绩四张表走通一套 CRUD,再配登录、状态机审核、成绩统计和 Excel 导入导出。它不炫技,但每一块都能对上课程设计和答辩的功能要求,代码也按界面层、数据访问层、业务逻辑层拆开,不是把几百行事件塞在按钮里那种写法。
适合三类人:正在做课程设计的学生,想快速上手分层结构的 C# 入门者,以及接单做内部小工具、需要一套可改脚手架的开发者。这篇会把数据库怎么选、表怎么建、核心代码怎么抄、常见坑在哪一次讲清。
2. 整体设计与数据库选型:为什么是 WinForms + Access,表结构怎么定才不返工
任何信息管理系统都是先定存储再写界面。选型直接决定连接字符串、类型映射、发布方式和后续维护成本。这套 C# 学生信息管理系统默认走 WinForms + Access,是实训项目里最经典的组合,也最容易在答辩时不被打问号。
2.1 选型:Access、SQLite、SQL Server 到底怎么选
先看一张对比表,再解释为什么默认选 Access。
| 维度 | Access(OleDb) | SQLite(Microsoft.Data.Sqlite) | SQL Server(SqlClient) |
|---|---|---|---|
| 存储形态 | 单文件 .mdb / .accdb | 单文件 .db | 服务端实例 |
| 部署成本 | 需装 Access Database Engine | 通过 NuGet 带 dll | 要安装 SQL Server 服务 |
| 并发能力 | 弱,适合单机课设 | 中等,适合工具类 | 强,适合多用户 |
| 参数占位符 | ? | @或$ | @Name |
| 典型场景 | 学生管理系统、课程设计 | 跨平台小工具 | 企业级系统 |
我一般会建议课程设计选 Access:答辩老师和验收机器基本都有 Office 环境,或者备一个 AccessDatabaseEngine 安装包就能跑。SQLite 更适合做成绿色工具,后续想换 SQL Server 也方便,但实训代码里默认按 OleDb 封装,所有换库动作都被挡在 DbHelper 后面,不用改窗体。
2.2 表结构设计:五张表搞定学生、班级、课程、成绩、状态流转
这个项目的核心表就五张:班级、学生、课程、成绩,外加一张可选的状态变更日志表。业务不复杂时表越多越难填演示数据,五张表刚好能把闭环讲清楚。
下面是 Access 的建表 SQL,可以直接在 mdb 文件里执行。
CREATE TABLE t_class ( ClassId COUNTER PRIMARY KEY, ClassName TEXT(50) NOT NULL, EnterYear TEXT(4) NULL ); CREATE TABLE t_student ( StudentId COUNTER PRIMARY KEY, StudentNo TEXT(20) UNIQUE NOT NULL, StudentName TEXT(50) NOT NULL, Gender TEXT(2) DEFAULT '男', Birthday DATETIME NULL, ClassId INTEGER NULL, Status TEXT(10) DEFAULT '在读', Phone TEXT(20) NULL, Email TEXT(50) NULL ); CREATE TABLE t_course ( CourseId COUNTER PRIMARY KEY, CourseName TEXT(50) NOT NULL, Credit SINGLE DEFAULT 0 ); CREATE TABLE t_score ( ScoreId COUNTER PRIMARY KEY, StudentId INTEGER NOT NULL, CourseId INTEGER NOT NULL, Score SINGLE NULL, ExamTime DATETIME NULL );COUNTER是 Access 的自增主键类型;换 SQL Server 要写成IDENTITY(1,1),换 SQLite 要写成INTEGER PRIMARY KEY AUTOINCREMENT。很多人从网上抄代码换库时就在这里报语法错误。StudentNo加UNIQUE是为了防止学号重复,业务层插入时也要做异常捕获,不能只靠数据库。
关键字段的职责如下。
| 表 | 关键字段 | 说明 |
|---|---|---|
| t_class | ClassId, ClassName | 班级主数据 |
| t_student | StudentNo, StudentName, Gender, Birthday, ClassId, Status | 学生主表,Status 驱动状态流转 |
| t_course | CourseId, CourseName, Credit | 课程主数据 |
| t_score | StudentId, CourseId, Score, ExamTime | 成绩明细,组合重复由业务层去重 |
Access 的 DDL 支持不全,外键约束一般靠表关系图手动建立,代码里不依赖数据库外键。删除学生时不要做物理删除,用Status='删除'保留成绩历史,否则统计报表会缺数据。
提示:如果下载的资源里没有现成的 mdb,就按这段 DDL 在 Access 里新建数据库,再在连接字符串里改 Data Source 路径,系统就能直接跑。
2.3 数据访问层封装:一个 DbHelper 类让所有窗体都干净
不封装的典型后果是:每个窗体里都写一遍OleDbConnection,连接字符串散落七八处,哪天要换数据库就得全局替换。封装之后,所有窗体只依赖一个DbHelper。
using System; using System.Data; using System.Data.OleDb; public class DbHelper { private readonly string _connStr; public DbHelper(string connStr) { _connStr = connStr; } public DataTable Query(string sql, params OleDbParameter[] pars) { using (var conn = new OleDbConnection(_connStr)) { conn.Open(); using (var cmd = new OleDbCommand(sql, conn)) { if (pars != null) { cmd.Parameters.AddRange(pars); } var dt = new DataTable(); using (var da = new OleDbDataAdapter(cmd)) { da.Fill(dt); } return dt; } } } public int ExecuteNonQuery(string sql, params OleDbParameter[] pars) { using (var conn = new OleDbConnection(_connStr)) { conn.Open(); using (var cmd = new OleDbCommand(sql, conn)) { if (pars != null) { cmd.Parameters.AddRange(pars); } return cmd.ExecuteNonQuery(); } } } }using保证连接用毕即关,mdb 文件不会被长时间占用。参数化查询是必须的,学生姓名里带单引号是经典事故,拼接 SQL 会直接把系统打崩。这里要注意 OleDb 的占位符是?,不是 SqlClient 的@Name,参数按顺序匹配,名字没有实际意义。
连接字符串放在 App.config 里,方便换环境时直接改。
<connectionStrings> <add name="StuDb" connectionString="Provider=Microsoft.ACE.OLEDB.12.0;Data Source=|DataDirectory|StudentManage.mdb;" /> </connectionStrings>|DataDirectory|是相对路径,发布后系统会定位到 exe 所在目录下的数据文件夹。如果写死绝对路径,换一台机器就崩。Microsoft.ACE.OLEDB.12.0对应 Office 2007 之后的 Access 引擎,旧机器只有 Jet 4.0 时要用Microsoft.Jet.OLEDB.4.0,但 mdb 文件版本也得匹配,这是第 4 章要重点展开的坑。
3. 核心功能实现:学生 CRUD、成绩统计、状态机与导入导出的完整写法
这一章按分层思路拆四个功能点。所有代码都围绕前面那张五表结构展开,窗体里只负责收集界面输入,业务逻辑放 Service,数据操作走 DbHelper。
3.1 学生信息 Service:增删改查与搜索的分层写法
先给实体类。学生表的Birthday允许为空,所以用DateTime?。
public class Student { public int StudentId { get; set; } public string StudentNo { get; set; } public string StudentName { get; set; } public string Gender { get; set; } public DateTime? Birthday { get; set; } public int? ClassId { get; set; } public string Status { get; set; } }DateTime?表示可空日期,没有生日时不会因为空值报错。ClassId用int?同样道理,未分班的学生也得能保存。
然后是 StudentService,封装搜索、添加、更新和软删除。
public class StudentService { private readonly DbHelper _db; public StudentService(DbHelper db) { _db = db; } public DataTable Search(string keyword, string className) { string sql = @"SELECT s.StudentId, s.StudentNo, s.StudentName, s.Gender, s.Birthday, c.ClassName, s.Status FROM t_student s LEFT JOIN t_class c ON s.ClassId = c.ClassId WHERE s.Status <> '删除'"; var pars = new List<OleDbParameter>(); if (!string.IsNullOrEmpty(keyword)) { // Jet 引擎在 OLE DB 下默认用 * 做通配符,不是 % sql += " AND (s.StudentNo LIKE ? OR s.StudentName LIKE ?)"; pars.Add(new OleDbParameter("?", "*" + keyword + "*")); pars.Add(new OleDbParameter("?", "*" + keyword + "*")); } if (!string.IsNullOrEmpty(className)) { sql += " AND c.ClassName = ?"; pars.Add(new OleDbParameter("?", className)); } sql += " ORDER BY s.StudentNo"; return _db.Query(sql, pars.ToArray()); } public void Add(Student stu) { string sql = @"INSERT INTO t_student (StudentNo, StudentName, Gender, Birthday, ClassId, Status) VALUES (?, ?, ?, ?, ?, ?)"; _db.ExecuteNonQuery(sql, new OleDbParameter("?", stu.StudentNo), new OleDbParameter("?", stu.StudentName), new OleDbParameter("?", stu.Gender), new OleDbParameter("?", stu.Birthday), new OleDbParameter("?", stu.ClassId), new OleDbParameter("?", stu.Status)); } public void Update(Student stu) { string sql = @"UPDATE t_student SET StudentName = ?, Gender = ?, Birthday = ?, ClassId = ?, Status = ? WHERE StudentId = ?"; _db.ExecuteNonQuery(sql, new OleDbParameter("?", stu.StudentName), new OleDbParameter("?", stu.Gender), new OleDbParameter("?", stu.Birthday), new OleDbParameter("?", stu.ClassId), new OleDbParameter("?", stu.Status), new OleDbParameter("?", stu.StudentId)); } public void DeleteSoft(int studentId) { string sql = "UPDATE t_student SET Status = '删除' WHERE StudentId = ?"; _db.ExecuteNonQuery(sql, new OleDbParameter("?", studentId)); } }搜索条件从第三行开始都带AND,这是动态拼 SQL 的习惯写法,比先拼1=1更明确。LEFT JOIN保证没分班的学生也能搜出来;Status <> '删除'是软删除的过滤,想看全部学生时再临时去掉这一行。LIKE 参数的值要自己拼上*,因为 Access 的 Jet 引擎在 OLE DB 下默认通配符是*,换成%反而可能匹配不到,这是和 SQL Server 明显不同的地方。
3.2 成绩统计与排行榜:用一条 SQL 把汇总做掉
成绩统计不要全查出来再在 C# 里算,一条 SQL 就能出结果,迁移后端也方便。
SELECT TOP 10 s.StudentNo, s.StudentName, ROUND(AVG(sc.Score), 1) AS AvgScore, COUNT(sc.CourseId) AS CourseCount FROM t_student s INNER JOIN t_score sc ON s.StudentId = sc.StudentId WHERE s.Status <> '删除' GROUP BY s.StudentNo, s.StudentName HAVING COUNT(sc.CourseId) >= 3 ORDER BY AVG(sc.Score) DESC;Access 里TOP 10直接跟在SELECT后面,不用括号;SQL Server 要写成SELECT TOP (10) ...。HAVING COUNT(sc.CourseId) >= 3是为了防止只考一门 100 分的学生霸榜,演示数据里更合理。调用时就是_db.Query(sql),返回 DataTable 直接绑到 DataGridView。
3.3 学生状态机:从“在读”到“毕业/休学”的流转怎么做
学生状态不是随便改的,休学不能跳回毕业,删除之后不能再激活。最省心的做法是写一张状态迁移表,用状态机思路管理,这也是 C# 高级编程里最实用的业务规则落法。
public static class StudentStatusMachine { private static readonly Dictionary<string, string[]> Transitions = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase) { { "在读", new[] { "休学", "毕业", "删除" } }, { "休学", new[] { "在读", "删除" } }, { "毕业", new[] { "删除" } } }; public static bool CanChange(string current, string target) { if (!Transitions.ContainsKey(current)) return false; return Transitions[current].Contains(target); } public static string[] Next(string current) { return Transitions.TryGetValue(current, out var next) ? next : new string[0]; } }StringComparer.OrdinalIgnoreCase让状态比对不区分大小写,避免“在读”和“在读 ”这类细节引来投诉。窗体里用Next(current)生成下拉项,点保存之前先调CanChange拦截非法流转,规则只维护这一处。
if (!StudentStatusMachine.CanChange(stu.Status, newStatus)) { MessageBox.Show("不允许从" + stu.Status + "转为" + newStatus); return; } stu.Status = newStatus; studentService.Update(stu);为什么状态机放 Service 而不是窗体?因为批量导入、Excel 更新、单条编辑都会改状态,如果每个入口各写一套 if else,后面加一种状态就要改三四处。
3.4 DataGridView 分页与界面细节:绑定、分页、图片按钮一次弄好
窗体直接dataGridView1.DataSource = dt;就能显示,但数据量上千行后会明显卡顿。Access 没有LIMIT也没有ROW_NUMBER(),常见做法是两层 TOP 分页。
private void LoadPage(int pageIndex, int pageSize) { string sql = string.Format( "SELECT TOP {0} * FROM (" + "SELECT TOP {1} s.StudentId, s.StudentNo, s.StudentName, c.ClassName " + "FROM t_student s LEFT JOIN t_class c ON s.ClassId = c.ClassId " + "WHERE s.Status <> '删除' " + "ORDER BY s.StudentId DESC) t " + "ORDER BY t.StudentId ASC", pageSize, pageIndex * pageSize); dataGridView1.DataSource = _db.Query(sql); }内层先倒序取前pageIndex * pageSize条,外层正序取当前页的pageSize条。数据超过一万行时 Access 的分页效率会明显下降,这种量级就该换 SQL Server。界面上建议关掉AutoGenerateColumns,手动配列头,避免查出来的字段顺序让用户看懵。
按钮风格用图片统一是实训项目的加分项。常见做法是放一个ImageList,然后设置按钮的FlatStyle = FlatStyle.Flat、BackgroundImage = imageList.Images[0]。窗体最大化时,把主要控件用Anchor固定到右下角,就不会拉伸后按钮还挤在左上角。需要选颜色时用ColorDialog让用户挑,配合Color.FromArgb存到配置里,比手输色号可靠。
3.5 Excel 导入导出:用 NPOI 批量建学籍
比起手工一条条录,老师通常更愿意给一张 Excel 名单。用 NPOI 的好处是不用装 Office,xls 和 xlsx 都能读。
using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; public int ImportStudents(string filePath) { int count = 0; using (var fs = File.OpenRead(filePath)) { IWorkbook wb = new XSSFWorkbook(fs); ISheet sheet = wb.GetSheetAt(0); for (int i = 1; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; string no = row.GetCell(0)?.ToString()?.Trim(); string name = row.GetCell(1)?.ToString()?.Trim(); if (string.IsNullOrEmpty(no) || string.IsNullOrEmpty(name)) continue; var stu = new Student { StudentNo = no, StudentName = name, Status = "在读" }; _studentService.Add(stu); count++; } } return count; }XSSFWorkbook对应 xlsx,老的用HSSFWorkbook对应 xls,读之前先判断扩展名。循环从第 1 行开始,跳过表头;GetCell返回 null 时?.直接给空字符串,避免空单元格把ToString()炸掉。几百行数据逐条 Add 完全够用,真到几千行再加批量插入。
导出成绩单同理,按模板往 Excel 里填行即可。如果想导出 Word 版成绩通知单,核心是用书签或内容控件定位学生姓名、成绩、排名这些变量,然后替换文本,Office 自动化太脆,用 NPOI 或 OpenXML 更稳。
4. 避坑与常见问题排查:C# 操作 Access 最容易翻车的 5 个点
这章的值钱程度和核心代码差不多。很多学生信息管理系统跑不起来,不是业务逻辑难,而是环境、连接串、空值处理这些小地方翻车。
4.1 排查思路:先看异常类型,再查连接字符串
拿到报错先分三类:OleDbException多半是连接字符串、文件权限或引擎问题;InvalidOperationException多半是线程或连接状态问题;InvalidCastException基本是空值没判断。我一般会先在 DbHelper 里加一段日志,把ex.Message写进 error.log,比 MessageBox 弹窗好用,因为弹窗一出来就挡视线。
catch (OleDbException ex) { // 0x80004005 常见于文件被占用或引擎缺失 File.AppendAllText("error.log", DateTime.Now + " " + ex.ToString() + Environment.NewLine); }日志里看到0x80004005,优先检查 mdb 文件是否被 Access 打开,其次看 Provider 是否匹配。先把异常类型分清楚,再往下查配置,比乱改代码高效得多。
4.2 高频坑 1:Provider 与 mdb 版本不匹配
现象:本机编译运行都正常,换到答辩电脑上就报“未在本地计算机上注册 Microsoft.ACE.OLEDB.12.0 提供程序”。
原因:目标机器没装 Access Database Engine,或者项目平台目标与 ACE 版本不一致。常见的组合坑是项目选 AnyCPU,64 位系统默认以 64 位进程跑,但很多机器只装了 32 位 ACE。
解决:两步走。先在 App.config 里确认Provider=Microsoft.ACE.OLEDB.12.0,再把项目属性的“目标平台”改成 x86。如果公司电脑不允许装引擎,可以换Microsoft.ACE.OLEDB.16.0,但前提是装了对应的 AccessDatabaseEngine 2016 Redistributable。老 Jet 4.0 只能读旧 mdb,读不了 accdb。
4.3 高频坑 2:跨线程更新状态栏抛“线程间操作无效”
现象:导入 Excel 或后台查询时,状态栏闪了一下就抛InvalidOperationException: 线程间操作无效,进度条也不动了。
原因:WinForms 控件只能在创建它的线程上访问。后台线程直接改toolStripStatusLabel1.Text会被框架拦截。
解决:封装一个统一的更新方法,用InvokeRequired判断线程,再通过BeginInvoke回到主线程。
private void SetStatus(string text) { if (toolStripStatusLabel1.InvokeRequired) { toolStripStatusLabel1.BeginInvoke(new Action<string>(SetStatus), text); return; } toolStripStatusLabel1.Text = text; }BeginInvoke是异步投递,不会卡当前后台线程。窗口若已关闭,调用前要判断IsDisposed,否则会抛ObjectDisposedException。也可以改用 BackgroundWorker.ReportProgress,但一个通用方法到处能调用,更省事。
4.4 高频坑 3:日期字段为空时 DataGridView 显示“无法将 DBNull 转换为 String”
现象:查询结果里有学生没填生日,DataGridView 直接蹦 InvalidCastException,定位半天发现是空日期。
原因:Birthday列是 DBNull,DataGridView 的文本框单元格不能直接显示 DBNull。
解决:查询后统一把空日期转成空字符串。
foreach (DataRow row in dt.Rows) { if (row["Birthday"] == DBNull.Value) { row["Birthday"] = ""; } }也可以在 SQL 里用IIf(Birthday IS NULL, '', Birthday),但这样会把日期列变成文本类型,排序会按字符串走。更推荐先判空再转yyyy-MM-dd,DataGridView 列的DefaultCellStyle.Format只负责格式,DBNull 会先炸,所以判空不能省。
4.5 高频坑 4:自增主键回填时机不对,导致新增后 ID 拿不到
现象:新增学生后要马上给他写成绩,取回来的StudentId一直是 0。
原因:ExecuteNonQuery只返回影响行数,不返回新主键。有的同学在连接关闭之后再查@@IDENTITY,自然拿不到。
解决:在 DbHelper 里加一个GetIdentity方法,必须保证插入和查询在同一个连接上完成。
public object GetIdentity(string insertSql, params OleDbParameter[] pars) { using (var conn = new OleDbConnection(_connStr)) { conn.Open(); using (var cmd = new OleDbCommand(insertSql, conn)) { cmd.Parameters.AddRange(pars); cmd.ExecuteNonQuery(); cmd.CommandText = "SELECT @@IDENTITY"; return cmd.ExecuteScalar(); } } }调用时先执行插入 SQL,再把同一个命令对象的CommandText改成SELECT @@IDENTITY,取回自增值。Access 的写法是@@IDENTITY,SQL Server 里更严谨的是SCOPE_IDENTITY(),别直接照搬。
4.6 高频坑 5:发布后提示“未找到类型或命名空间”
现象:源码包从网上下下来,编译报 CS0246,DbHelper或Models找不到。
原因:下载的源码文件没被正确加入项目,或者 namespace 和项目默认命名空间不一致。这种情况常见于压缩包解压后把文件丢了,或原作者换过电脑、改名过项目。
解决:先在解决方案资源管理器里看报错文件是不是灰的,灰的就是被排除出项目,右键“包括在项目中”。再检查项目属性里的“默认命名空间”,把所有.cs文件头部的namespace统一。如果报的是第三方库找不到,去 NuGet 还原对应包,Access 项目一般只需要 System.Data 这些基础程序集。
5. 进阶用法与交付验证:从合并 dll 到防反编译的几步实操
功能跑通之后,再往交付方向推一步。学生信息管理系统虽然是个课设级项目,但交付时能不能变成一个 exe、会不会被人直接反编译看代码、换台电脑能不能启动,这些都是加分项。
5.1 用 Costura.Fody 把依赖打成一个 exe
如果用了 NPOI 这类第三方库,发布目录会多出一堆 dll。拷贝时漏一个,到客户机器就闪退。常见做法是用 Costura.Fody 把程序集合并进主 exe。
NuGet 安装Costura.Fody后,项目会自动生成一个FodyWeavers.xml。
<Weavers> <Costura /> </Weavers>重新编译发布,依赖 dll 会被嵌入主程序。代价是 exe 体积变大,杀毒软件偶尔会误报,但作为绿色工具足够省心。答辩时直接交一个单文件 exe 和一份说明,观感比一堆 dll 好很多。
5.2 防反编译与机器码授权
.NET 程序集用 ILSpy 拖进去就能看源码,这个事实没法回避。学生管理系统本身敏感度不高,但如果想防止代码被白嫖,至少做一层混淆,常见工具是 ConfuserEx,课设和内部工具够用了。
如果要做授权校验,简单方案是绑定机器码。比如读取硬盘序列号做注册因子。
using System.Management; var disk = new ManagementObject("Win32_LogicalDisk.DeviceID=\"C:\""); disk.Get(); string sn = disk["VolumeSerialNumber"]?.ToString() ?? "";System.Management需要在工程里手动添加引用。这套逻辑不该当安全手段,只是提高门槛,防普通用户和演示场景足够。
5.3 交付前 10 分钟验证清单
最后这些检查项我每次交付前都会走一遍,比临时翻代码靠谱。
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| Provider 环境 | 换一台没装 Office 的机器启动 | x86 + ACE 引擎能正常连接 |
| 单引号姓名 | 新增一个叫 O'Neil 的学生 | 保存不报错,搜索能查到 |
| 空日期 | 清空生日字段再查询 | DataGridView 正常显示空串 |
| 自增主键 | 新增后立即写成绩 | 新 StudentId 不是 0 |
| 状态机 | 把“在读”直接改成“删除”再改回 | 第二次修改被拦截 |
| 发布包 | 只保留 exe 和 mdb 目录后启动 | 首次启动不弹缺失 dll |
从那以后,我每次交付 C# 学生信息管理系统前都会强制走一遍验证清单,先看 Provider,再测单引号姓名,最后做一次干净目录启动。这套项目最怕的不是业务复杂,而是连接串、空值、线程环境这些小事。把它跑通一遍,后面再加工成绩统计、导出 Word、做图片按钮都会顺很多,希望帮到你。
本文还有配套的精品资源,点击获取