首页 / 资讯中心 / 文章详情

C# WinForms年会照片抽奖源码:防重复、缩略图缓存与双屏适配

C# WinForms年会照片抽奖源码:防重复、缩略图缓存与双屏适配 ★ FEATURED ARTICLE
简介这是一套面向企业行政、活动策划人员及C#初学者的年会照片抽奖程序源码解决现场抽奖环节需要随机滚动照片、烘托气氛并记录中奖名单的实际问题。程序支持读取指定文件夹内的图片并高速随机切换按下回车键或单击鼠标即可暂停与继续抽中者照片会自动移入winner目录从而不再参与后续抽取全部抽完或名单为空时均有提示同时集成开始、中奖欢呼与结束三类音乐可随时替换音乐名称并支持F11全屏、A键查看全部中奖人员及自定义背景图。资源包共90个文件约10.7MB包含12个cs源文件、25张jpg图片素材、3段mp3音效、6个可执行程序及sln与csproj工程文件另附config配置、resources资源与resx界面定义结构完整可直接编译运行。目前已有623人学习下载适合希望快速复刻抽奖效果或在此基础上二次开发的学习者参考。1. 年会大屏前的照片抽奖C#源码能解决什么年会现场大屏正滚着照片行政临时塞过来一个U盘说“这些头像能不能直接抽”。这类公司年会照片抽奖程序 C#源码解决的就是这个场景把员工照片放进目录程序扫描成参与者池滚动展示、随机定格、按奖项去重最后把中奖名单落盘。它不是简单的“随机数图片框”现场最怕重复中奖、图片加载卡顿、双屏分辨率错位。适合谁手里有 C# 基础、要在半天内改出可用抽奖页面的开发者或者想给行政做一套可复用活动工具的技术同事。源码是 WinForms 思路控件直接拖、事件直接写不需要先搭前端工程也不依赖现场网络。你拿到后先跑通默认照片目录再换自己的奖项配置基本就能上台用。2. 技术选型与项目骨架WinForms 分层目录怎么搭2.1 为什么选 WinForms 而不是 WPF/Web年会抽奖程序的运行环境通常很“土”一台临时借来的笔记本、一个投影仪、一个照片文件夹可能还没有网络。WinForms 在这种场景下优势很直接PictureBox显示照片、Timer做滚动、Screen.AllScreens找副屏、FolderBrowserDialog选目录全是现成控件。现场改一个按钮位置拖一下就能编译不用先理解 XAML 的绑定和动画时间线。WPF 的动画确实更顺滑做缩放、淡入淡出更漂亮但代价是你要处理Dispatcher、样式模板和不同 DPI 下的布局。Web 方案看起来“跨平台”实际要起服务、配浏览器全屏、防止误触地址栏现场网络一断就尴尬。这不是说 WinForms 更先进而是交付时间只有半天时它翻车概率最低。常见做法是 WinForms 做壳业务逻辑抽到几个服务类里别把所有代码塞进Form1.cs。我一般会把项目分成四层Forms只负责按钮、标签、图片框和用户交互Services放抽奖、照片加载、结果导出Models放参与者和奖项Infrastructure放配置读取和序列化。这样二次开发时改抽奖规则不用碰界面换皮肤也不用动算法。下面这张对比表是选型时最常被问到的几个点方案现场部署动画能力改造成本适合场景WinForms拷贝文件夹即可依赖少够用靠 Timer 和图片切换低事件驱动直观单机、离线、快速交付WPF需要对应运行时强适合复杂动效中高XAML 和绑定要熟有设计资源、追求视觉效果Web 页面要起服务或打包浏览器强CSS 动画灵活中前后端都要管多端访问、远程协作真正要警惕的是“所有逻辑都写在按钮事件里”。抽奖按钮里直接new Random()、直接读文件、直接拼导出字符串第一版能跑第二天加一个“三等奖抽完再抽二等奖”就会乱。分层不是摆样子是为了让你在現場改规则时只改一个类。2.2 源码目录结构与启动链路这份 C# 源码的骨架不复杂但目录要能一眼看出东西放哪。下面是我拆这类项目时最常见的文件清单你可以对照手里的源码看是否一致路径职责二次开发时改哪里Program.cs程序入口加载配置启动主窗体换启动参数、加异常兜底Forms/MainForm.cs主界面绑定照片、按钮、Timer改布局、加奖项页签Forms/PrizeForm.cs奖项设置或中奖展示改奖项字段、加动画Models/Participant.cs参与者模型照片路径、工号、姓名加部门、座位号Models/Prize.cs奖项模型名称、人数、是否允许重复加奖品图片、抽奖顺序Services/PhotoService.cs扫描照片、生成缩略图、缓存改命名规则、加格式Services/LotteryService.cs抽奖池、随机抽取、去重改权重、改分组Services/ResultService.cs中奖结果落盘、导出改 CSV 字段、加时间戳Infrastructure/ConfigStore.cs读写config.json加配置项、做默认值Assets/Photos员工照片目录换成现场照片config.json照片目录、导出目录、奖项列表现场前改路径和奖项启动链路通常是Program.Main读配置MainForm构造时扫描照片目录PhotoService返回参与者列表LotteryService用这个列表初始化抽奖池最后Timer只负责滚动展示。这个顺序很重要照片没加载完就允许点“开始”抽奖池可能是空的现场就会出现“抽不出来”的尴尬。static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // 配置文件放在 exe 同目录现场换电脑时整包拷贝即可 var config ConfigStore.Load(config.json); // 主窗体只接收配置不在这里读照片避免启动时卡死 Application.Run(new MainForm(config)); } }这段入口代码的关键参数是STAThread和配置路径。STAThread不是可有可无的装饰文件对话框、剪贴板、部分图片解码都依赖单线程单元去掉后可能在现场选目录时直接抛异常。config.json用相对路径程序会按当前工作目录找实际项目里更稳的写法是Path.Combine(AppDomain.CurrentDomain.BaseDirectory, config.json)这样从桌面快捷方式启动也不会找错位置。构建和运行可以按下面的顺序走不要一上来就双击 exe# 先还原依赖再编译 Release最后到输出目录运行 nuget restore AnnualLottery.sln msbuild AnnualLottery.sln /p:ConfigurationRelease # 输出目录通常在 bin\Release 下把 config.json 和 Assets 一起拷过去nuget restore只在你拿到的是带包引用的解决方案时需要如果源码里没有外部包直接编译即可。/p:ConfigurationRelease是为了让现场跑的是优化后的版本别拿 Debug 输出上台异常对话框和性能都不适合大屏。编译完先检查输出目录里有没有config.json、Assets/Photos和占位图缺一个都可能在启动时弹错。2.3 运行前的环境与素材准备环境上目标框架按源码里已有的 .NET Framework 4.x 走不要手贱升到新版本再怪PictureBox行为不一致。开发机装好对应的开发工具现场机装好运行时如果现场机不能装环境就提前确认源码是否支持自带运行时或改成单文件发布。字体也要提前看抽奖大屏常用粗体大字临时换机器可能没有同款字体姓名标签会换行或截断。素材准备比代码更容易翻车。照片目录建议只放一层不要嵌套子目录文件名用“工号_姓名.jpg”这种可解析格式没有工号就统一“姓名.jpg”但要注意重名。格式优先jpg、jpeg、png、bmp聊天软件导出的特殊格式先批量转掉。下面这个配置类负责把现场参数收口改路径和奖项不用重新编译public sealed class AppConfig { // 照片目录建议用绝对路径现场换电脑时只改这一处 public string PhotoDir { get; set; } // 导出目录中奖名单和日志都写这里别写系统盘根目录 public string ExportDir { get; set; } // 奖项列表Name 是显示名Count 是该奖项人数 public ListPrize Prizes { get; set; } new ListPrize(); public static AppConfig Load(string path) { if (!File.Exists(path)) { // 首次运行给一套默认值避免直接崩溃 return new AppConfig { PhotoDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Assets, Photos), ExportDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Results), Prizes new ListPrize { new Prize { Name 三等奖, Count 5, AllowRepeat false }, new Prize { Name 二等奖, Count 3, AllowRepeat false }, new Prize { Name 一等奖, Count 1, AllowRepeat false } } }; } // 具体序列化实现按源码里的来这里只表达配置加载的边界 return ConfigStore.DeserializeAppConfig(File.ReadAllText(path)); } }这段代码里PhotoDir和ExportDir是现场最常改的两个参数。照片目录不要写死成开发机路径导出目录要提前建好并确认有写权限。Prizes里的AllowRepeat控制同一个人能否重复中奖年会大多数规则是不允许所以默认false。如果源码没有配置类而是把奖项写死在按钮里建议先抽出来不然后面加“特等奖”要改多个地方。提示正式上台前用 20 到 30 张测试照片完整跑一遍“开始滚动、停止定格、导出名单”别只测试一张照片。3. 照片加载与缩略图缓存几百张人像不卡顿的写法3.1 照片目录扫描与命名约定照片抽奖的第一个性能瓶颈不在随机算法而在图片加载。几百张手机照片动辄两三兆如果启动时在 UI 线程里逐张Image.FromFile窗口会白屏好几秒现场主持人会以为程序挂了。正确的做法是先只扫描文件路径和姓名把缩略图加载放到后台并且做缓存。扫描阶段不碰像素数据速度很快。命名约定要提前和行政对齐。最常见的是“工号_姓名.jpg”解析时取第一个下划线后面的部分作为显示名没有下划线就把整个文件名当姓名。遇到“张三(1).jpg”这种重复文件最好在扫描时记录日志别让两个人同名同姓在台上分不清。下面这段扫描代码只做三件事过滤扩展名、解析显示名、按姓名排序public sealed class PhotoService { private readonly string[] _allowedExts { .jpg, .jpeg, .png, .bmp }; public ListParticipant Scan(string photoDir) { var list new ListParticipant(); if (!Directory.Exists(photoDir)) { throw new DirectoryNotFoundException($照片目录不存在{photoDir}); } // 只扫顶层避免把缩略图缓存目录或子文件夹里的旧照片带进来 foreach (var file in Directory.EnumerateFiles(photoDir, *.*, SearchOption.TopDirectoryOnly)) { var ext Path.GetExtension(file).ToLowerInvariant(); if (!_allowedExts.Contains(ext)) continue; var fileName Path.GetFileNameWithoutExtension(file); var displayName ParseDisplayName(fileName); if (string.IsNullOrWhiteSpace(displayName)) continue; list.Add(new Participant { Id fileName, DisplayName displayName, PhotoPath file }); } // 排序后滚动展示更稳定也方便现场核对人数 return list.OrderBy(p p.DisplayName, StringComparer.CurrentCulture).ToList(); } private string ParseDisplayName(string fileName) { // 约定工号_姓名没有下划线就把整个文件名当姓名 var index fileName.IndexOf(_); return index 0 index fileName.Length - 1 ? fileName.Substring(index 1) : fileName; } }SearchOption.TopDirectoryOnly是有意为之现场照片目录经常混进“已中奖”子文件夹或备份目录递归扫描会把重复人员带进池子。_allowedExts用白名单而不是黑名单系统缩略图文件、聊天软件缓存图、特殊格式都会被挡掉。ParseDisplayName的下标判断是为了防止文件名以“_”结尾时取到空字符串。扫描结果按当前文化排序中文姓名不会出现乱序现场核对人数时更直观。如果照片量大扫描本身仍然可能让窗口停顿可以在MainForm.Load里先显示“正在扫描照片”再用Task.Run包一层。常见做法是扫描只返回路径缩略图在后台逐张补界面先显示姓名占位不要求一次把所有头像都画出来。3.2 异步加载与缩略图缓存代码缩略图加载有两个坑一是直接Image.FromFile会锁住原文件现场想换一张照片会提示“文件被占用”二是每次滚动都重新解码原图几百张轮播时 CPU 直接拉满。解决方式是用FileStream以FileShare.Read打开解码后生成固定尺寸的Bitmap再放进内存缓存。缓存键用照片路径抽奖时只取缓存不碰磁盘。public async TaskBitmap LoadThumbnailAsync(string path, int width, int height, CancellationToken token) { return await Task.Run(() { // FileShare.Read 允许其他程序同时读避免锁死原照片 using (var fs new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var source Image.FromStream(fs)) { var bmp new Bitmap(width, height); using (var g Graphics.FromImage(bmp)) { g.InterpolationMode InterpolationMode.HighQualityBicubic; g.PixelOffsetMode PixelOffsetMode.HighQuality; g.SmoothingMode SmoothingMode.HighQuality; // Max 表示填充裁切保证头像框铺满要完整显示改成 Min var scale Math.Max((float)width / source.Width, (float)height / source.Height); var drawWidth (int)(source.Width * scale); var drawHeight (int)(source.Height * scale); // 居中绘制横图和竖图都不会偏到一边 g.DrawImage(source, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight); } return bmp; } }, token); }这段代码的参数width、height是缩略图目标尺寸建议按头像框实际像素给比如 240×320 或 300×300不要先给 1000 再让控件缩内存会白涨。scale用Math.Max是“填充”模式适合人像占满框如果照片里有半身像不想裁头改成Math.Min并给背景色。FileShare.Read是血泪经验现场换照片时不会因为程序开着就复制不进去。CancellationToken用于窗口关闭或重新扫描时取消后台任务避免退出后还在解码。缓存建议用ConcurrentDictionarystring, Bitmap键是完整路径值是缩略图。加载时先查缓存没有再异步加载加载失败不要抛到 UI 线程记录路径后显示占位图。下面这段是缓存调用的骨架private readonly ConcurrentDictionarystring, Bitmap _thumbCache new ConcurrentDictionarystring, Bitmap(); public async TaskBitmap GetThumbnailAsync(Participant p, int w, int h, CancellationToken token) { if (_thumbCache.TryGetValue(p.PhotoPath, out var cached)) return cached; try { var bmp await LoadThumbnailAsync(p.PhotoPath, w, h, token); _thumbCache[p.PhotoPath] bmp; return bmp; } catch (Exception ex) { // 单张失败不影响整场记录后返回占位图 LogHelper.Write($加载照片失败{p.PhotoPath}{ex.Message}); return _placeholder; } }TryGetValue先查缓存避免重复解码_thumbCache[p.PhotoPath] bmp在单线程 UI 场景够用如果多线程同时加载改用AddOrUpdate。_placeholder是提前加载的占位图尺寸和缩略图一致失败时界面不会出现红叉。日志里写完整路径现场排查时能直接定位是哪张照片坏了不要只写“加载失败”。3.3 头像框比例与占位图处理照片比例是现场最容易忽略的细节。员工自拍有竖图、合影有横图、证件照有白边如果头像框固定 1:1横图会被裁掉两边竖图会裁掉头顶。比较稳的做法是头像框统一 3:4代码里用填充裁切并且在框内加一层半透明遮罩让姓名标签始终可读。要是行政坚持不裁人脸就改成完整显示左右留背景色视觉上不如填充饱满但不会把人的头切掉。占位图不要用纯色块最好是一张写着“照片加载中”或“暂无照片”的灰色图尺寸和缩略图一致。加载失败时直接换占位图不要弹 MessageBox现场弹窗会挡住大屏。图片格式支持上jpg和png最稳bmp文件大但兼容性好特殊格式提前批量转换。下面这张表可以作为素材检查清单检查项建议出问题时的表现文件格式jpg/jpeg/png/bmp红叉、空白、解码异常文件命名工号_姓名 或 姓名姓名显示成文件名、重名照片比例竖图优先人脸居中裁切后看不到完整人脸文件大小单张控制在 2MB 内首次加载慢、滚动卡顿目录层级只放一层重复人员、旧照片混入注意正式抽奖前把所有照片跑一遍加载坏图当场换掉不要等到台上滚到那一张才发现是红叉。4. 抽奖核心链路随机算法、去重与滚动动画4.1 奖项配置与参与者池抽奖程序的业务核心不是“随机”而是“池子”和“规则”。参与者池初始化时把所有照片对应的人放进去每抽中一个就从池子里移除天然避免重复中奖。如果业务允许同一个人中多个奖项就不能物理移除而是维护一个全局中奖集合抽之前过滤。年会大多数规则是“一人一奖”所以物理移除最简单也最不容易出错。奖项配置至少要有名称、人数、是否允许重复、抽取顺序。下面这两个模型类是后面所有逻辑的基础public sealed class Participant { public string Id { get; set; } // 工号或文件名用于去重 public string DisplayName { get; set; } // 大屏显示姓名 public string PhotoPath { get; set; } // 原图路径 } public sealed class Prize { public string Name { get; set; } // 奖项名称如“三等奖” public int Count { get; set; } // 该奖项人数 public bool AllowRepeat { get; set; } // 是否允许已中奖者再抽 }Id不要用显示姓名重名时会把两个人当成一个人。Count是计划人数实际抽取时还要受池子剩余人数限制池子空了要提示“没有可抽人员”不能继续Next。AllowRepeat为false时抽中后从池子移除为true时保留池子但用集合记录已中奖展示时加标记。常见做法是默认false特等奖需要“全场重新抽”时才临时改配置。4.2 防重复的随机抽取代码随机数本身不是玄学重复中奖多半是池子没移除或者每次点击都new Random()导致同一毫秒种子相同。正确写法是Random只创建一次抽取时加锁抽中后立即从池子删除。下面这段LotteryService把池子和随机源都收在类里外部只调用DrawOnepublic sealed class LotteryService { private readonly ListParticipant _pool; private readonly Random _random new Random(Guid.NewGuid().GetHashCode()); private readonly object _sync new object(); public LotteryService(IEnumerableParticipant all) { _pool all.ToList(); } public Participant DrawOne() { lock (_sync) { if (_pool.Count 0) return null; var index _random.Next(_pool.Count); var winner _pool[index]; // 中奖即移出池天然防重复允许重复的奖项不要走这个方法 _pool.RemoveAt(index); return winner; } } public int Remaining _pool.Count; }_random用Guid.NewGuid().GetHashCode()做种子是为了避免程序在同一秒重启时产生相同序列虽然概率低但现场重启一次就中招会很尴尬。lock防止 Timer 重入或用户连点导致两个线程同时修改_pool。RemoveAt是 O(n)几百人规模完全够用不值得为这点性能引入链表。Remaining暴露剩余人数界面可以显示“剩余 128 人”行政心里有数。如果奖项允许重复不要直接改DrawOne而是另写一个DrawOneAllowRepeat用HashSetstring _wonIds记录已中奖抽之前过滤。这样两条规则互不影响二次开发时不会把“一人一奖”的场次改坏。4.3 Timer 滚动与定格逻辑滚动动画的本质是快速切换图片不是每次 Tick 都抽一次奖。正确顺序是点“开始”启动TimerTick 里按顺序轮流显示照片点“停止”先停Timer再调用DrawOne抽一个人最后把中奖者照片和姓名定格。这样照片和姓名一定对应同一个人不会出现“照片是张三、名字是李四”的翻车现场。private int _rollIndex; private void timerRoll_Tick(object sender, EventArgs e) { if (_participants.Count 0) return; // 只做展示轮播不在这里抽奖 var p _participants[_rollIndex % _participants.Count]; picCurrent.Image _photoService.GetCachedThumbnail(p.PhotoPath); lblName.Text p.DisplayName; } private void btnStop_Click(object sender, EventArgs e) { timerRoll.Stop(); btnStop.Enabled false; // 防止连点导致重复抽奖 var winner _lottery.DrawOne(); if (winner null) { lblName.Text 没有可抽人员; btnStop.Enabled true; return; } ShowWinner(winner); // 绑定同一张照片和同一个姓名 _resultService.Append(winner, _currentPrize); btnStop.Enabled true; }Interval建议 50 到 80 毫秒太快人眼看不清太慢像卡顿。_rollIndex取模保证循环照片列表为空时直接返回。btnStop.Enabled false是防连点的关键现场主持人一紧张会连按两下不加这行就可能一次抽两个人。ShowWinner里只接收winner一个对象照片和姓名都从它取避免两个数据源不同步。Append放在定格之后先让观众看到结果再落盘顺序不影响数据但心理上更稳。4.4 结果落盘与导出结果落盘要按“每抽一次写一次”来做不要等全场结束再统一保存。现场断电、误关窗口、程序崩溃都可能发生内存里的中奖名单丢了就是黑匣子。最简单的做法是维护一个ListResultRow每次Append后立即序列化到results.json导出 CSV 时再读这个列表加 BOM 防止表格软件打开乱码。public sealed class ResultService { private readonly string _jsonPath; private readonly ListResultRow _rows new ListResultRow(); public ResultService(string exportDir) { Directory.CreateDirectory(exportDir); _jsonPath Path.Combine(exportDir, results.json); } public void Append(Participant p, Prize prize) { _rows.Add(new ResultRow { Time DateTime.Now, PrizeName prize.Name, ParticipantId p.Id, DisplayName p.DisplayName }); // 每抽一次就落盘现场断电也不丢已出结果 File.WriteAllText(_jsonPath, JsonStore.Serialize(_rows)); } public void ExportCsv(string csvPath) { var lines new Liststring { 时间,奖项,工号,姓名 }; lines.AddRange(_rows.Select(r ${r.Time:yyyy-MM-dd HH:mm:ss},{r.PrizeName},{r.ParticipantId},{r.DisplayName})); // UTF-8 带 BOM表格软件打开中文不乱码 File.WriteAllLines(csvPath, lines, new UTF8Encoding(true)); } }exportDir由配置传入构造函数里先CreateDirectory避免现场没有目录导致写失败。Append里写 JSON 是覆盖写数据量小没问题如果担心写入中断可以先写临时文件再替换。ExportCsv的UTF8Encoding(true)是后悔药不带 BOM 的 CSV 在部分表格软件里会把中文显示成乱码。导出字段不要带照片绝对路径名单发给行政时路径没有意义还泄露本机目录结构。提示抽奖前先在导出目录放一个空文件测试写权限现场机经常把结果目录设在只读位置。5. 现场联调避坑双屏、权限、字体和照片格式5.1 双屏投射与分辨率适配年会现场最常见的是笔记本接投影或大屏程序要显示在副屏控制界面留在主屏。WinForms 里用Screen.AllScreens找非主屏把抽奖窗体设为无边框、最大化、手动定位到副屏。不要用固定坐标1920,0不同机器主副屏排列不一样插拔投影后坐标会变。下面这段代码在窗体加载后调用找不到副屏就回退主屏private void MoveToSecondScreen(Form form) { if (Screen.AllScreens.Length 2) return; var second Screen.AllScreens.FirstOrDefault(s !s.Primary); if (second null) return; form.StartPosition FormStartPosition.Manual; form.FormBorderStyle FormBorderStyle.None; form.WindowState FormWindowState.Maximized; form.Bounds second.Bounds; }!s.Primary取副屏Bounds用副屏完整区域FormBorderStyle.None去掉标题栏避免投影上出现关闭按钮。StartPosition.Manual必须在设置Bounds前给否则窗体可能被系统重新居中。如果现场是复制屏幕而不是扩展屏幕AllScreens.Length只有 1代码会回退主屏这是正常行为提前和行政确认显示模式即可。分辨率适配靠AutoScaleMode.Dpi和控件的Anchor、Dock不要把所有位置写死。大屏字体用系统自带粗体提前在目标机器上看一遍缺字体时姓名可能换行。照片框用SizeMode Zoom或自己绘制的填充模式避免拉伸变形。现场联调时按“控制屏点开始、大屏看滚动、停止后看定格、导出名单”走一遍别只在开发机上点。5.2 五条踩坑记录现象、原因、解决第一条现象是抽奖时照片显示红叉或空白。原因通常是原图被其他程序占用或者Image.FromFile加载后没有释放现场想替换同名照片时复制失败。解决方式是用FileStream加FileShare.Read读取加载失败返回占位图并把失败路径写日志。不要用Application.DoEvents硬刷新那会让界面更卡。第二条现象是同一个人连续中两个奖。原因多半是只按当前奖项去重没有维护全局中奖集合或者抽中后没有从池子里移除。解决方式是“一人一奖”场次直接RemoveAt允许重复的场次用HashSetstring记录Participant.Id抽之前过滤。注意去重键用工号或文件名不要用显示姓名重名时会误伤。第三条现象是点击“停止”后照片和姓名不是同一个人。原因是 Tick 里更新了界面停止时又用另一个随机源抽奖两个数据源各走各的。解决方式是停止时先timerRoll.Stop()再DrawOne()拿到winner后照片和姓名都从这个对象取。滚动阶段只做展示不产生中奖结果链路就唯一了。第四条现象是投影后界面只显示一半或者按钮跑到屏幕外。原因是窗体尺寸写死、DPI 缩放不一致或者副屏分辨率比开发机小。解决方式是布局用Dock和Anchor窗体启动后按Screen.Bounds重新定位必要时用AutoScaleMode.Dpi。现场提前把投影接上跑一遍不要等开场前五分钟才插线。第五条现象是中奖名单导出后中文乱码或者照片目录里混入系统缩略图文件导致启动报错。原因是 CSV 用了无 BOM 的 UTF-8照片扫描没有过滤扩展名。解决方式是new UTF8Encoding(true)写 CSV照片扫描用白名单.jpg/.jpeg/.png/.bmp单张加载失败只记日志不阻断整场。导出前先打开一次文件确认列名和中文正常别直接发给行政。6. 二次开发把抽奖程序改成可复用活动工具6.1 从硬编码到配置化第一版能跑之后最值得做的改造是把“照片目录、奖项、导出路径、是否允许重复”全部收进config.json。这样下一次活动不用重新编译行政自己改奖项人数就能用。进一步可以加一个IActivityMode接口把“照片抽奖”和“名单抽签”分开实现主窗体只依赖接口。下面这个小接口就是扩展点public interface IDrawMode { string Name { get; } Participant DrawOne(); void Reset(IEnumerableParticipant participants); }Name给界面显示模式名DrawOne返回中奖者Reset用于换场次或重新开始。照片抽奖实现里维护池子名单抽签实现里可以按部门权重抽。主窗体只调接口不加if (mode 照片)这种分支后面加“答题抽奖”也不用改 UI。6.2 验证方法与扩展点改完配置化后我一般用 30 张测试照片跑三轮第一轮抽完检查有没有重复第二轮中途关掉程序再打开看结果文件是否还在第三轮把导出 CSV 用表格软件打开看中文和列顺序。双屏验证不能省必须接一次副屏看定格位置照片验证要故意放一张坏图确认程序显示占位图而不是崩溃。扩展点上优先加“中奖者上台确认”按钮和“撤销上一次抽奖”现场主持人手滑时比重新跑程序体面。从那以后我每次接年会现场需求都强制先在副屏跑一遍、拔一次网线、再抽三轮空奖确认池子空了会提示而不是抛异常。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站