简介:一套基于 .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建议 8~16,太小看不出圆角,太大在高分屏上边缘会露出锯齿;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 输入校验:什么时候该拦,什么时候该放
校验的边界感很重要。用户名格式不对,应该在输入框失焦时就提示,而不是等用户点登录;密码错误则必须点登录才知道。这套源码的规则是:格式类校验前置、结果类校验后置。用户名允许 3~32 位字母数字下划线,密码只校验非空和长度下限(不限制字符种类,避免用户设了个强密码反而被规则挡回去):
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-catch,catch里既写日志也更新界面状态;同时所有会发请求的入口都过一遍状态机,防止回车连击。事件处理器用async void是 WinForms 里唯一被允许的场景,但「允许」不等于「不用管异常」。
6. 验收与答辩:怎么用一张检查表证明这套登录界面能交付
验收这件事,最怕的是「我看着挺好」,然后换台机器就出问题。我一般会准备一张检查表,逐项过,过完才算完:
| 验收项 | 判定标准 | 验证方法 |
|---|---|---|
| 布局适配 | 100%/125%/150% 三档缩放不错位 | 改系统缩放后重启程序 |
| 拖动与最大化 | 可拖、可双击最大化、不遮任务栏 | 手动操作一遍 |
| 输入校验 | 空值、超长、非法字符都有明确提示 | 逐条构造输入 |
| 密码存储 | 数据库里看不到明文,换盐后同密码哈希不同 | 直接查表对比两条记录 |
| 状态控制 | 连点 5 次只发 1 次请求 | 断点或日志计数 |
| 记住密码 | 换 Windows 账户后无法解密 | 换账户启动程序 |
| 异常兜底 | 断开数据源后提示友好,不闪退 | 临时改连接串 |
答辩环节,老师或评审最爱问的三个问题基本是固定的:密码怎么存的、连点怎么办、换分辨率会不会乱。这三个问题对应的就是上表的第 4、5、1 项,提前把答案准备成「做法 + 为什么这么做 + 怎么验证」三段,比背概念有用得多。如果时间允许,现场演示一次「把数据库里的密码哈希贴出来,再演示两条同密码用户的哈希不一样」,比讲十分钟原理有说服力。
还有个容易被忽略的点:提前准备一个「降级演示」。万一现场网络或数据库连不上,能一键切到本地模拟数据源把流程走完,不至于卡在登录页。这个开关平时藏起来,演示前确认一遍。
从那以后我每次交这类带界面的项目,都会在打包前把这张表从头到尾走一遍,尤其是缩放和换账户这两项——它们不会在开发机上出问题,只会在别人的机器上出问题。希望帮到你。
本文还有配套的精品资源,点击获取