
简介一套基于 .NET 的 C# 登录界面示例源码面向刚接触 Windows 桌面开发、希望临摹界面布局与交互逻辑的初学者也可为有经验的开发者提供视觉设计参考。压缩包内共 42 个文件约 2.06MB以 6 个 cs 源码文件与 resx、resources 资源文件为核心配合 sln 解决方案、csproj 项目配置及 png 图标素材另含编译产物与缓存文件打开解决方案即可对照窗体设计与后台代码的衔接方式。已有 1617 人学习下载。示例围绕用户名与密码输入框、登录按钮、记住我选项、忘记密码与注册入口、错误提示等常见元素展开通过 Click 事件捕获输入并完成有效性校验读者可借此理清控件布局、事件响应与简单验证机制的串联思路并将其改造为符合自身项目风格的登录入口。1. 登录界面为什么值得单独做一套源码从一个「明明能跑却不敢交」的场景说起登录界面这东西很多人第一反应是「不就是两个输入框加一个按钮」真动起手来才发现卡人的全是细节无边框窗体怎么拖、圆角一缩放就发虚、密码框右边的小眼睛图标点下去光标跑没影、高分屏上整个布局散架。这份 [.Net] C# 登录界面源码要解决的正是这些「看起来不算问题」的问题——它把一套能直接跑的 C# 登录界面拆成视觉层、交互层、数据层三段每段都给到明确参数和可替换的实现而不是一张截图配几句说明。它适合正在做课程设计、毕业设计、需要一个像样登录模块的同学也适合手上有个 C# 桌面小工具、想给它换张脸但不想从零画控件的开发者。下面按需求、设计、实现、验收的顺序把这份源码里的东西一样样摊开讲。2. 技术选型与总体架构WinForms 与 WPF 两条路线、四层职责怎么切2.1 WinForms 与 WPF 的取舍先看交付周期再看视觉上限C# 桌面端做登录界面绕不开 WinForms 和 WPF 这两个选项。我一般不会一上来就说「WPF 更现代」那是没被排期追过。真实的判断维度只有三个视觉要求有多高、交付周期有多紧、后续要不要换主题。WinForms 的优势在控件成熟、拖拽即用、调试直观缺点是圆角、阴影、过渡动画这些都要自己用 GDI 画画不好就是锯齿和闪烁。WPF 的控件模板天生矢量圆角阴影写在 XAML 里改主题只改资源字典代价是 XAML 的样式优先级和触发器会劝退一部分刚上手的人而且无边框窗口的拖动、DPI 适配的坑一点不比 WinForms 少。给一个可以直接照抄的判断表判断维度选 WinForms选 WPF视觉要求扁平、单主题、够用就行需要渐变、阴影、动画、多主题切换交付周期一周内要能演示两周以上允许调样式团队熟悉度只会拖控件、没写过 XAML写过数据绑定和模板高分屏需要手动处理缩放天生矢量缩放更平滑后续维护功能为主界面基本不动界面会被反复改这套源码的思路是两条路线都给WinForms 版把圆角、拖动、焦点边框封装成可复用的基类控件WPF 版把这些写进 Style 和 ControlTemplate。你按自己的项目情况挑一条走完就行不要两边各抄一半——混着抄最容易出现「WinForms 窗体里塞了个 WPF 的 UserControl」然后输入法、焦点、DPI 三件事一起出问题排查起来就是黑匣子。2.2 四层职责界面、状态、用例、数据各管一段登录界面最容易写崩的地方不是界面丑而是所有逻辑都塞在按钮的点击事件里。表单校验、数据库查询、错误提示、按钮禁用、记住密码写文件全在一个方法里两百行下去改一个字都怕动到别的地方。这份源码把它切成四层每层只对外暴露必要的方法// 用例层界面只认这个接口后面换成 SQLite、Web API 都跟界面无关 public interface IAuthService { // 返回统一的登录结果不抛异常给界面层 LoginResult Login(string userName, string password); // 退出时清理会话与本地凭据 void SignOut(); } // 结果对象把「成功/失败原因/剩余锁定时间」打包界面层只负责展示 public class LoginResult { public bool Success { get; set; } public string Message { get; set; } // 面向用户的提示文案 public int RemainLockSeconds { get; set; } // 账号锁定时才用得上 }代码里两个设计点值得说一下。LoginResult用返回对象而不是抛异常是因为「密码错」在业务上是正常分支用异常控制流程会让调用方到处写 try-catch还容易把真正的系统异常一起吞掉RemainLockSeconds单独留字段是为了让界面层不做时间计算——时间计算一旦放到界面里倒计时和实际锁定时间就对不上了。四层的具体分工是View 层窗体、控件、绘制只管显示和收集输入Presenter 层持有登录状态机决定什么时候禁用按钮、什么时候显示错误Service 层做校验规则和用例编排Repository 层只跟数据库打交道。层与层之间用接口连方便你在答辩时现场演示「换个数据源界面不动」。2.3 工程目录与关键文件职责目录不必复杂但要能一眼看出边界。我通常按下面这样分目录/文件职责改动频率Forms/LoginForm.cs窗体与控件布局、事件转发低Forms/Controls/RoundPanel.cs圆角容器、阴影绘制低Presenters/LoginPresenter.cs登录状态机、按钮可用性中Services/AuthService.cs校验规则、锁定策略中Services/PasswordHasher.cs加盐哈希与校验低Data/UserRepository.cs用户表读写、参数化 SQL低Config/CredentialStore.cs记住密码的加密存取低Theme/UiTheme.cs颜色、字号、圆角半径常量高Theme/UiTheme.cs被标成高频改动是有原因的项目做到一半需求方说「按钮颜色再亮一点」如果你把颜色硬编码在十几个控件里那就是一场灾难。集中成常量类之后改一行全局生效这在答辩演示前临时调色时特别救命。3. 视觉层落地配色变量、圆角阴影与无边框窗体的具体参数3.1 配色与字号先定变量再动控件好看的界面不是靠灵感是靠一套收敛的变量。我一般先定 6 个颜色、3 个字号、2 个圆角半径之后所有控件都从这里取值不再出现第八种灰。直接可抄的一张表变量参考值用途BrandPrimary#2F6BED主按钮常态BrandPrimaryHover#4A80F2主按钮悬停BrandPrimaryPressed#2456C4主按钮按下SurfaceBackground#FFFFFF输入框底色TextPrimary#1F2329标题与输入文字TextPlaceholder#9AA0A6占位提示BorderNormal#D9DDE3输入框默认边框BorderFocused#2F6BED输入框聚焦边框对应的常量类长这样internal static class UiTheme { public static readonly Color BrandPrimary Color.FromArgb(0x2F, 0x6B, 0xED); public static readonly Color BrandPrimaryHover Color.FromArgb(0x4A, 0x80, 0xF2); public static readonly Color BorderFocused Color.FromArgb(0x2F, 0x6B, 0xED); public const int CornerRadius 8; // 输入框与按钮统一圆角 public const int CardRadius 12; // 外层卡片圆角比控件大一圈才有层次 public const float TitleSize 18f; // 标题加粗 public const float BodySize 10f; // 输入与按钮文字 public const float HintSize 9f; // 错误提示比正文小一号 }参数怎么定CornerRadius不要超过控件高度的一半否则圆角会糊成一个胶囊CardRadius比控件圆角大 4 左右视觉上才有「容器包住控件」的层次感。字号差距控制在 1.5 倍以内标题 18、正文 10、提示 9是因为登录界面元素少字号跨度太大反而显得散。这地方没什么玄学把变量抽出来之后你会发现调界面变成了改数字。3.2 无边框窗体的圆角、阴影与拖动无边框窗体是「好看」的必经之路也是翻车率最高的一段。默认 FormBorderStyle 设成 None 之后窗口既没有圆角也不能拖全得自己补。圆角用 Region 裁切拖动用系统消息模拟标题栏using System.Drawing.Drawing2D; using System.Runtime.InteropServices; private const int WM_NCLBUTTONDOWN 0xA1; private const int HTCAPTION 0x2; [DllImport(user32.dll)] private static extern bool ReleaseCapture(); [DllImport(user32.dll)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); // 用圆角路径裁切窗体区域半径取自 UiTheme.CardRadius protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); ApplyRoundedRegion(UiTheme.CardRadius); } private void ApplyRoundedRegion(int radius) { using (var path new GraphicsPath()) { int d radius * 2; var r ClientRectangle; path.AddArc(r.X, r.Y, d, d, 180, 90); path.AddArc(r.Right - d, r.Y, d, d, 270, 90); path.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); path.AddArc(r.X, r.Bottom - d, d, d, 90, 90); path.CloseFigure(); var old Region; Region new Region(path); // 旧 Region 必须释放否则 GDI 句柄泄漏 old?.Dispose(); } } // 绑到顶部标题区域或整个窗体的 MouseDown实现拖动 private void TitleBar_MouseDown(object sender, MouseEventArgs e) { if (e.Button ! MouseButtons.Left) return; ReleaseCapture(); // 先释放当前鼠标捕获 SendMessage(Handle, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero); // 交给系统按标题栏拖 }逻辑说明ReleaseCapture是因为鼠标已经在你的控件上捕获不释放的话系统消息进不来WM_NCLBUTTONDOWN HTCAPTION相当于告诉 Windows「用户正在按标题栏」于是拖动、双击最大化这些行为全部由系统接管比自己在 MouseMove 里算坐标稳得多——后者在快速拖动时一定会出现窗口跟不上鼠标的情况。参数上radius建议 816太小看不出圆角太大在高分屏上边缘会露出锯齿OnHandleCreated里调用而不是构造函数里是因为句柄创建前ClientRectangle拿到的尺寸不可靠。阴影这块WinForms 可以用分层窗口自己画也可以走最省事的系统投影窗口类样式CS_DROPSHADOW。如果视觉要求不高建议后者一行搞定要求高再上分层窗口但要记得处理好UpdateLayeredWindow与控件重绘的配合否则会出现「拖动时阴影留在原地」的现象。3.3 控件四态默认、悬停、按下、禁用的绘制好看的登录界面和「能看的」差别八成在控件状态上。默认态谁都能画难的是悬停要和默认有区分、按下要有反馈、禁用态要明显灰掉但不能糊成一片。自定义按钮的绘制顺序通常是先铺背景按当前状态取色再画圆角边框最后画文字。这里有个容易被忽略的细节Enabled false时如果你仍然用BrandPrimary铺底只是把文字变灰看起来就像「按钮还能点但文字坏了」。正确做法是禁用态背景用BorderNormal那一档灰文字用比背景深一点的中灰整体明度降下来用户一眼就知道按不动。输入框同理聚焦时边框换成BorderFocused并加 1px失焦时回落——这个 1px 的反馈比任何动画都有效而且不需要额外控件。4. 交互与数据实现登录状态机、输入校验与密码哈希存储4.1 登录状态机把一次点击拆成六种状态按钮点下去到结果出来中间可能发生很多事正在校验、正在查库、查库超时、账号被锁。如果只用一个bool isLoggingIn控制这些情况全部挤在一起就会出现「点一次没反应、点三次发了三个请求」的经典问题。用枚举把状态显式写出来public enum LoginState { Idle, // 空闲可点击 Validating, // 本地校验中按钮禁用 Requesting, // 正在查询用户按钮禁用并显示加载 Success, // 成功跳转主界面 Failed, // 失败恢复可点击并显示原因 Locked // 账号锁定倒计时结束前不可点 } private LoginState _state LoginState.Idle; private void SetState(LoginState next) { _state next; bool busy next LoginState.Validating || next LoginState.Requesting; btnLogin.Enabled !busy next ! LoginState.Locked; btnLogin.Text busy ? 登录中… : 登 录; txtPassword.Enabled !busy; txtUserName.Enabled !busy; } private void BtnLogin_Click(object sender, EventArgs e) { // 双保险即使界面状态没来得及刷新也不会重复提交 if (_state LoginState.Validating || _state LoginState.Requesting) return; if (!ValidateInput(out string tip)) { ShowError(tip); SetState(LoginState.Failed); return; } SetState(LoginState.Requesting); try { var result _authService.Login(txtUserName.Text.Trim(), txtPassword.Text); if (result.Success) { SetState(LoginState.Success); DialogResult DialogResult.OK; // 交给调用方决定打开哪个主窗体 Close(); } else { ShowError(result.Message); SetState(result.RemainLockSeconds 0 ? LoginState.Locked : LoginState.Failed); } } finally { if (_state LoginState.Failed) SetState(LoginState.Idle); } }这段的关键在于进入方法第一行就做状态判断而不是靠btnLogin.Enabled false去挡。为什么因为回车键触发的是AcceptButton某些情况下按钮已经禁用但消息还在队列里光靠 Enabled 挡不住连击。状态位是最后一道闸成本一毫秒能省掉一个「老师演示时点了两下出现两条错误提示」的尴尬。4.2 输入校验什么时候该拦什么时候该放校验的边界感很重要。用户名格式不对应该在输入框失焦时就提示而不是等用户点登录密码错误则必须点登录才知道。这套源码的规则是格式类校验前置、结果类校验后置。用户名允许 332 位字母数字下划线密码只校验非空和长度下限不限制字符种类避免用户设了个强密码反而被规则挡回去private static readonly Regex UserNamePattern new Regex(^[A-Za-z0-9_]{3,32}$, RegexOptions.Compiled); private bool ValidateInput(out string tip) { string userName txtUserName.Text.Trim(); if (userName.Length 0) { tip 请输入用户名; return false; } if (!UserNamePattern.IsMatch(userName)) { tip 用户名为 3-32 位字母、数字或下划线; return false; } if (txtPassword.Text.Length 6) { tip 密码至少 6 位; return false; } tip string.Empty; return true; }RegexOptions.Compiled在这里的收益其实很小因为校验频率极低写不写都行——加上它更多是为了让代码看起来「知道自己在干什么」。真正要注意的是提示文案报错要告诉用户「怎么改」而不是只说「格式错误」。用户名为 3-32 位字母、数字或下划线比用户名不合法多打十几个字但能省掉一半的重复提问。4.3 用户表设计与密码哈希别把明文密码写进库里登录模块最能体现工程素养的地方就是密码怎么存。明文存、MD5 存、SHA256 裸哈希存这三种在课程设计里都还算常见但只要被问到一句「彩虹表是什么」就得当场愣住。正确的做法是每个用户一个随机盐用 PBKDF2 迭代。表结构CREATE TABLE AppUser ( Id INTEGER PRIMARY KEY AUTOINCREMENT, UserName TEXT NOT NULL UNIQUE, PasswordHash TEXT NOT NULL, -- Base64 的派生密钥 Salt TEXT NOT NULL, -- Base64 的 16 字节随机盐 Iterations INTEGER NOT NULL, -- 迭代次数随记录存方便以后升级 FailedCount INTEGER NOT NULL DEFAULT 0, LockedUntil TEXT NULL, -- ISO8601 时间NULL 表示未锁 CreatedAt TEXT NOT NULL );Iterations随记录存是个小设计但很有用以后把默认迭代次数从 10 万提到 20 万老用户登录时用他自己那行的次数校验校验通过后再用新次数重新算一遍写回去平滑升级不用强制所有人改密码。哈希与校验的写法// 生成盐必须用加密安全的随机源不能用 Random public static (string Hash, string Salt, int Iterations) Create(string password, int iterations 100000) { byte[] salt RandomNumberGenerator.GetBytes(16); using var kdf new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); byte[] hash kdf.GetBytes(32); return (Convert.ToBase64String(hash), Convert.ToBase64String(salt), iterations); } // 校验比较过程用固定时间比较避免逐字节比较泄露信息 public static bool Verify(string password, string storedHash, string storedSalt, int iterations) { byte[] salt Convert.FromBase64String(storedSalt); byte[] expected Convert.FromBase64String(storedHash); using var kdf new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); byte[] actual kdf.GetBytes(expected.Length); int diff 0; for (int i 0; i expected.Length; i) diff | expected[i] ^ actual[i]; return diff 0; }参数说明盐 16 字节是通用下限够用且不浪费派生密钥 32 字节对应 SHA256 输出长度不需要更长迭代次数 10 万是近几年的常用起点机器慢可以降到 5 万但别低于 1 万。校验那段的异或累加就是固定时间比较的手写版.NET 新版本里直接有CryptographicOperations.FixedTimeEquals能用就用它别自己造。4.4 记住密码本地凭据必须加密且绑定当前用户「记住密码」是需求方最爱提、也最容易做错的功能。把密码明文写进 config 文件或者 Base64 一下就存起来等于没做。Windows 上最省事且够用的是 DPAPI加密时绑定当前登录用户换个账户登录同一台机器也解不开using System.Security.Cryptography; // 附加熵让同机其他程序即便拿到密文也不能直接解 private static readonly byte[] Entropy Encoding.UTF8.GetBytes(LoginDemo.Credential.v1); public static string Protect(string plainText) { byte[] data Encoding.UTF8.GetBytes(plainText); byte[] cipher ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser); return Convert.ToBase64String(cipher); } public static string Unprotect(string cipherBase64) { byte[] cipher Convert.FromBase64String(cipherBase64); byte[] data ProtectedData.Unprotect(cipher, Entropy, DataProtectionScope.CurrentUser); return Encoding.UTF8.GetString(data); }DataProtectionScope.CurrentUser这个参数不要随手改成LocalMachine改成机器级之后同一台电脑上的任何账户都能解开安全性直接降一档。附加熵用固定字符串就行它的作用不是保密熵本身写在代码里而是让别的程序光拿到密文没法直接调Unprotect解开。真正要保密的是「谁在调用」这一点靠 CurrentUser 作用域保证。另外记住密码只应该记住用户名和密码不要把「自动登录」默认勾上自动登录一旦默认开启用户换密码后第一次启动就会看到一条莫名其妙的失败提示体验反而更差。5. 避坑与排查无边框窗体项目里五个高频翻车点5.1 跨线程访问控件抛 InvalidOperationException现象查库放到后台线程后程序时不时崩在txtUserName.Text ...这一行报「线程间操作无效」。原因很直接WinForms 控件有线程亲和性只有创建它的线程能碰。解决方式不是在每个赋值前手动Invoke而是收敛到一个方法里// 统一入口调用方不用关心自己在哪个线程 private void UiInvoke(Action action) { if (InvokeRequired) BeginInvoke(action); else action(); }用BeginInvoke而不是Invoke是因为Invoke是同步阻塞后台线程等界面线程界面线程又在等后台线程的结果时就会死锁。这属于典型的血泪经验遇到一次能记一辈子。5.2 高分屏下界面错位、字体发虚现象在 125% 或 150% 缩放的笔记本上输入框被裁掉一半文字边缘发毛。原因有两个层面一是进程没声明 DPI 感知系统把整个窗口当位图拉伸二是布局用了绝对坐标缩放后对不上。解决分两步在清单里声明 PerMonitorV2并把窗体AutoScaleMode设成Dpi布局改用 TableLayoutPanel 或 Anchor/Dock别在一个个控件上写死 Location。改完之后记得在 100%、125%、150% 三档缩放下各跑一遍只看一档不算验完。5.3 无边框窗体最大化后盖住任务栏现象点最大化窗口糊满整屏任务栏被压在下面用户切不回来。原因是WindowState Maximized对无边框窗体是按屏幕全尺寸算的不扣工作区。解决办法是最大化之前先把MaximizedBounds设成当前屏幕的工作区// 在最大化前调用Screen.FromControl 保证多显示器下取对屏幕 MaximizedBounds Screen.FromControl(this).WorkingArea; WindowState FormWindowState.Maximized;顺带一个相关问题圆角 Region 和最大化是冲突的最大化时应该把 Region 清掉恢复矩形还原时再套回去否则最大化状态下四角会露出背景色。5.4 记住密码把明文写进配置文件现象一切正常直到有人打开 config 文件发现密码是明文或者用 Base64 解了一下就出来了。原因是没有做真正的加密或者用了固定密钥的对称加密还把密钥写在同一个文件里。解决就是 4.4 节那套 DPAPI作用域设 CurrentUser附加上熵。如果项目要跨平台DPAPI 不可用那就退一步只存用户名和一个有时效的令牌密码本身不落地。5.5 异步 void 吞异常与重复提交现象网络或数据库异常时界面毫无反应日志里也什么都没有。原因是用了async void事件处理器异常抛出去没人接直接终止在同步上下文里。解决是在事件处理器内部整体包 try-catchcatch里既写日志也更新界面状态同时所有会发请求的入口都过一遍状态机防止回车连击。事件处理器用async void是 WinForms 里唯一被允许的场景但「允许」不等于「不用管异常」。6. 验收与答辩怎么用一张检查表证明这套登录界面能交付验收这件事最怕的是「我看着挺好」然后换台机器就出问题。我一般会准备一张检查表逐项过过完才算完验收项判定标准验证方法布局适配100%/125%/150% 三档缩放不错位改系统缩放后重启程序拖动与最大化可拖、可双击最大化、不遮任务栏手动操作一遍输入校验空值、超长、非法字符都有明确提示逐条构造输入密码存储数据库里看不到明文换盐后同密码哈希不同直接查表对比两条记录状态控制连点 5 次只发 1 次请求断点或日志计数记住密码换 Windows 账户后无法解密换账户启动程序异常兜底断开数据源后提示友好不闪退临时改连接串答辩环节老师或评审最爱问的三个问题基本是固定的密码怎么存的、连点怎么办、换分辨率会不会乱。这三个问题对应的就是上表的第 4、5、1 项提前把答案准备成「做法 为什么这么做 怎么验证」三段比背概念有用得多。如果时间允许现场演示一次「把数据库里的密码哈希贴出来再演示两条同密码用户的哈希不一样」比讲十分钟原理有说服力。还有个容易被忽略的点提前准备一个「降级演示」。万一现场网络或数据库连不上能一键切到本地模拟数据源把流程走完不至于卡在登录页。这个开关平时藏起来演示前确认一遍。从那以后我每次交这类带界面的项目都会在打包前把这张表从头到尾走一遍尤其是缩放和换账户这两项——它们不会在开发机上出问题只会在别人的机器上出问题。希望帮到你。本文还有配套的精品资源点击获取