ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从Excel到Web:ASP.NET值班排班管理系统的数据库设计与部署实践

从Excel到Web:ASP.NET值班排班管理系统的数据库设计与部署实践

简介:一套面向企事业单位值班管理场景的ASP.NET WebForms源码项目,基于Visual Studio 2010与Access数据库开发,包含登录认证、值班排班、部门与人员维护、历史值班查询、离岗登记等常用功能,适合需要快速搭建轻量级B/S值班系统的开发者参考。压缩包共77个文件,约8.39MB,主体为31个aspx页面、2个ascx用户控件,配合CSS样式表、JavaScript脚本(含jQuery、日期选择器)、少量DLL程序集及MDB数据库文件,另含帮助说明文本与环境配置文件,便于还原初始运行状态;页面、脚本、图片、数据分层存放,便于按模块查找与二次开发。目前已有491人学习下载,说明该案例具备一定的通用性与借鉴价值。通过它可学习ASP.NET页面事件处理、用户控件复用、Ajax局部刷新、Access数据库操作以及值班排班逻辑的实现细节,对入门ASP.NET或承担行政办公类小型管理系统开发工作具有参考意义。

1. 值班排班还在用Excel:ASP.NET值班管理系统把这一摊子搬进浏览器

在很多单位,值班排班至今还是三张Excel表:一张排班、一张换班记录、一张月末统计,月末对着微信群里的截图核对能核对到晚上十点。ASP.NET值班管理系统就是把这一摊子搬进浏览器:ASPX页面管排班、换班申请、审批、值班记录和统计,数据库统一存,权限按角色分。它不追求技术新,WebForms的Postback模型放给老同事用反而顺手,IIS一挂就能用。

适合的场景很明确——车间班组、医院科室、物业保安这类要按周/月循环排班、审批留痕的团队,以及手里还攥着旧ASP.NET代码想快速整改交付的开发者。老技术不代表该被淘汰,在内部管理系统这个圈子里,稳定、好维护、不容易跑偏,比炫技重要得多。

2. ASP.NET WebForms值班系统的家底:三层架构与五张核心表

2.1 为什么这个场景还选ASPX:生命周期、Postback与ViewState

很多新入场的开发者一看到ASPX就皱眉,觉得是历史包袱。在这个项目场景里,选型恰恰是被需求推着走的:第一,改动范围就是内部局域网,一台服务器带几十个并发;第二,业务逻辑是典型的表单+列表+审批,Postback天然契合;第三,团队里老员工熟悉的是GridView绑定数据这样直白的写法,上手快,不用重新培训。WebForms把状态管理交给ViewState,服务器控件自带事件回发,排班页面选中一行、点击换班,事件的触发和值传递都封装在页面生命周期里,这一点和MVC的路由与控制器模型完全是两套心智。

ASPX页面的生命周期大致是:请求进来后依次触发Init、Load、事件处理、PreRender、SaveState、Render。Init阶段恢复控件树,Load阶段加载数据,事件处理阶段响应按钮点击或行命令。新手最容易犯的错是忘了判断IsPostBack,导致每次回发都重新绑定数据,用户改了一半的表单被刷新掉。值班系统里排班页、换班审批页都频繁发生回发,所以Page_Load里绑数据的逻辑必须用if (!IsPostBack)包住,这个习惯比任何框架技巧都重要。

2.2 五张核心表:从排班计划到换班审批的数据模型

这套值班系统的数据库我拆成了五张核心表,分别是用户表、班次表、排班计划表、换班申请表和值班记录表。用户表管登录和角色,班次表管白班夜班的起止时间,排班计划表是业务主表,换班申请表管审批流,值班记录表存每次值班的交接内容。拆表的原则是:班次这种会重复引用的字典数据必须独立成表,不然排班计划里时间写死,后面统计夜班次数会写得非常痛苦。

CREATE TABLE DutyUser ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(20) NOT NULL, RoleId INT NOT NULL, -- 1管理员 2排班员 3普通值班员 IsEnabled BIT NOT NULL DEFAULT 1 ); CREATE TABLE DutyShift ( ShiftId INT IDENTITY(1,1) PRIMARY KEY, ShiftName NVARCHAR(20) NOT NULL, -- 白班/夜班 StartTime NVARCHAR(5) NOT NULL, -- 08:00 EndTime NVARCHAR(5) NOT NULL -- 20:00 ); CREATE TABLE DutyPlan ( PlanId INT IDENTITY(1,1) PRIMARY KEY, UserId INT NOT NULL, DutyDate DATE NOT NULL, ShiftId INT NOT NULL, Status TINYINT NOT NULL DEFAULT 0 -- 0正常 1已换班 2已调休 ); CREATE TABLE DutySwap ( SwapId INT IDENTITY(1,1) PRIMARY KEY, PlanIdA INT NOT NULL, -- 发起人的排班ID PlanIdB INT NOT NULL, -- 被换班人的排班ID Reason NVARCHAR(200) NULL, Status TINYINT NOT NULL DEFAULT 0 -- 0待审批 1同意 2拒绝 ); CREATE TABLE DutyLog ( LogId INT IDENTITY(1,1) PRIMARY KEY, PlanId INT NOT NULL, -- 关联到排班计划 Content NVARCHAR(500) NOT NULL, -- 值班交接事项 CreatedTime DATETIME NOT NULL DEFAULT GETDATE() );

Why 这样设计:DutyShift把班次单独拆出来,加一个新班次不需要改排班表结构,统计报表里按ShiftName聚合也很顺;DutyPlan里DutyDate用DATE类型而非DATETIME,是为了避免排班日期被时间部分污染;DutySwap只存两边的排班计划ID,不冗余值班人姓名,审批通过后直接改DutyPlan的状态,换班记录天然留痕。这套表结构不复杂,但覆盖了排班、换班、记录、统计四条主要业务线。

2.3 列表页的标准姿势:GridView + 分页 + RowCommand

值班系统的列表页面,无论是排班计划列表还是值班记录列表,常规做法都是GridView绑定数据,配分页和行命令。下面这段代码是这个资源包里排班列表页的核心骨架,把DataSource换成你自己的查询方法就能用。

<asp:GridView ID="grdPlan" runat="server" AutoGenerateColumns="False" AllowPaging="True" PageSize="20" OnPageIndexChanging="grdPlan_PageIndexChanging" OnRowCommand="grdPlan_RowCommand"> <Columns> <asp:BoundField DataField="DutyDate" HeaderText="值班日期" DataFormatString="{0:yyyy-MM-dd}" /> <asp:BoundField DataField="UserName" HeaderText="值班人" /> <asp:BoundField DataField="ShiftName" HeaderText="班次" /> <asp:ButtonField Text="申请换班" CommandName="ApplySwap" ButtonType="LinkButton" /> </Columns> </asp:GridView>
protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { BindPlan(1); } } private void BindPlan(int pageIndex) { // 分页参数:最多只取当前页的数据,避免全表加载 DataTable dt = GetDutyPlanByPage(pageIndex, 20); grdPlan.DataSource = dt; grdPlan.DataBind(); } protected void grdPlan_RowCommand(object sender, GridViewCommandEventArgs e) { if (e.CommandName == "ApplySwap") { int planId = Convert.ToInt32(e.CommandArgument); // 跳到换班申请页,把排班ID带过去 Response.Redirect("SwapApply.aspx?planId=" + planId); } }

逻辑说明:Page_Load里只做首次绑定,回发时GridView会把数据从ViewState里恢复,不需要重新查库;RowCommand通过CommandName区分操作类型,CommandArgument这个参数在GridView里绑定的是当前行的主键。很多项目把GridView和后端代码耦合得很死,建议封装一个GetDutyPlanByPage方法,里面用SqlDataAdapter做分页查询,这样列表页改动时只动一个方法,页面事件可以保持干净。

3. 排班与换班审批怎么落地:生成规则、状态机与统计查询

3.1 批量生成排班:按固定规则生成一个月计划

排班是最容易产生脏数据的环节。这个资源里的做法是先用存储过程按规则生成当月计划,再允许排班员手工微调,避免每天手打排班。常见规则是周一到周五白班、周末夜班,人员按用户表顺序轮转。下面这个存储过程生成指定年月的排班计划,参数只有两个:年份和月份。

CREATE PROCEDURE sp_GenerateDutyPlan @Year INT, @Month INT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; BEGIN TRAN; DECLARE @StartDate DATE = DATEFROMPARTS(@Year, @Month, 1); DECLARE @EndDate DATE = EOMONTH(@StartDate); -- 重新生成前先清掉当月旧计划,避免重复数据 DELETE FROM DutyPlan WHERE DutyDate BETWEEN @StartDate AND @EndDate; ;WITH Numbers AS ( SELECT 0 AS n UNION ALL SELECT n + 1 FROM Numbers WHERE n < 31 ), DaysOfMonth AS ( SELECT DATEADD(DAY, n, @StartDate) AS DutyDate FROM Numbers WHERE DATEADD(DAY, n, @StartDate) <= @EndDate ) INSERT INTO DutyPlan (UserId, DutyDate, ShiftId, Status) SELECT u.UserId, d.DutyDate, CASE WHEN DATEPART(WEEKDAY, d.DutyDate) IN (1, 7) THEN 2 ELSE 1 END AS ShiftId, 0 AS Status FROM DaysOfMonth d CROSS JOIN ( SELECT TOP 10 UserId FROM DutyUser WHERE IsEnabled = 1 ORDER BY UserId ) u; COMMIT; END

参数说明:@Year、@Month决定生成哪个月,DATEFROMPARTS拼出月初日期,EOMONTH算出月末;Numbers递归CTE生成1号到月底的所有日期,最大31天够用;CROSS JOIN把每个日期和值班用户做笛卡尔积,再用DATEPART(WEEKDAY)判断周末。这里有个坑:DATEPART(WEEKDAY)的结果受SET DATEFIRST影响,默认周日是1,周一是2,如果你改了DATEFIRST,判断逻辑要跟着调。

注意:这个写法适合人数不超过10人的小团队,人数多了会出现同一天排给多人的情况,实际项目里一般会在规则层再加一个人数过滤或者轮转序号。CROSS JOIN的轮转顺序是按UserId排的,要改成按上次值班日期轮转,得在存储过程里加一个排序号字段,这个可以放到后期功能里再补。

3.2 换班申请与审批:状态机 + 唯一性校验

换班是值班系统里最容易产生数据冲突的地方。A和B换班,如果两边都同时操作,就可能出现一条排班记录被换走两次的情况。这个资源把审批逻辑放在存储过程里,事务内做校验,能有效避免并发窗口。

protected void grdPlan_RowCommand(object sender, GridViewCommandEventArgs e) { if (e.CommandName == "ApplySwap") { int sourcePlanId = Convert.ToInt32(e.CommandArgument); // 把来源排班ID放到ViewState,审批页提交时再取 ViewState["SourcePlanId"] = sourcePlanId; Response.Redirect("SwapApply.aspx?planId=" + sourcePlanId); } }

换班申请页填好对方排班ID和理由之后,提交时调用审批存储过程。审批时最关键的是下面这段状态校验:

CREATE PROCEDURE sp_AuditSwap @SwapId INT, @Status TINYINT AS BEGIN SET NOCOUNT ON; DECLARE @PlanA INT, @PlanB INT; -- 取出换班申请两边的排班ID SELECT @PlanA = PlanIdA, @PlanB = PlanIdB FROM DutySwap WHERE SwapId = @SwapId; IF @Status = 1 BEGIN -- 校验两条排班都是正常状态,任何一条已换过就不能再换 IF EXISTS ( SELECT 1 FROM DutyPlan WHERE PlanId IN (@PlanA, @PlanB) AND Status <> 0 ) BEGIN RAISERROR('其中一条排班已经参与过换班,不能重复操作', 16, 1); RETURN; END -- 两边的状态同时改成已换班 UPDATE DutyPlan SET Status = 1 WHERE PlanId = @PlanA; UPDATE DutyPlan SET Status = 1 WHERE PlanId = @PlanB; UPDATE DutySwap SET Status = 1 WHERE SwapId = @SwapId; END ELSE BEGIN -- 拒绝:只更新申请状态,排班计划保持原样 UPDATE DutySwap SET Status = 2 WHERE SwapId = @SwapId; END END

逻辑说明:存储过程先把待审申请的两个排班ID取出来,通过判断DutyPlan.Status是否等于0来保证排班记录没被换过;两条排班同时更新状态,要么都成功要么都失败,事务保证一致性。应用层校验最大的问题是两条SQL语句之间存在时间差,理论上会出现两边同时读到Status=0然后都执行更新,存储过程把查询和更新放在同一个事务批次里,风险小得多。

3.3 值班记录与统计报表:聚合查询与导出兜底

值班记录页就是值班人登录后按排班日期写交接内容,一张表单提交后写入DutyLog。统计报表则用聚合SQL,一个月跑一次,直接给管理员的页面出数据。下面是最常用的每人值班数统计,统计某个月的每人总值班次数、夜班次数和换班次数。

SELECT u.UserName, COUNT(*) AS TotalDuty, SUM(CASE WHEN s.ShiftName = N'夜班' THEN 1 ELSE 0 END) AS NightDuty, SUM(CASE WHEN p.Status = 1 THEN 1 ELSE 0 END) AS SwapCount FROM DutyPlan p JOIN DutyUser u ON u.UserId = p.UserId JOIN DutyShift s ON s.ShiftId = p.ShiftId WHERE p.DutyDate BETWEEN @StartDate AND @EndDate GROUP BY u.UserName ORDER BY TotalDuty DESC;

参数说明:@StartDate和@EndDate是统计区间的起止日期,传当月第一天和最后一天。SUM(CASE WHEN ...)是聚合里常用的条件计数写法,比COUNT加WHERE再拼接多个结果集方便得多。排序用TotalDuty DESC可以让值班次数多的人排前面,方便管理员一眼看出工作量不平衡。报表页面我一般会再加一个月份选择器,选中后往这个SQL传两个日期参数,整个报表页不需要接第三方图表库,直接用GridView渲染出来就行。

4. 部署与配置:IIS、连接串与目录权限三件套

4.1 Web.config连接串与发布方式

老项目的连接串经常是明文写在Web.config里的,而且要改IP、账号、密码还要改配置文件里的整段字符串。建议把这个资源包里的连接串单独抽到connectionStrings节点里,发布时只改这一处,不用在代码里到处找SQL连接。

<connectionStrings> <add name="DutyConn" connectionString="Data Source=127.0.0.1;Initial Catalog=DutyDB;User ID=duty_user;Password=xxxx;MultipleActiveResultSets=True;" providerName="System.Data.SqlClient" /> </connectionStrings>

参数说明:Data Source是数据库实例地址,Initial Catalog是数据库名,MultipleActiveResultSets=True允许在同一个连接上同时执行多个查询,排班页面里同时读取列表和统计时能减少连接占用。如果服务器是Windows认证方式,连接串可以改成Integrated Security=True。要注意的是密码里如果包含分号、引号这类特殊字符,必须用XML实体转义,不然配置文件直接解析失败。

发布方式上,老项目常用“发布网站”直接拷到服务器,aspx文件保留源码,改动页面比较方便;预编译发布会把所有C#代码编译成DLL,服务器上看不到后台代码。两种方式对值班系统来说都够用,但如果接的是外包活,我一般选预编译,防止源码泄露。

发布方式服务器上是否保留aspx源码适合场景
发布网站保留,页面可改内部运维、频繁微调
预编译只留编译后的DLL交付外包、代码保护

4.2 IIS应用池与身份

部署到IIS时新手最容易翻车的点是应用池没配对,页面一打开就报503或者无法识别的错误。值班系统的页面是基于WebForms的,应用池的.NET CLR版本要选v4.0,托管管道模式建议用集成模式。经典模式下有些控件事件和控制器的处理方式不同,跑起来会出现诡异的页面行为,排查半天才发现是管道模式不对。

配置项建议值说明
.NET CLR版本v4.0对应.NET Framework 4.x
托管管道模式集成模式与模块、授权协同更顺
应用程序池标识ApplicationPoolIdentity默认即可,权限隔离

创建完应用池后,网站绑定里把主机头设置成内网机器名或IP,端口可以用80,也可以单独指定一个像8080这样的端口。如果服务器上已经跑了别的站点,端口别冲突,否则站点一直处于停止状态,日志里不报错很难定位。

4.3 文件目录权限与上传目录

值班系统如果做了交接班图片上传,IIS进程就需要对上传目录有写权限。默认的ApplicationPoolIdentity权限很有限,直接往站点目录里写文件会报“对路径的访问被拒绝”。解决办法是在服务器上把上传目录的写权限显式授给当前应用池的虚拟账号,命令可以这样敲:

icacls D:\DutySite\Uploads /grant "IIS APPPOOL\DutyPool":(CI)(OI)M

参数说明:D:\DutySite\Uploads是上传目录的物理路径,DutyPool是应用池名称;(CI)(OI)表示容器和对象都继承这个权限,M是修改权限,比完全控制少一点,够用。跑完命令后最好手动在浏览器里传一张图试试,能传上去说明权限通了。日志目录也是一样,Global.asax里写文件日志的路径需要IIS用户有写权限,否则异常记录写不进去,后面排错就没有依据。

5. 值班系统常见的坑与排查:五条翻车记录

5.1 线上报错但日志文件一片空白

现象:用户点“生成排班”后页面直接报黄页错误,管理员打开服务器上的日志文件却发现一个字都没有。原因:很多老项目只在页面里捕获了异常,没有在应用层面统一记录,异常一旦发生在存储过程调用或事件绑定里,就直接抛到页面层,日志系统没接到。解决:在Global.asax里加Application_Error事件,把还活着的异常抓下来写入日志文件。

protected void Application_Error(object sender, EventArgs e) { Exception ex = Server.GetLastError(); string logPath = Server.MapPath("~/Logs/error_" + DateTime.Now.ToString("yyyyMMdd") + ".log"); System.IO.File.AppendAllText(logPath, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " " + ex.ToString() + Environment.NewLine); Server.ClearError(); }

逻辑说明:App_Data或Logs目录需要IIS写权限,这段代码里文件路径按天拆开,方便按日期翻问题。抓到异常后调用Server.ClearError()是为了防止它继续往后抛造成二次报错。从那之后我每接一个老项目,第一件事就是确认全局异常日志能不能写进去,不然排查问题等于摸黑。

5.2 排班页面刷新后数据丢失

现象:管理员在排班页调好了几行数据,一按F5,刚填的内容全没了,提示重新登录。原因:Session默认是InProc模式,存在IIS进程内,应用池一旦回收,所有登录状态和页面暂存数据全部消失。解决:让Session不依赖应用池进程,改用StateServer或SQL Server存Session;如果不想改架构,至少在排班这种关键页面把暂存数据写进数据库,不能在Session里留关键业务数据。常见的状态配置是在Web.config的system.web节点里加sessionState模式,整套值班系统如果只是内部几十个人用,StateServer就够了。

5.3 连接池耗尽导致登录超时

现象:系统运行一两个月后,某天早上所有人登录都报“Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool”。原因:有人写数据访问代码时只Open不Close,或者用了DataTable但没有释放DataAdapter关联的连接,连接池的连接全部被占满。解决:把所有SqlConnection改成using块包裹,确保方法结束或异常时连接释放。

// 反面:只new不释放 SqlConnection conn = new SqlConnection(connStr); conn.Open(); // 正面:using块保证Dispose using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd = new SqlCommand(sql, conn)) { // 执行查询 } }

参数说明:连接池默认开启,Max Pool Size默认是100,这个数值不是越大越好,池只是复用连接,不是无限开连接。修完代码后重启应用池,连接池会被清空,之后再观察一两个月,超时一般就再没出现过。

5.4 部署后CSS和图片全部404

现象:本地开发一切正常,发布到IIS后页面裸奔,样式全丢,图片也打不开。原因:aspx页面里用了相对路径引用CSS,比如css/style.css,当页面处在子目录或虚拟目录下时,相对路径变成了子目录下的css,IIS自然找不到。解决:统一用根路径或ResolveUrl生成绝对路径,模板页里的写法是这样:

<link href="<%= ResolveUrl("~/Content/site.css") %>" rel="stylesheet" />

逻辑说明:~会解析到当前站点的根路径,ResolveUrl拿到的是完整的应用程序根路径,无论在哪个子目录都能正确指到Content/site.css。这个坑在本地调试时不容易出现,因为本地路径和最终部署路径一致,只有部署到虚拟目录或子目录时才暴露。

5.5 日期又少了一天:UTC与格式化

现象:值班记录页显示的日期是前一天,或者Excel导出里日期变成了带时间的乱码。原因:数据库里存的字段是DATETIME,带时分秒,页面绑定GridView时没有指定格式化字符串,默认把2025-03-15 00:00:00整个输出;另外如果服务器Region设为UTC而C#代码里用了ToLocalTime,会出现差一天。解决:排班日期字段在表里就用DATE类型,页面显示统一用DataFormatString="{0:yyyy-MM-dd}",取数据时用Convert.ToDateTime转完再ToString格式化,不要拿着DataRow里的值直接ToString。

// 错误:item["DutyDate"]是object,直接ToString输出完整时间 object rawDate = row["DutyDate"]; string bad = rawDate.ToString(); // 正确:先转DateTime再格式化 string good = Convert.ToDateTime(rawDate).ToString("yyyy-MM-dd");

逻辑说明:Convert.ToDateTime会根据服务器区域设置解析,如果服务器是UTC时区,和数据库本地时间会有偏差。内部系统最稳妥的做法是不做时区转换,数据库存什么就显示什么,统一格式字符串,所有列表、报表、导出Excel都用同一个格式,日期类问题基本能根治。

6. 进阶改造:统一鉴权、字典缓存与报表导出

6.1 统一鉴权:所有页面继承一个BasePage

老值班系统的登录校验经常散落在每个页面的Page_Load里,代码重复且容易漏。一个有效做法是新增一个BasePage类,让所有业务页面继承它,在OnInit阶段判断登录状态。

public class BasePage : System.Web.UI.Page { protected int CurrentUserId { get; private set; } protected override void OnInit(EventArgs e) { if (Session["UserId"] == null) { Response.Redirect("~/Login.aspx?returnUrl=" + Server.UrlEncode(Request.RawUrl)); } CurrentUserId = (int)Session["UserId"]; base.OnInit(e); } }

OnInit比Load早,登录失效时能第一时间跳走,带returnUrl参数可以让用户登录后回到原页面。

6.2 字典缓存:班次数据不每次查库

班次表这种基本不变的字典数据,每次打开排班页都查库属于白白消耗连接。我一般会在数据访问层套一个Cache缓存,首次读取后缓存30分钟,管理员调整班次后手动清缓存。

private DataTable GetShifts() { if (Cache["DictShift"] == null) { // 从数据库加载班次表 Cache.Insert("DictShift", dt, null, DateTime.Now.AddMinutes(30), TimeSpan.Zero); } return (DataTable)Cache["DictShift"]; }

缓存过期时间设成30分钟一个周期,数据变化后最多延迟半小时生效,内部系统完全够用。

6.3 报表导出:不引入第三方库的Excel方案

统计报表要导出Excel,老服务器不一定连外网,不方便装NuGet包。经验做法是直接输出HTML表格并声明Excel的ContentType,中文导出不乱码的关键是编码设成GB2312。

Response.Clear(); Response.ContentType = "application/vnd.ms-excel"; Response.ContentEncoding = Encoding.GetEncoding("GB2312"); Response.AddHeader("Content-Disposition", "attachment;filename=DutyReport.xls"); Response.Write("<table><tr><td>值班人</td><td>值班次数</td></tr>"); Response.Write("</table>"); Response.End();

这种方式生成的报表用Excel和WPS都能打开,适合报表量不大、格式简单的场景。

从那以后,我每次维护这类型的老系统都强制走一遍:先确认全局异常日志能写,再检查Session过期策略,最后盯一遍上传目录权限。旧系统不是不能动,而是每一步都要给后来的人留好后悔药。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进