基于C#的理发会员管理系统:数据库设计与实现全解析 简介这套资源是使用C#语言开发的理发会员管理系统包含完整源码和数据库设计文档面向计算机、软件工程、通信工程等专业学生课程设计及理发店日常运营。系统涵盖会员档案、服务预约、消费记录、积分兑换、报表统计、权限控制等核心功能配有数据库表结构说明清晰展示会员表、预约表、消费记录表的关联关系帮助学习者掌握C#窗体应用与数据库交互的完整流程。压缩包共二百一十个文件以源代码、界面资源、可执行程序为主另含解决方案、数据库文件及设计文档大小约二点六三兆字节目录结构按功能模块划分。已有二百八十一人学习下载适合需要完成课程设计或了解会员系统架构的开发者参考项目文件命名体现版本控制习惯也有助于体会代码协作与维护。1. 基于C#的理发会员管理系统别再用纸本和Excel管会员了这套含数据库文档的方案能直接改很多开理发店的朋友找到我第一句话都是“有没有那种简单的会员管理软件”。打开他们电脑一看会员信息全在Excel里充值和扣费靠聊天记录月底对账对到怀疑人生。基于C#的理发会员管理系统就是解决这个问题的一个WinForms桌面端配上数据库设计文档自己就能改、能加功能、能随时导出数据不用被第三方SaaS的年费绑住。这篇我会把表结构怎么设计、C#端怎么连库、充值和消费的事务怎么写以及最容易翻车的几个坑一次说透。适合有C#基础的开发者也适合想找人定制这套系统的门店管理者。2. 理发会员系统的模块拆解四个业务对象和一套三层结构先想清楚再写代码2.1 会员、充值、消费、服务项目理发店真正需要管的是这四张表理发会员管理系统看起来功能不多但业务对象必须提前理清。根据我做过门店系统的经验绝大多数理发店真正要管的只有四类东西会员档案、储值流水、消费流水、服务项目。会员档案解决“店里有多少人、谁快到期了、余额还有多少”的问题储值流水管充值和赠送消费流水管每一次剪发、染发、护理扣了多少钱服务项目管理价格表让前台点选项目自动带出金额。把这四个对象映射到数据库就是四张核心表Member会员主表、RechargeRecord充值记录表、ConsumeRecord消费记录表、ServiceItem服务项目表。再往外延伸还有操作员表Operator和会员等级表MembershipLevel。很多新手会把所有字段塞进一张会员表里用几列存“充值历史”“消费历史”这是典型的错误设计。历史数据必须用流水表存会员主表只存当前余额和累计值否则查某天充了多少钱会非常痛苦。我一般还会在数据库设计文档里补一张数据字典表逐字段说明类型、长度、默认值、是否可空。这张表在项目交付时价值极大因为后期改需求时照着字典改表和代码比翻代码猜字段快得多。2.2 C#技术选型WinForms SQL Server体系为什么这么选只给了一个“基于C#”但C#能做的东西太多了ASP.NET Core Web API、WPF、WinForms、MAUI、控制台程序。理发店会员管理这种场景选型标准就三条部署简单、开发效率高、门店电脑带得动。常见做法是WinForms SQL Server LocalDB或者WinForms SQLite。我用下面这张表说明选择的思路参数这东西没有绝对好坏匹配场景才最重要方案部署难度多门店联网适合场景WinForms SQLite最低单文件数据库不支持单店、数据量小WinForms SQL Server LocalDB低需装运行时单机支持单店、数据量大一点WPF SQL Server中UI更现代支持局域网想做好看界面的单店ASP.NET Core Web较高需服务器天然支持多门店连锁我的选择是WinForms SQL Server LocalDB。理由很直接LocalDB是轻量级SQL Server实例不需要装完整数据库服务C#开发者最熟悉的SqlConnection、SqlCommand全都能用事务也完整支持。理发店前台要的就是双击exe启动就用金蝶云那种网页套壳对普通店员反而有学习成本。WinForms虽然界面老气一点但DataGridView绑数据、按钮事件写业务这套路太成熟了网上示例一堆踩坑成本也低。WPF做动画和绑定确实漂亮但店里要的是稳定不是界面炫技。2.3 项目结构怎么摆UI、业务、数据三层别把SQL全塞进按钮事件这是这个项目里最容易被忽略、但后期影响最大的一步。很多培训班项目把数据库连接直接写在Form的按钮点击事件里比如双击“充值”按钮里面new一个SqlConnection然后ExecuteNonQuery。小Demo没问题但如果后面要加“会员到期提醒”“营业统计报表”这些代码会越来越乱最后变成一个没人敢动的类。我一般会把项目拆成三个文件夹Models存放实体类比如Member.cs、RechargeRecord.cs字段和数据库表一一对应。DALData Access Layer专门放SQL语句提供AddMember、GetMemberByPhone、CreateRechargeRecord这类方法返回值是实体对象或DataTable。UI界面层只负责用户操作调用DAL层方法把结果显示到控件上不允许出现裸SQL字符串。这样做最大的好处是如果某天从LocalDB换成MySQL只需要改DAL层界面和实体类一行不用动。我见过太多小店系统因为升级数据库几乎重写一遍代码分层就是你的后悔药而且完全免费。3. 数据库设计文档的落地从ER图到可执行的建表脚本每张表都给你讲清楚3.1 会员主表设计谁做主键、哪些字段必须建索引会员主表是整套系统的根。第一版设计我通常会给出以下字段结构这也是数据库设计文档里数据字典的典型写法字段名类型允许空默认值说明MemberIdINT IDENTITY(1,1)否自增主键MemberNoNVARCHAR(20)否无会员卡号加唯一索引MemberNameNVARCHAR(50)否无会员姓名PhoneNVARCHAR(11)否无手机号加唯一索引GenderTINYINT是00未知 1男 2女BirthdayDATE是NULL用于生日提醒BalanceDECIMAL(10,2)否0当前余额TotalRechargeDECIMAL(10,2)否0累计充值金额PointsINT否0积分LevelIdINT否1所属会员等级CreateTimeDATETIME否GETDATE()办卡时间ExpireTimeDATETIME是NULL到期时间空为永久StatusTINYINT否11正常 0停用RemarkNVARCHAR(200)是NULL备注这里有一个很重要的设计决策主键用自增int不要用手机号或会员卡号做物理主键。原因我在第5章会细说但建表时就要埋下这个正确的钉子。Balance当前余额必须用DECIMAL(10,2)不能用float。float是近似值存金额会出现0.10.20.30000000000000004这种问题做账会出事。另一个关键索引是Phone。前台办理业务时输入手机号是最高频的操作没有索引数据量一大就开始卡顿。MemberNo同理办卡时会按卡号查询。索引不是越多越好但这两列是必加的唯一索引。3.2 流水表设计储值记录和消费记录为什么要双写会员充值和消费不能只改会员表里的Balance字段必须同时往流水表里写一条记录。这是整个系统的账本逻辑Balance是“当前结果”流水是“发生过程”。月底对账时把流水聚合出来的数跟会员表Balance比对如果对不上就说明哪里出Bug了。充值记录表RechargeRecord的字段设计字段名类型说明RechargeIdINT IDENTITY主键MemberIdINT关联Member表AmountDECIMAL(10,2)实收金额BonusAmountDECIMAL(10,2)赠送金额默认0PayTypeTINYINT1现金 2微信 3支付宝BalanceAfterDECIMAL(10,2)操作后余额OperatorIdINT操作员OperateTimeDATETIME操作时间默认GETDATE()注意BalanceAfter字段。很多初学者不屑于存这个字段觉得“用当前余额减去金额就能算出来”。但实际操作时门店开卡充值会有赠送、会有跨天退款、会有手工调整单靠事后推算很难还原当时的账户状态。把每次操作后的余额直接落库对账时一目了然。消费记录表ConsumeRecord类似ConsumeId、MemberId、ItemId服务项目、Amount应收金额、DiscountAmount折扣减免、RealAmount实收、BalanceAfter消费后余额、OperatorId、OperateTime。两张流水表一组合你就能回答“这个月哪个项目卖得最好”“谁充值后一直没来消费”这类老板最爱问的问题。3.3 建表SQL与初始化数据可直接执行的T-SQL和参数说明下面这段T-SQL是整套系统的核心建表脚本包含了会员表、充值流水表、消费流水表、服务项目表以及基础数据。在SQL Server Management Studio里连接LocalDB后对master库执行即可。-- 理发会员管理系统核心建表脚本 -- 适用于 SQL Server LocalDB / Express CREATE DATABASE BarberShop; GO USE BarberShop; GO -- 会员等级表 CREATE TABLE MembershipLevel ( LevelId INT IDENTITY(1,1) PRIMARY KEY, LevelName NVARCHAR(20) NOT NULL, -- 等级名称普通会员/金牌会员 DiscountRate DECIMAL(3,2) NOT NULL DEFAULT 1.00 -- 折扣率0.85表示85折 ); GO -- 会员主表 CREATE TABLE Member ( MemberId INT IDENTITY(1,1) PRIMARY KEY, MemberNo NVARCHAR(20) NOT NULL UNIQUE, -- 会员卡号唯一 Phone NVARCHAR(11) NOT NULL UNIQUE, -- 手机号唯一索引 MemberName NVARCHAR(50) NOT NULL, Gender TINYINT NOT NULL DEFAULT 0, -- 0未知 1男 2女 Birthday DATE NULL, Balance DECIMAL(10,2) NOT NULL DEFAULT 0, -- 当前余额 TotalRecharge DECIMAL(10,2) NOT NULL DEFAULT 0, Points INT NOT NULL DEFAULT 0, LevelId INT NOT NULL DEFAULT 1, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), ExpireTime DATETIME NULL, -- 到期时间 Status TINYINT NOT NULL DEFAULT 1, -- 1正常 0停用 Remark NVARCHAR(200) NULL ); GO -- 充值流水表 CREATE TABLE RechargeRecord ( RechargeId INT IDENTITY(1,1) PRIMARY KEY, MemberId INT NOT NULL, Amount DECIMAL(10,2) NOT NULL, -- 实收金额 BonusAmount DECIMAL(10,2) NOT NULL DEFAULT 0, -- 赠送金额 PayType TINYINT NOT NULL DEFAULT 1, -- 1现金 2微信 3支付宝 BalanceAfter DECIMAL(10,2) NOT NULL, -- 充值后余额 OperatorId INT NOT NULL DEFAULT 1, OperateTime DATETIME NOT NULL DEFAULT GETDATE() ); GO -- 消费流水表 CREATE TABLE ConsumeRecord ( ConsumeId INT IDENTITY(1,1) PRIMARY KEY, MemberId INT NOT NULL, ItemId INT NULL, -- 服务项目ID可空表示手工调整 RealAmount DECIMAL(10,2) NOT NULL, -- 实际扣费金额 BalanceAfter DECIMAL(10,2) NOT NULL, -- 消费后余额 OperatorId INT NOT NULL DEFAULT 1, OperateTime DATETIME NOT NULL DEFAULT GETDATE() ); GO -- 初始化服务项目 INSERT INTO ServiceItem (ItemName, Price) VALUES (N洗剪吹, 45.00), (N染发, 280.00), (N烫发, 380.00), (N头皮护理, 128.00); GO参数说明DECIMAL(10,2)能存到10位总长度、2位小数也就是最大不超过9位整数金额门店场景绰绰有余Status字段主要用于冻结异常会员而不是删除数据史数据留着随时可追溯。服务项目如果要频繁改价格可以考虑加一张PriceHistory表记录调价历史不做也不是致命伤。需要说明的是上面代码块中ServiceItem表的建表语句未包含在第3.3节中实际项目里应补上。这里为了代码块完整实际建表时应将服务项目表的结构一并落库其字段为ItemId主键、ItemName、Price、Duration可选时长。4. 用C#把数据库跑起来连接、会员注册、充值扣费的最小可运行代码4.1 DbHelper与连接串五分钟把界面接到本地数据库数据库设计文档再漂亮C#代码连不上就是废纸。第一步是搞定连接串。我用的是SQL Server LocalDBApp.config里的连接串写法如下?xml version1.0 encodingutf-8? configuration connectionStrings add nameBarberShopDb connectionStringServer(localdb)\MSSQLLocalDB;DatabaseBarberShop; Integrated SecurityTrue;MultipleActiveResultSetsTrue providerNameSystem.Data.SqlClient / /connectionStrings /configuration参数说明(localdb)\MSSQLLocalDB是Visual Studio自带的LocalDB实例名不用额外配置MultipleActiveResultSetsTrue是很多人忽略的关键参数它允许同一个连接上并发执行多个DataReader否则在循环读数据时再执行另一条SQL会报“连接已打开”的错误。接着是DbHelper。我习惯写一个极简版本只包含两个方法执行非查询语句和返回DataTable。别看它简单项目里所有DAL方法都基于它写好了后面省很多事。using System; using System.Data; using System.Data.SqlClient; using System.Configuration; /// summary /// 数据库访问助手只做两件事——执行SQL返回受影响行数或执行查询返回DataTable /// /summary public class DbHelper { // 从App.config读取连接串项目所有地方复用这一个方法 private static string ConnString ConfigurationManager.ConnectionStrings[BarberShopDb].ConnectionString; /// summary /// 执行INSERT/UPDATE/DELETE返回受影响行数 /// /summary public static int ExecuteNonQuery(string sql, SqlParameter[] parameters null) { using (SqlConnection conn new SqlConnection(ConnString)) { using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } } /// summary /// 执行SELECT返回结果集 /// /summary public static DataTable ExecuteQuery(string sql, SqlParameter[] parameters null) { using (SqlConnection conn new SqlConnection(ConnString)) { using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); using (SqlDataAdapter da new SqlDataAdapter(cmd)) { DataTable dt new DataTable(); da.Fill(dt); return dt; } } } } }逻辑说明这段代码的核心思路是一个项目只保留一个连接入口所有DAL方法都走DbHelper而不是每个按钮事件里new连接。using语句保证连接和数据适配器在使用后被自动释放不需要手动conn.Close()。SqlParameter数组用于参数化查询是防止SQL注入的关键也是让日期、金额字段类型不混乱的保证——给参数指定SqlDbType数据库就不需要做隐式转换。4.2 会员注册、充值、消费三个核心方法事务和参数化一条都不能省下面是这个系统的三个核心业务方法均写在DAL层。它们对应理发店每天重复最多的动作开卡、充值、扣费。我把它们合成一个MemberService类using System; using System.Data; using System.Data.SqlClient; public class MemberService { /// summary /// 新增会员返回新会员的MemberId /// /summary public int AddMember(Member m) { string sql INSERT INTO Member (MemberNo, Phone, MemberName, Gender, Birthday, LevelId) VALUES (MemberNo, Phone, MemberName, Gender, Birthday, LevelId); SELECT CAST(SCOPE_IDENTITY() AS INT);; SqlParameter[] ps { new SqlParameter(MemberNo, m.MemberNo), new SqlParameter(Phone, m.Phone), new SqlParameter(MemberName, m.MemberName), new SqlParameter(Gender, m.Gender), new SqlParameter(Birthday, (object)m.Birthday ?? DBNull.Value), new SqlParameter(LevelId, m.LevelId) }; // 执行查询并取回自增主键 DataTable dt DbHelper.ExecuteQuery(sql, ps); return Convert.ToInt32(dt.Rows[0][0]); } /// summary /// 充值更新余额 写充值流水必须在一个事务里 /// /summary public bool Recharge(int memberId, decimal amount, decimal bonus, int payType, int operatorId) { using (SqlConnection conn new SqlConnection(DbHelper.ConnString)) { conn.Open(); SqlTransaction tran conn.BeginTransaction(); try { SqlCommand cmd new SqlCommand(); cmd.Connection conn; cmd.Transaction tran; // 第一步加余额原子更新防止并发覆盖 cmd.CommandText UPDATE Member SET Balance Balance Amount Bonus, TotalRecharge TotalRecharge Amount WHERE MemberId MemberId;; cmd.Parameters.Clear(); cmd.Parameters.AddWithValue(Amount, amount); cmd.Parameters.AddWithValue(Bonus, bonus); cmd.Parameters.AddWithValue(MemberId, memberId); cmd.ExecuteNonQuery(); // 第二步写充值流水 cmd.CommandText INSERT INTO RechargeRecord (MemberId, Amount, BonusAmount, PayType, BalanceAfter, OperatorId) VALUES (MemberId, Amount, Bonus, PayType, (SELECT Balance FROM Member WHERE MemberId MemberId), OperatorId);; cmd.Parameters.Clear(); cmd.Parameters.AddWithValue(MemberId, memberId); cmd.Parameters.AddWithValue(Amount, amount); cmd.Parameters.AddWithValue(Bonus, bonus); cmd.Parameters.AddWithValue(PayType, payType); cmd.Parameters.AddWithValue(OperatorId, operatorId); cmd.ExecuteNonQuery(); tran.Commit(); return true; } catch { tran.Rollback(); return false; } } } }逻辑说明Recharge方法里我用了Balance Balance Amount而不是先查出来再加回去这是避免并发覆盖的关键写法。如果在C#里先SELECT出来在内存里加上金额再UPDATE回去两次操作之间如果另一个窗口也充了值后提交的会把先提交的覆盖掉。事务则保证“加余额”和“写流水”要么都成功要么都失败不会出现钱到账了流水没记的问题。AddMember返回自增主键用的是SCOPE_IDENTITY()这是SQL Server在同一个会话里拿到刚插入的主键最稳妥的方式比IDENTITY更安全因为后者可能被触发器返回的其他自增值干扰。消费扣费的代码原理跟充值一样只是余额方向相反且要加一个余额检查条件UPDATE Member SET Balance Balance - RealAmount WHERE MemberId MemberId AND Balance RealAmount;这条SQL执行后受影响行数为0表示余额不足或会员不存在C#端拿这个返回值提示用户。这种写法把并发安全的判断下放到数据库做比在C#里先判断余额再更新要可靠得多。4.3 列表界面绑定DataGridView BindingSource的正确用法最后是界面层操作。会员列表查询和展示用DataGridView但直接给DataGridView.DataSource赋DataTable会在刷新时出现界面闪烁、滚动位置丢失。我推荐用BindingSource做中间层// 在窗体初始化时创建一次BindingSource private BindingSource memberBindingSource new BindingSource(); // 加载列表方法查询所有会员并绑定 private void LoadMemberList(string keyword ) { // 用参数化查询避免SQL注入keyword是手机号或姓名模糊搜索 string sql SELECT MemberId, MemberNo, MemberName, Phone, Balance, Points, Status, CreateTime FROM Member WHERE (Keyword OR MemberName LIKE Keyword OR Phone LIKE Keyword) ORDER BY MemberId DESC;; SqlParameter[] ps { new SqlParameter(Keyword, % keyword %) }; DataTable dt DbHelper.ExecuteQuery(sql, ps); memberBindingSource.DataSource dt; dataGridView1.DataSource memberBindingSource; // 隐藏不展示的列 dataGridView1.Columns[MemberId].Visible false; }参数说明Keyword 的处理方式是为了避免拼接字符串的麻烦传空字符串时条件自动放宽。界面操作完毕需要刷新时调用memberBindingSource.ResetBindings(false)就能让DataGridView重新读取数据并且能保持当前的滚动位置和选中状态。工作多年的人可能直接重新赋值dataGridView1.DataSource dt那样会丢掉用户正在查看的行体验上就像界面“跳了一下”。5. 理发会员系统的5条避坑记录主键、并发、日期、路径、刷新全踩过5.1 手机号当主键改手机号时全是泪现象第一版图省事直接拿Phone字段当主键后来客户要求支持会员修改手机号充值流水表里又存了Phone作为关联字段一改手机号历史流水全部对不上。原因手机号是可变的业务数据不是稳定的自然主键。而且手机号录入经常带空格、带横线唯一性约束很容易误伤。解决主键用自增的MemberId。Phone字段保留唯一索引但只是业务查询条件不作为外键关联依据。所有流水表通过MemberId关联会员。数据库设计文档里从第一张表开始就要确立这个规则改起来成本最低也最值得。5.2 充值消费并发导致余额对不上事务和原子更新的翻车现场现象两个前台同时给同一个会员操作一个充值一个消费最后余额跟两张流水表算出来的总数不一致。老板觉得系统“自己会吞钱”。原因这是典型的读-改-写并发问题。C#端先SELECT余额内存里算好新余额再UPDATE回数据库。两个操作交叉执行时后提交的写操作覆盖了先提交的结果。解决一是要写事务充值、扣费和写流水是一个原子操作二是所有涉及余额变更的SQL都用UPDATE Member SET Balance Balance Amount这种原子表达式让数据库锁行别在应用层读出来再加回去。加一个WHERE Balance 消费金额还能顺带解决余额不足判断。5.3 日期字段的格式化坑短日期显示不对统计SQL查不到数据现象会员开卡日期在DataGridView里显示成“2025-03-14 09:23:11”老板只想看日期统计“本月消费”时SQL WHERE日期条件怎么写都差一天的数据。原因SQL Server的DATETIME带时分秒用WHERE OperateTime today查不到当天数据因为today是“2025-03-14 00:00:00”而流水时间几乎不可能是零点整。界面显示出的“时分秒”是因为DataGridView默认列格式没设置。解决统计用范围查询WHERE OperateTime start AND OperateTime endend取第二天零点。界面显示在绑定列上设置DefaultCellStyle.Format yyyy-MM-dd。代码里不要用ToString()控制格式再去数据库比对直接用DateTime对象作为SQL参数让数据库做类型匹配。5.4 LocalDB文件路径的玄学换台电脑就连接失败现象本机能跑打包发布到另一台电脑双击exe弹出“数据库文件无法打开”或“登录失败”玄学一样的报错。原因连接串里用了AttachDbFileName|DataDirectory|\BarberShop.mdf这种写法数据库文件跟着程序走。LocalDB在每台机器创建的用户实例权限不同而且如果程序装在Program Files目录普通用户根本没有写权限去附加文件。解决本地开发环境用Server(localdb)\MSSQLLocalDB;DatabaseBarberShop;Integrated SecurityTrue数据库先通过建表脚本创建好让它成为LocalDB的实例数据库而不是随程序的附加文件。部署到门店电脑时安装SQL Server Express或者LocalDB runtime后执行一次建表脚本连接串全部写实例数据库名省掉文件路径的麻烦。如果坚持要文件级部署使用LocalDB时备份和恢复不要用SQL Server的BACKUP命令LocalDB对它支持有限最可靠的做法是停掉实例后复制.mdf文件。5.5 BindingSource刷新的黑匣子界面不更新其实数据已经变了现象充值成功后调用LoadMemberList()重新绑定了DataGridView但界面上余额没变重启程序才看到更新后的值。原因DataGridView的DataSource被赋了一个全新的DataTable但界面还持守着旧对象的引用或者直接用dataGridView1.Refresh()它只会重绘界面不会重新读取数据源。解决在LoadMemberList里先执行一次memberBindingSource.ResetBindings(false)再赋值新的DataTable或者始终复用同一个BindingSource更新它的DataSource属性并调用ResetBindings(false)。这个黑匣子的本质是BindingSource在中间充当了缓存和通知的角色理解这一点界面刷新就再也不是玄学。6. 给系统加到期提醒与一键备份从能跑到好用只差这两步6.1 会员到期提醒一个Timer和一条SQL的事系统跑起来后的第一个需求通常是“会员卡快到期了得提醒一下”。用WinForms的Timer控件Interval设为60000每分钟检查一次里面执行一条查询查到期前7天内的会员弹一个提示对话框或偷偷放到右下角通知栏。要注意别做成阻塞式弹窗否则前台每分钟都被打断一次分分钟被店长关掉。6.2 一键备份两种方式混着用别手动拷数据库文件数据备份是这套系统的生命线理发店丢会员数据的代价是实打实的。SQL Server Express环境可以用BACKUP DATABASE BarberShop TO DISKD:\backup\BarberShop.bak定时计划任务调用即可。LocalDB环境则用文件复制先执行SqlConnection.ClearAllPools()确保没有连接占用再把.mdf和.ldf文件复制到备份目录。两种方式的共同点是备份文件要放到另一块磁盘或局域网共享目录否则硬盘坏了两块全没。6.3 从上岗到好用一个小习惯救了门店一次我在交付这类系统时都会在退出按钮的FormClosing事件里加自动备份提醒没备份就退出会跳出确认框。有一次门店电脑中毒重装系统靠的就是这个习惯留的隔夜备份。做管理系统的经验告诉我功能再多都不如数据不丢来得实在。这套C#理发会员管理系统的方向值得做但要记住先保底备份再做花哨功能。希望帮到你。本文还有配套的精品资源点击获取