简介:这是一份基于C#与SQL Server 2008开发的在线考试系统完整源码,采用Visual Studio 2010开发,面向ASP.NET学习者以及需要快速部署考试平台的教育机构。系统覆盖试卷生成、教师自主出题、学生信息管理、自动批改,并设计了学生、教师、管理员三类角色的权限体系,功能完整度较高。压缩包共133个文件,以47个C#源文件(实现业务逻辑与数据访问)、39个ASPX页面(提供界面与交互)为主,另含CSS样式、图片素材以及SQL Server数据库文件(mdf/ldf),整体仅1.66MB,结构清晰,便于逐模块阅读。该资源已有1379人学习下载,是从源码层面理解在线考试业务流程与数据库设计的实用案例。通过分析核心页面与逻辑代码,可掌握随机组卷、题目筛选、答题评分、成绩统计等关键实现;附带的数据库文件可直接附加还原,节省环境搭建时间,适合课程设计、毕业设计或教学演示参考。
1. C#在线考试系统源码里到底有什么:从一场期末考试说起
某高校的A同学接到系里任务,要把一门课的期末考从纸质搬上线。需求听起来简单:教师导入题库、一键生成试卷,学生在机房登录答题,交卷后自动出分。但他在市面上翻了半天发现,商用考试平台按月收费,题库格式被锁定,想导出成绩还要另付功能费。最后他在一个技术论坛里找到了这个标题的源码包:C#编写的在线考试系统,包含生成试卷、学生管理、教师管理和考试管理。解开后跑通一遍,我才确认这套系统的本质就是「题库管理系统 + 考试会话系统 + 评分系统」三件套:教师维护题,程序按规则抽题组卷,学生在线答题,交卷后服务端判分落库。对想自己掌控数据、能改逻辑的培训机构、高校和做内部技能考核的企业来说,这是一条低成本可落地的路线。对想练手完整业务系统的C#开发者,它也是一个能看清权限设计、事务处理和并发边界的现成教材。本文从部署到二次开发,把关键实现和踩坑点一次讲完。
2. 用C#搭在线考试系统的底层设计:权限模型与数据库建模
2.1 用户、角色、权限:学生和教师共用一个账号表的边界
我见过不少初学着把学生和教师各建一张表,理由是“字段不同”。实际上,学生表和教师表的重合度高达百分之七八十:用户名、密码、姓名、所属组织、联系方式都是同一套。分表之后,登录时得先判断输入的是学号还是工号,再决定查哪张表,出错的概率反而更高。
常见做法是统一一张用户表,加一个角色字段区分。角色字段我用int而不是字符串,因为后续权限判断就是一次等值比较,int比字符串快,也避免“teacher”和“Teacher”大小写不一致这种低级问题。
CREATE TABLE [User] ( UserId INT IDENTITY(1,1) PRIMARY KEY, LoginName NVARCHAR(50) NOT NULL UNIQUE, PassWord NVARCHAR(64) NOT NULL, RoleType INT NOT NULL DEFAULT 0, RealName NVARCHAR(50) NOT NULL, ClassId INT NULL, SubjectId INT NULL, CreateTime DATETIME DEFAULT GETDATE() );这段建表逻辑里,PassWord 字段存的是密码哈希而不是明文,常见做法是 SHA256 加盐,而不是在数据库里直接读出明文密码。RoleType 的取值约定为 0 学生、1 教师、2 管理员,这个约定要在数据访问层统一封装,别在页面代码里写死数字。ClassId 和 SubjectId 一个挂学生、一个挂教师,方便按班级统计成绩、按科目维护题库。
登录验证的代码逻辑也不复杂,核心是拿输入的用户名查库,比对哈希值,再按 RoleType 跳转到不同的首页。
public User Login(string loginName, string password) { var user = _userDal.GetByLoginName(loginName); if (user == null || !VerifyPassword(password, user.PassWord)) return null; return user; }这段代码有两个值得注意的参数:password 传进来之后只用于校验,绝不能拼进 SQL;VerifyPassword 把用户输入的密码加同样的盐做哈希,再和库里的值比较。这样即使数据库泄露,也不会直接暴露用户的明文密码。
2.2 题库、试卷、考试记录、成绩四张表的建模逻辑
整个考试系统的数据核心是四类表:题目表、试卷表、考试记录表和答题明细表。题目表负责存题面、题型、难度和答案;试卷表负责记录一份试卷的标题、总分、时长;考试记录表记录某位考生对某份试卷的一场考试;答题明细表存每一道题考生的答案和判分结果。
CREATE TABLE Question ( QuestionId INT IDENTITY(1,1) PRIMARY KEY, SubjectId INT NOT NULL, QuestionType INT NOT NULL, -- 1单选 2多选 3判断 4填空 Content NVARCHAR(MAX) NOT NULL, OptionA NVARCHAR(500) NULL, OptionB NVARCHAR(500) NULL, OptionC NVARCHAR(500) NULL, OptionD NVARCHAR(500) NULL, OptionE NVARCHAR(500) NULL, Answer NVARCHAR(500) NOT NULL, Difficulty INT NOT NULL DEFAULT 3, Score INT NOT NULL DEFAULT 5, CreateTime DATETIME DEFAULT GETDATE() );这道题表的 Answer 字段统一用字母编码存:单选题存 A,多选题存 ABCD 或 BCD,判断题用 A 表示正确、B 表示错误。这样判分代码只要比较字符串,不需要为题型写一堆分支。Content 字段放题面文本,如果题目里有公式或图片,常见做法是存 HTML 或富文本,渲染时直接输出。
试卷表需要一个单独的明细表来关联题目,而不是直接把题目内容复制进来。原因有二:题库里的题目可能被修改,但已归档的试卷要保持原样;同一道题可能出现在多份试卷里,没有明细表就只能反复复制数据。
CREATE TABLE PaperQuestion ( PaperId INT NOT NULL, QuestionId INT NOT NULL, OrderNo INT NOT NULL, Score INT NOT NULL );这里最关键的设计是:OrderNo 决定了试卷里题目的显示顺序,Score 是这道题在这份试卷里的分值。同一道题在不同试卷里分值不同是常有的事,明细表里单独存一份 Score 就是为了应对这种情况。而题目本身的内容变化,通过快照机制来处理。
考试记录表和答题明细表是这一套设计里最容易被忽略的部分。很多第一次做考试系统的人只在考试记录表里存一个总分,但阅卷、复查、导出成绩单都需要看每道题的作答情况,所以答题明细表几乎是必须的。
CREATE TABLE ExamRecord ( RecordId INT IDENTITY(1,1) PRIMARY KEY, PaperId INT NOT NULL, StudentId INT NOT NULL, StartTime DATETIME NOT NULL, EndTime DATETIME NULL, Status INT NOT NULL DEFAULT 0, -- 0进行中 1已交卷 2超时 Score INT DEFAULT 0 ); CREATE TABLE AnswerDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, RecordId INT NOT NULL, QuestionId INT NOT NULL, StudentAnswer NVARCHAR(500) NULL, IsCorrect BIT NULL, Score INT DEFAULT 0 );Score 同时出现在 ExamRecord 和 AnswerDetail 里,这不是冗余设计失误,而是故意为之。答完一交卷,系统算出每道题的分值写入明细表,再把总分写入考试记录表。之后即使题库的答案被教师改了,历史考试成绩也不会跟着变。
2.3 三层架构在这个项目里的实际边界
网上流传的C#在线考试系统源码,绝大多数按经典三层架构组织:表示层放页面;业务层放组卷、阅卷这样的核心逻辑;数据层放SQL访问。这套分层在考试系统里最大的收益,是组卷和阅卷可以脱离页面单独测试。
我一般会建议把业务层的组卷逻辑做成独立的类,方法签名只接受参数、返回数据结构,不依赖任何控件对象。这样哪怕表示层从 WebForm 换到 MVC,业务层一行都不用改。数据访问层统一封装成类,数据库连接字符串只存在于配置文件里,页面代码里不允许出现连接字符串或者拼出来的 SQL。
这样做还有一个实际好处:二次开发时,给外部系统对接一个“创建考试”的接口,业务层的方法可以直接复用,不需要跑到页面上模拟操作。
3. 把源码从rar里拿出来跑通:目录结构、数据库初始化与连接串修改
3.1 解压后的典型目录结构与每个文件夹的用途
拿到源码包后第一步不是急着打开 Visual Studio,而是先把目录结构过一遍。我接触过几套类似的考试系统,结构大同小异:解决方案文件在根目录,下面按项目分文件夹,数据库脚本通常放在名为 Database 或 SQL 的子目录,没有的话就去项目里找以 .sql 结尾的文件。
最常见的一种布局是这样:Exam.Web 是表示层,Exam.BLL 是业务层,Exam.DAL 是数据层,Exam.Model 放实体类。表示层里又按角色分目录,Student 下是学生端页面,Teacher 下是教师端页面,Admin 下是管理端页面。业务层里一般能看到 PaperService、QuestionService、ExamService 这类类文件,它们是核心逻辑所在地。
打开源码后先看两个地方:一个是 Global.asax 或 Program.cs,确认入口是什么类型的应用;另一个是 Web.config 或 app.config 里的连接字符串,确认数据库名和目标服务器。这两处确认完,基本就能判断这套源码好不好跑。
3.2 还原数据库:从初始化SQL脚本到改连接字符串
数据库初始化是跑通这套系统的第一道坎。常见做法是拿初始化 SQL 脚本直接执行,脚本里包含了建库、建表、插入默认账号的全过程。
sqlcmd -S .\SQLEXPRESS -U sa -P your_password -i ExamDatabase.sql这条命令里,-S 指定 SQL Server 实例,本地默认实例直接写英文句号,命名实例写成 .\SQLEXPRESS 这样的形式;-U 和 -P 对应登录账号和密码;-i 指定要执行的脚本文件路径。如果本机没有 sqlcmd,也可以用 SQL Server Management Studio 打开脚本文件,在查询菜单里直接执行。
执行成功后,用脚本里预设的管理员账号登录系统,先确认能读到基础数据。数据库脚本跑完以后,去应用配置里把连接字符串改成指向本机实例:
<connectionStrings> <add name="ExamContext" connectionString="Server=.;Database=ExamDB;User ID=sa;Password=你的密码;TrustServerCertificate=true;" providerName="System.Data.SqlClient" /> </connectionStrings>这里的 ExamContext 是和代码里约定好的名称,不能随便改,否则程序启动时找不到连接会直接抛异常。Server=.; 表示本机默认实例;Database 要用初始化脚本里建的库名;TrustServerCertificate=true 是新版驱动连接本地开发库时避免证书校验报错常用的写法。如果用的是集成验证,可以把 User ID 和 Password 整段换成 Integrated Security=true,但这会要求运行程序的账号有数据库权限,开发机上大多数人不一定具备,所以我默认用 SQL 账号的方式。
刚跑起来最常见的报错是登录时提示“已成功与服务器建立连接,但是在登录过程中发生错误”。这个基本就是密码不对,或者 SQL Server 没有启用混合验证模式。在本地开发机上,把 SQL Server 的身份验证模式改成“SQL Server 和 Windows 身份验证模式”并存,再重建一次登录账号即可解决。
3.3 三个入口页面对应哪些功能
跑起来之后,先别急着到处点,把三个角色的边界理清,验证就有方向了。学生端入口通常在首页的学生登录下,进去之后能看到我的考试、在线答题、我的成绩三个模块。教师端登录后看到的是题库管理、组卷管理、阅卷管理、成绩导出。管理员端则是系统管理入口,负责用户、班级、科目的维护。
我建议按下面这条链路做最小验证:管理员登录,创建一个教师账号;教师登录,导入几道题,生成一份试卷;再创建几个学生账号,用其中一个学生登录参加考试,交卷后回教师端看成绩。这条链路只要走通,说明权限、题库、组卷、考试、判分、成绩六大模块都已经正常工作了。
走链路时要注意角色切换必须重新登录,不要想着在同一浏览器里靠开多个标签页搞定,很多源码在处理会话时会把过期的登录状态直接踢回登录页。多角色验证最好用两个不同的浏览器,或者一个正常窗口加一个隐身窗口。
4. 生成试卷的核心逻辑拆解:随机抽题、难度分布与防重复
4.1 固定试卷还是随机试卷:先想清楚自动组卷策略
生成试卷是标题里排第一位的功能,也是整套系统的灵魂。实现之前先得决定策略:是固定试卷还是随机试卷。固定试卷由教师从题库里手选题目,适合期末考试这种所有考生同一份卷子的场景;随机试卷由程序按规则抽题,每个考生的题不完全一样,适合防止互相传答案的随堂测验。
我看到的源码里,多数提供两种模式,但默认走随机抽题。随机组卷要解决的问题有三个:抽哪些题、每个题型和难度各抽多少、怎么避免本次卷子和历史卷子大面积重复。这三个问题参数化之后,就是教师端组卷页面上的题型题量和难度占比两个设置区域。
4.2 用T-SQL按题型和难度抽题的两种写法
随机抽题最直观的写法是用 ORDER BY NEWID() 取前 N 条。NEWID() 每次调用生成一个全局唯一标识,把整个题目表按随机值排序,取前 N 条出来,就相当于随机抽题。
SELECT TOP 10 QuestionId, Content, Score FROM Question WHERE QuestionType = 1 AND Difficulty = 3 ORDER BY NEWID();这段 SQL 的含义是:从单选题里取难度为 3 的 10 道题。Difficulty 的取值通常约定为 1 到 5,数字越大越难。NEWID() 在整个表扫描时为每行生成一个随机排序键,所以结果是不可预测的先后顺序。这种写法的优点是代码短、易读,缺点是大表上性能一般,因为要对全表生成随机值并排序。考试题库一般就几千道题,性能完全够用。
如果要做更细的分布控制,比如每个章节都要出固定数量的题,可以用窗口函数按分组随机排序再取号:
WITH QuestionPool AS ( SELECT QuestionId, ChapterId, ROW_NUMBER() OVER (PARTITION BY ChapterId ORDER BY NEWID()) AS rn FROM Question WHERE QuestionType = 2 ) SELECT QuestionId FROM QuestionPool WHERE rn <= 5;这段 SQL 的核心是 PARTITION BY ChapterId,它对每个章节单独编号,编号顺序由 NEWID() 随机决定。外层再取 rn 小于等于 5 的数据,效果就是每个章节各抽 5 道多选题。这样组卷就能实现按章节配额的需求,而不仅仅是一次全表随机。
两道题要特别说明:一是随机的随机性只是不可预测顺序,不需要达到密码学安全级别,考试抽题防的只是考生提前获知题目;二是 NEWID() 排序时如果题目表很大,可以先按题型难度筛出小集合再排序,减少无谓的全表排序开销。
4.3 C#端把抽出的题目组装成试卷并落库
SQL 负责选出题,C# 负责把它们组装成一份完整的试卷并落库。这段逻辑要放在业务层,并且必须包在一个事务里,因为试卷主表和明细表是一起写入的,任何一步失败都不能留下半张试卷。
public Paper CreateRandomPaper(string title, int totalMinutes, int questionType, int difficulty, int count) { var questionRows = _questionDal.GetRandomQuestions(questionType, difficulty, count); if (questionRows.Count < count) throw new Exception("题库中符合条件的题目不足,请降低数量或难度要求"); var paper = new Paper { Title = title, TotalScore = count * 5, CreateTime = DateTime.Now }; _paperDal.InsertPaper(paper); int orderNo = 1; foreach (var row in questionRows) { _paperDal.InsertPaperQuestion(paper.Id, row.QuestionId, orderNo, 5); orderNo++; } return paper; }这段代码有四个关键点。第一行是数量校验,题库题目不够时直接抛异常,不能生成缺题的试卷。TotalScore 这里用 count 乘以 5 计算,但更严谨的做法是从题目表里读出每道题的分值累加,因为实际项目里不同难度、不同题型的单题分值往往不同。InsertPaper 之后拿到的 paper.Id 是数据库返回的自增主键,明细表靠它关联。orderNo 从 1 开始递增,保证试卷题号连续。所有操作都在一个事务作用域内完成,可以用 TransactionScope 或 ADO.NET 的 SqlTransaction 实现,事务能保证主表和明细表要么一起成功、要么一起回滚。
事务实现最常见的简化写法是使用 TransactionScope:
using (var scope = new TransactionScope()) { _paperDal.InsertPaper(paper); foreach (var row in questionRows) { _paperDal.InsertPaperQuestion(paper.Id, row.QuestionId, orderNo, 5); } scope.Complete(); }TransactionScope 用起来方便,它的作用域内默认对 SQL Server 开启一个分布式范围,代价是要求 MSDTC 正常工作。如果遇到分布式事务相关的报错,可以改用 SqlTransaction 直接绑定在连接上。后者写法麻烦一点,但对单库应用反而更稳。
4.4 防重复的两层保险:快照与选项乱序
试卷生成之后,有两个容易被忽略的细节直接决定系统的可靠性。一是题库题目被教师误改,导致已经考完的历史试卷在查卷时内容变了;二是多选题、判断题的选项顺序固定,考生之间容易通过选项位次传递答案。
第一个问题的解法是快照。在 PaperQuestion 明细表里增加一个 ContentSnapshot 字段,组卷时把题面、选项、答案一并存进去。查卷和阅卷时优先读快照,只在快照为空时回退到题库。这样教师改题库不会伤及历史试卷。
第二个问题的解法是选项乱序。单选题渲染时把 A 到 D 的顺序打乱,同时把标准答案一起映射到新顺序。最简单的实现是用随机数交换选项位置:
public void ShuffleOptions(Question q) { var opts = new[] { q.OptionA, q.OptionB, q.OptionC, q.OptionD }; var newIndex = new[] { 0, 1, 2, 3 }; for (int i = 3; i > 0; i--) { int j = new Random().Next(i + 1); (newIndex[i], newIndex[j]) = (newIndex[j], newIndex[i]); } q.OptionA = opts[newIndex[0]]; q.OptionB = opts[newIndex[1]]; q.OptionC = opts[newIndex[2]]; q.OptionD = opts[newIndex[3]]; }这段代码用 Fisher-Yates 洗牌算法,从后往前遍历,每个位置与随机位置交换。选项被交换后,标准答案也要跟着变:假设原答案是 C,在原列表里下标是 2,需要同步更新答案字段。如果不做这一步,题面顺序变了而答案没变,判分会全部出错。这是做选项乱序时最容易翻车的地方,建议在写完 ShuffleOptions 后立刻写一个单元测试,固定输入一组选项和答案,断言洗牌后答案与选项位置仍然匹配。
5. 在线考试系统开发中要躲开的5个常见坑
5.1 答到一半被踢回登录页,考生直接懵了
现象:学生在考试页面答题超过二十分钟,点下一题时页面突然跳回登录页,填过的答案全没了。
原因:多数源码用 Session 保存登录状态,而 IIS 的 Session 默认超时是 20 分钟。考试时长动不动就是 45 分钟、90 分钟,Session 一过期,服务端就认为用户未登录,强制跳转。
解决:把 Session 超时时间调到比最长考试时长再长 10 分钟。在 Web.config 里配置:
<system.web> <sessionState mode="InProc" timeout="100"></sessionState> </system.web>timeout 单位为分钟。这里有一个取舍:调太大,服务器内存占用上升;调太小,考试中途掉线。我一般按最长考试时长加 15 分钟来设。还要检查登录跳转逻辑,有的源码在 Session 缺失时直接 Response.Redirect 到登录页,这个行为本身没错,但应该在跳转前把当前答题进度通过 AJAX 暂存,避免掉线即丢全部答案。
5.2 同一时间大量交卷,成绩写成 0 分
现象:考试结束时几十人同时交卷,成绩单里大批记录是 0 分,但答题明细表里明明有答案。
原因:交卷时逐题判分并逐条 UPDATE,分数低的人先写完先提交,高并发下数据库连接池被占满,部分连接超时导致判分循环中断;还有一种情况是判分代码在读取标准答案时读到空白,说明事务隔离级别或并发更新出了问题。
解决:改成分批判分。交卷接口只做一件事:把 ExamRecord 的状态置为已交卷,然后异步执行判分。判分时用一条 UPDATE 按关联更新,把明细表和标准答案一次性算出来写入。关键点是判分前锁定该考试记录,防止重复交卷重复判分:
UPDATE ExamRecord SET Status = 1 WHERE RecordId = @RecordId AND Status = 0;这条 UPDATE 里 Status = 0 是并发保护条件,只有记录还处于进行中时才允许置为已交卷。受影响行数大于 0 才继续判分,否则说明学生已经交过卷。这样再多人同时交卷,也只有一条成功,不会出现重复判分覆盖成绩。
5.3 选项顺序不一致导致多选误判
现象:标准答案是 ABC,考生选 BCA,被判错;有些考生的卷子选项顺序和别人的不一样。
原因:选项乱序功能写了一半,前端显示时打乱了选项,判分端却还在按 A、B、C 的原始顺序比对字符串。
解决:学生端生成试卷时,把每道题的选项顺序和对应答案一起存到 AnswerDetail 的临时字段里,交卷判分用这份顺序映射还原答案。更简单的做法是:判分前把学生答案和标准答案都按字母排序后比对。对多选题,把选中项转成字符串后 OrderBy 排序再比较,这样 ABC 和 BCA 会被视为同一答案。判断题不存在这个问题,但单选和多选必须处理。
5.4 题目里嵌入脚本导致页面异常或注入
现象:教师在题库里粘贴了一段带图片的富文本,学生端打开试卷时页面弹窗报错或样式错乱。
原因:富文本编辑器输出的内容里有 script 或事件属性,页面直接拼接 HTML 输出,浏览器执行了其中的脚本。
解决:入库前过滤危险标签,渲染时对内容做编码。富文本允许的标签要白名单化,script、iframe、object 一律剥掉,style 属性只保留字体、颜色、对齐这几项。纯文本字段一律经过 HtmlEncode 再入库:
string safeContent = HttpUtility.HtmlEncode(rawContent);HtmlEncode 会把 < > 转成实体,浏览器显示时还原成字符,但不会执行。富文本的字段不能用整字段 HtmlEncode,否则格式全没了,要用白名单过滤的方式逐标签清理。还有一个不常提的坑:富文本里粘贴的图片如果是 base64 编码,会让题面文本凭空变长好几倍,数据库如果用的是 NVARCHAR(MAX) 问题不大,但导出成绩单时这些图片会导致文件体积膨胀得离谱。
5.5 考试时间到了,考生还能继续答题
现象:倒计时显示 0 分 0 秒,考生刷新页面后发现还能提交答案,甚至还能改答案。
原因:前端倒计时是 JavaScript 实现的,浏览器的时间可以被修改,页面也可以不跳转。服务端没有对截止时间做二次校验,只是依赖了前端的行为。
解决:双端校验。前端倒计时到点后禁用答题按钮只是体验层,服务端在提交每个答案和交卷时必须检查当前时间是否超过该考试的截止时间:
if (DateTime.Now > record.EndTime) { throw new Exception("考试已截止,无法继续作答"); }同时设一个后台定时任务,把超过考试截止时间且状态仍为进行中的考试记录强制置为已交卷,并按现有作答判分。这样即使用户关掉设备,成绩也会在超时后自动落库。强制判分的定时任务建议每 5 分钟跑一次,时间间隔太长,成绩单上会一直显示进行中,影响考生查分。
6. 给试卷加一个Word导出功能:用模板字段替换代替复杂组件
教师端经常会提一个需求:把生成的试卷导出成 Word 文件,打印给没条件上网的班级用。我第一次实现时直接引用了文档生成类库,结果光处理分页、图片、字体就折腾了一整天,还遇到部署环境类库不兼容的问题。后来我换了思路:用 HTML 格式生成 .doc 文件。Word 本身就能打开 HTML 结构的 doc 文档,而 HTML 拼接对 C# 来说再简单不过。
var sb = new StringBuilder(); sb.Append(@"<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word'>"); sb.Append("<head><meta charset='utf-8'></head>"); sb.Append("<body>"); sb.AppendFormat("<h2>{0}</h2>", paper.Title); sb.Append("<table border='1' cellpadding='4' cellspacing='0'>"); foreach (var q in questions) { sb.Append("<tr><td>"); sb.AppendFormat("<p>{0}.({1}分){2}</p>", q.OrderNo, q.Score, q.Content); foreach (var opt in q.Options) { sb.AppendFormat("<p> {0}</p>", opt); } sb.Append("</td></tr>"); } sb.Append("</body></html>"); File.WriteAllText(savePath, sb.ToString(), Encoding.UTF8);这段代码生成了一个带标题和题目的 HTML 文档,保存成 .doc 后缀后,Word 能直接打开。注意那两行 xmlns 声明,它们让 Word 识别这个文件为 Word 文档而不是普通网页,这个细节不加也能打开,但加了解析更干净。生成后的文件里保留答案区,教师打印时自己掌握分发时机。
这里藏着一个我踩过的坑:保存时必须用 Encoding.UTF8 且带 BOM。不带 BOM 的 UTF-8 文件被 Word 打开时,中文会全部乱码。File.WriteAllText 默认就是无 BOM 的 UTF-8,所以要显式指定编码为 UTF8Encoding(true)。
如果想再进一步,把这份导出的 doc 按班级批量生成,可以在循环外包一层学生名单,逐个生成文件再压缩成一个 zip。这个扩展把教师教研和打印两条线彻底解开了。
做完这个功能后,我自己养成了一个小习惯:凡是生成文件的逻辑,都在代码里加一段验证再保存——生成成功后立刻用 FileInfo 检查文件大小是否为 0,再抽查开头几个字节的 BOM 标记。文件生成这种功能,界面提示导出成功很容易,文件本身能不能被应用程序正常打开才是硬指标。考试系统这类工具,教师最在意的不是界面有多高级,而是导出的成绩单、试卷别人能正常打开。这个习惯让我少挨了不少骂,希望帮到你。
本文还有配套的精品资源,点击获取