简介:面向具备C#基础、希望把Access数据库接入Web页面的开发者,这份实例资料围绕“通过浏览器查询Access数据”展开,可用于小型管理系统、内部查询工具或ASP.NET课程练习。压缩包为rar格式,共62个文件、约973KB,主体包括C#源码、解决方案与项目文件、Web窗体及服务端配置、资源文件,以及Access示例数据库和少量编译产物,能较完整呈现从连接配置到页面展示的工程结构。已有245人学习,适合用来对照理解OleDbConnection、OleDbCommand、OleDbDataReader在ASP.NET中的实际用法,并参考GridView绑定、参数化查询、异常处理与IIS部署思路。读者可借由现成示例快速复现查询流程,排查连接字符串、驱动与权限等常见问题,再按需扩展业务逻辑。
1. 从桌面文件到浏览器表格:Web 查询 Access 的边界先讲清
一个.accdb文件放在文件服务器上,业务同事每天远程桌面连过去,开客户端、找表、筛选、导出,这套流程在小团队里能跑很多年。直到有人提出“能不能在浏览器里查”,问题就不再是加一个页面,而是把桌面文件变成 Web 应用的数据源。Web方式查询Access数据库,核心不是把 Access 换成别的库,而是在 IIS 和 C# 进程里安全地打开 OleDb 连接,把参数化查询、分页、权限和锁文件处理好。这份资源适合还在维护 Access 台账、又不想马上重写业务系统的团队;如果查询并发高、数据量上百万行,它只能作为过渡方案。
2. 环境与连接选型:C# 里 OleDb、ACE 驱动和 IIS 位数怎么对齐
Access 在 Web 场景里最先翻车的往往不是 SQL 写错,而是进程位数和驱动位数对不上。桌面端双击能打开,是因为客户端进程和本机 Office 位数一致;放到 IIS 上,w3wp.exe是 32 位还是 64 位,由应用池设置决定,不由项目里的 AnyCPU 单独决定。选型阶段先把这条链路定下来,后面写查询才有意义。
2.1 先定运行时:.NET Framework 与 ASP.NET 的取舍
用 C# 做 Access 的 Web 查询,我一般会选 .NET Framework 4.x 下的 ASP.NET 项目,Web Forms 或 MVC 都行。原因很实际:System.Data.OleDb在 .NET Framework 里是内置程序集,部署时不用额外带一堆包;Access 的 ACE 驱动本身是 Windows 上的 OLE DB 提供程序,和 IIS 应用池的位数绑定得很死。新项目如果坚持上较新的运行时,也能用,但排错路径会变长,现场服务器上缺组件时不容易快速定位。
应用池的设置要跟驱动位数一致。服务器装的是 64 位 ACE 驱动,应用池就保持 64 位;装的是 32 位驱动,就在 IIS 应用池里把“启用 32 位应用程序”设为 True。常见误区是项目编译成 AnyCPU,就以为两边都能跑,实际在 IIS 里还是由应用池决定宿主进程位数。上线前在页面或日志里输出一次Environment.Is64BitProcess,比猜半天管用。
服务器上不要装完整 Office 来“顺便带驱动”。完整 Office 会带来位数冲突和组件注册问题,常见做法是只装 Access Database Engine 可再发行组件,位数与 IIS 进程保持一致。装完后用一段最小 C# 代码打开数据库,能列出用户表,再往下做页面。这个顺序看起来慢,但能省掉后面大量“本地好好的、服务器 500”的返工。
2.2 连接字符串:.mdb 与 .accdb 的差异
老库常见.mdb,新一点的库是.accdb,两者可用的 Provider 不一样。.mdb可以用Microsoft.Jet.OLEDB.4.0,但它只支持 32 位进程;.accdb一般用Microsoft.ACE.OLEDB.12.0,也有环境用Microsoft.ACE.OLEDB.16.0。不要看到连接字符串能打开就以为万事大吉,先确认文件扩展名和进程位数,再决定用哪个 Provider。
| 文件类型 | 常见 Provider | 进程位数 | 说明 |
|---|---|---|---|
.mdb | Microsoft.Jet.OLEDB.4.0 | 仅 32 位 | 老库常见,64 位进程下不可用 |
.accdb | Microsoft.ACE.OLEDB.12.0 | 32/64 位需对应 | 现场最常见,驱动要装对位数 |
.accdb | Microsoft.ACE.OLEDB.16.0 | 32/64 位需对应 | 环境已有时可用,不要和旧驱动混装 |
连接字符串里Data Source用绝对路径最稳。Web 项目里用Server.MapPath("~/App_Data/demo.accdb")把虚拟路径落到物理路径,不要把相对路径直接塞进去,也不要把数据库放到浏览器能直接访问的目录。Mode=Read表示只读打开,适合查询页;但只读不等于不需要目录写权限,Access 打开时仍可能在同目录创建.ldb锁文件,所以应用池账号对数据库所在目录要有读和写权限。
// 只读查询的连接字符串,路径由调用方传入绝对路径 string connStr = "Provider=Microsoft.ACE.OLEDB.12.0;" + "Data Source=" + dbPath + ";" + "Mode=Read;" + "Persist Security Info=False;";这段字符串里,Provider决定用哪个 OLE DB 提供程序,Data Source是.accdb的物理路径,Mode=Read降低误写风险,Persist Security Info=False避免连接打开后还能从对象里取回敏感信息。路径不要从请求参数里直接拼接,数据库位置应该来自配置或Server.MapPath,否则等于把文件路径交给外部输入。目录权限建议只给应用池账号,不要给 Everyone。
2.3 用一段最小 C# 代码验证链路
在写页面之前,先做一个探测方法:打开连接、列出用户表、输出进程位数。这样驱动没装、位数不对、路径错、权限不足,会分别暴露在不同的异常位置,排错时不用把页面、前端和 SQL 混在一起看。
using System; using System.Data; using System.Data.OleDb; public static class AccessProbe { public static string Probe(string dbPath) { // 只读打开,避免探测时产生写锁 string connStr = "Provider=Microsoft.ACE.OLEDB.12.0;" + "Data Source=" + dbPath + ";" + "Mode=Read;" + "Persist Security Info=False;"; using (var conn = new OleDbConnection(connStr)) { conn.Open(); // 只取用户表,不依赖系统表权限 DataTable tables = conn.GetOleDbSchemaTable( OleDbSchemaGuid.Tables, new object[] { null, null, null, "TABLE" }); int count = tables == null ? 0 : tables.Rows.Count; return $"OK, tables={count}, process64={Environment.Is64BitProcess}"; } } }GetOleDbSchemaTable的第一个参数指定要取表结构,第二个参数里最后一项"TABLE"用来过滤用户表。返回的DataTable里常见列有TABLE_NAME,可以打印出来核对表名是否拼错。Environment.Is64BitProcess输出当前宿主进程位数,和 ACE 驱动位数对照,能快速判断是不是位数不匹配。捕获异常时不要只写“连接失败”,把OleDbException的Message和错误码记到日志里,现场排查会省很多时间。
3. 查询层落地:参数化 SQL、分页和结果集映射的三个硬点
连接打通后,查询层要解决三件事:条件不能拼接、分页不能想当然、结果集不能无限往内存里装。Access 的 SQL 方言和 SQL Server 差别不小,尤其是分页和参数占位符。把这三件事按顺序做,页面层就只是展示问题。
3.1 参数化查询:把拼接 SQL 的习惯收起来
OLE DB 的参数占位符是?,不是@name。OleDbCommand按添加顺序匹配参数,名字基本不参与匹配,所以参数顺序错了,条件就会错位。常见翻车是 SQL 里有两个?,代码里先加日期再加部门,查询不报错但结果不对,最后查半天 SQL。文本条件用OleDbType.VarWChar,日期用OleDbType.Date,数字用OleDbType.Integer,类型给对,Access 的隐式转换会少很多。
using System.Data; using System.Data.OleDb; public static DataTable QueryByDept(string dbPath, string dept) { string connStr = "Provider=Microsoft.ACE.OLEDB.12.0;" + "Data Source=" + dbPath + ";" + "Mode=Read;" + "Persist Security Info=False;"; string sql = "SELECT ID, 姓名, 部门, 入职日期 FROM 员工 WHERE 部门 = ? ORDER BY ID"; using (var conn = new OleDbConnection(connStr)) using (var cmd = conn.CreateCommand()) { cmd.CommandText = sql; // 按 ? 出现的顺序添加,不要依赖参数名 cmd.Parameters.Add("pDept", OleDbType.VarWChar, 50).Value = dept; var table = new DataTable(); using (var adapter = new OleDbDataAdapter(cmd)) { adapter.Fill(table); } return table; } }这段代码里,?对应一个部门文本参数,OleDbType.VarWChar长度给 50,实际字段长度按表结构改。不要把dept直接拼进 SQL,像' OR '1'='1这样的输入会改变查询语义。字段名如果是保留字或含空格,用方括号包起来,例如[部门]、[入职日期]。用OleDbDataAdapter.Fill适合中小结果集,查询页要限制返回行数,不要一次把整表拉进DataTable。
3.2 分页:Access 的 TOP 与子查询写法
Access 不支持LIMIT/OFFSET,常见做法是用TOP加子查询。思路是先取出前pageSize * pageIndex行,再用NOT IN排除前面的行;也可以先取前 N 行再反向排序。分页 SQL 里的TOP后面的数字不能直接当参数用,常见做法是把pageSize和pageIndex做整数范围校验后拼进 SQL,文本条件仍然参数化。这个拼接和管理员配置不同,输入必须限定为整数并限制上限。
public static DataTable QueryPage(string dbPath, string dept, int pageIndex, int pageSize) { if (pageIndex < 1) pageIndex = 1; if (pageSize < 1 || pageSize > 200) pageSize = 20; string connStr = "Provider=Microsoft.ACE.OLEDB.12.0;" + "Data Source=" + dbPath + ";" + "Mode=Read;" + "Persist Security Info=False;"; string sql; if (pageIndex == 1) { sql = $"SELECT TOP {pageSize} ID, 姓名, 部门 FROM 员工 " + "WHERE 部门 = ? ORDER BY ID"; } else { int skip = (pageIndex - 1) * pageSize; sql = $"SELECT TOP {pageSize} ID, 姓名, 部门 FROM 员工 " + "WHERE 部门 = ? AND ID NOT IN (" + $"SELECT TOP {skip} ID FROM 员工 WHERE 部门 = ? ORDER BY ID" + ") ORDER BY ID"; } using (var conn = new OleDbConnection(connStr)) using (var cmd = conn.CreateCommand()) { cmd.CommandText = sql; cmd.Parameters.Add("pDept1", OleDbType.VarWChar, 50).Value = dept; if (pageIndex > 1) { // 第二个 ? 对应子查询里的部门条件 cmd.Parameters.Add("pDept2", OleDbType.VarWChar, 50).Value = dept; } var table = new DataTable(); using (var adapter = new OleDbDataAdapter(cmd)) { adapter.Fill(table); } return table; } }这里的pageSize限制在 1 到 200,pageIndex最小为 1,拼进 SQL 前已经是整数,不会带出注入字符。NOT IN子查询里的ORDER BY ID要求ID唯一;如果排序字段有重复值,翻页可能出现重复或漏行。总数查询单独写SELECT COUNT(*) FROM 员工 WHERE 部门 = ?,不要为了算总数把全量数据拉回来。页码很大时子查询会变慢,查询页应该加时间范围、部门等过滤条件,避免用户直接点到几千页。
3.3 结果集映射与异常边界
DataTable直接绑页面最省事,但字段一多、页面一复杂,还是建议映射成小对象或轻量字典。用OleDbDataReader逐行读时,注意DBNull判断,日期字段可能为空,文本字段可能有尾部空格。连接、命令、读取器都用using包住,异常在查询层记日志,页面层只给用户看简短提示,不要把堆栈和数据库路径吐到浏览器。
using System.Collections.Generic; using System.Data.OleDb; public sealed class EmployeeRow { public int Id { get; set; } public string Name { get; set; } public string Dept { get; set; } } public static List<EmployeeRow> ReadRows(OleDbDataReader reader) { var list = new List<EmployeeRow>(); while (reader.Read()) { list.Add(new EmployeeRow { Id = reader["ID"] == DBNull.Value ? 0 : System.Convert.ToInt32(reader["ID"]), Name = reader["姓名"] == DBNull.Value ? "" : reader["姓名"].ToString(), Dept = reader["部门"] == DBNull.Value ? "" : reader["部门"].ToString() }); } return list; }reader["ID"]按列名取值,列名要和 SQL 里的别名一致;如果表字段名是中文,SQL 里用[姓名]包一下更稳。DBNull.Value判断不能省,否则空日期或空文本会在Convert或ToString附近出问题。逐行映射适合需要裁剪字段的接口,直接DataTable适合后台管理页快速绑定。异常边界建议在查询层捕获OleDbException,把 SQL 摘要、参数类型和用户标识记入日志,但不要记录完整连接字符串。
4. Web 端交付:ASP.NET 页面、ASHX 接口与前端表格
查询层跑通后,Web 端只是把条件传进去、把结果展示出来。Web Forms 适合快速做内部管理页,ASHX 加前端表格适合把查询能力开放给多个页面。两条路都要守住一条:查询条件从请求进来先校验,数据库路径不从请求里来。
4.1 Web Forms 页面直接绑定
内部台账查询页用 Web Forms 最快:一个文本框、一个按钮、一个GridView,后台事件里调查询方法。页面首次加载和按钮点击分开处理,避免每次回发都重复查询。数据库路径用Server.MapPath落到App_Data下,App_Data目录默认不会被浏览器直接访问,适合放 Access 文件。
<asp:TextBox ID="txtDept" runat="server" MaxLength="50" /> <asp:Button ID="btnQuery" runat="server" Text="查询" OnClick="btnQuery_Click" /> <asp:GridView ID="GridView1" runat="server" AutoGenerateColumns="true" />protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { BindData(); } } protected void btnQuery_Click(object sender, EventArgs e) { BindData(); } private void BindData() { string dept = txtDept.Text.Trim(); if (dept.Length == 0) { GridView1.DataSource = null; GridView1.DataBind(); return; } string dbPath = Server.MapPath("~/App_Data/demo.accdb"); GridView1.DataSource = AccessQuery.QueryPage(dbPath, dept, 1, 20); GridView1.DataBind(); }IsPostBack判断保证首次进入只查一次,按钮点击再查一次。空条件直接返回空结果,避免用户一进来就全表扫描。Server.MapPath把虚拟路径转成物理路径,AccessQuery.QueryPage内部再做分页和参数化。GridView的AutoGenerateColumns适合字段少的管理页,字段多时显式列更可控。分页控件可以再包一层,但每次只绑定当前页,不要把整表塞进ViewState。
4.2 用一般处理程序输出 JSON
如果前端要自己渲染表格,用.ashx一般处理程序输出 JSON 更直接。请求参数只接收部门、页码这类有限字段,服务端做长度和范围校验。返回结构固定为page和rows,前端好处理;异常时返回统一错误结构,不要把异常堆栈写进响应。
using System; using System.Linq; using System.Text; using System.Web; using System.Web.Script.Serialization; public class QueryHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { context.Response.ContentType = "application/json"; context.Response.ContentEncoding = Encoding.UTF8; string dept = (context.Request["dept"] ?? "").Trim(); if (dept.Length == 0 || dept.Length > 50) { context.Response.Write("{\"error\":\"invalid dept\"}"); return; } int page = 1; int.TryParse(context.Request["page"], out page); if (page < 1) page = 1; try { string dbPath = context.Server.MapPath("~/App_Data/demo.accdb"); var table = AccessQuery.QueryPage(dbPath, dept, page, 20); var rows = table.AsEnumerable().Select(r => new { id = r["ID"].ToString(), name = r["姓名"].ToString(), dept = r["部门"].ToString() }).ToList(); var serializer = new JavaScriptSerializer(); context.Response.Write(serializer.Serialize(new { page, rows })); } catch (Exception ex) { // 日志里记 ex,响应里只给简短错误 context.Response.StatusCode = 500; context.Response.Write("{\"error\":\"query failed\"}"); } } public bool IsReusable => false; }context.Request["dept"]取到的内容先Trim,长度超过 50 直接拒绝,避免把超长文本带进参数。int.TryParse失败时页码回落为 1,防止非法字符串拼进分页 SQL。JavaScriptSerializer在 .NET Framework 项目里可直接用,返回的匿名对象序列化成 JSON。IsReusable设为false,让每个请求走独立实例,减少状态残留。生产环境把异常写日志,响应体只保留错误码。
4.3 前端表格与查询条件
前端用fetch调.ashx,查询条件用encodeURIComponent编码,渲染表格用textContent而不是拼innerHTML。Access 里的文本可能包含引号或尖括号,直接拼 HTML 会把页面结构打乱。分页按钮只改页码,不重新拼整个页面。
<input id="dept" maxlength="50" /> <button id="btnQuery">查询</button> <table id="grid"> <thead> <tr><th>ID</th><th>姓名</th><th>部门</th></tr> </thead> <tbody></tbody> </table> <script> document.getElementById('btnQuery').onclick = async function () { const dept = document.getElementById('dept').value.trim(); if (!dept) return; const url = '/QueryHandler.ashx?dept=' + encodeURIComponent(dept) + '&page=1'; const resp = await fetch(url); const data = await resp.json(); const tbody = document.querySelector('#grid tbody'); tbody.innerHTML = ''; (data.rows || []).forEach(function (row) { const tr = document.createElement('tr'); [row.id, row.name, row.dept].forEach(function (value) { const td = document.createElement('td'); td.textContent = value; tr.appendChild(td); }); tbody.appendChild(tr); }); }; </script>encodeURIComponent处理部门名里的空格、&和中文,避免 URL 参数被截断。textContent把内容当纯文本写入单元格,不执行标签。data.rows || []兼容空结果,表格先清空再追加。页码翻页时可以再加“上一页/下一页”按钮,把page传进同一个接口。前端不要直接暴露数据库路径,也不要在浏览器里拼 SQL。
5. 避坑与排查:Access 在 Web 场景下最容易翻车的五件事
Access 放到 Web 上之后,问题会集中在驱动、权限、锁、参数顺序和分页稳定性上。下面五条是现场最常遇到的,每条按“现象、原因、解决”拆开,照着排查能少走弯路。
5.1 现象:页面报“未在本地计算机上注册 Microsoft.ACE.OLEDB.12.0”
现象是本地调试正常,部署到 IIS 后打开页面直接报提供程序未注册,有时连登录页都进不去。原因通常有两个:服务器没装 ACE 驱动,或者应用池进程位数和驱动位数不一致。64 位w3wp去加载 32 位驱动,注册表里能看到,进程里照样找不到;反过来也一样。
解决时先确认进程位数,再确认驱动位数。在页面或日志里输出Environment.Is64BitProcess,如果是 64 位,就装 64 位 Access Database Engine;如果是 32 位,就装 32 位驱动,并在 IIS 应用池把“启用 32 位应用程序”设为 True。不要在同一台服务器上同时装 32 位和 64 位驱动来“兼容两边”,组件注册容易互相覆盖。装完重启应用池,再跑第 2 章的最小探测代码,能列出表才算链路打通。
5.2 现象:本地能查,部署到 IIS 就 500
现象是开发机用 Visual Studio 调试能出数据,发布到服务器后查询按钮一按就 500,日志里可能是“找不到文件”或“拒绝访问”。原因常见于路径和权限:代码里用了相对路径,工作目录变成w3wp的启动目录;数据库放在网站根目录下,应用池账号没有读权限;或者目录只给了读权限,Access 创建锁文件时失败。
解决时把数据库路径统一改成Server.MapPath("~/App_Data/demo.accdb"),确认文件确实在发布目录里。给应用池账号配置数据库所在目录的读和写权限,写权限是给.ldb锁文件用的,不是让页面写数据。查 IIS 日志和事件查看器,先把详细错误打开,看到底是 404 路径问题还是 401 权限问题。发布包里不要把本地绝对路径带上去,连接字符串放在配置里,按环境改。
5.3 现象:查询一段时间后数据库文件被锁
现象是页面刚开始能查,过一阵子写入失败,目录里留下.ldb文件,回收应用池又恢复正常。原因多是连接或读取器没有及时释放,长连接一直挂着;也可能是多个请求同时打开同一个 Access 文件,锁竞争变严重。把 Access 放在网络共享盘上,锁文件行为还会更不可控。
解决时所有OleDbConnection、OleDbCommand、OleDbDataReader都用using包住,查询完立即关闭。查询页用Mode=Read打开,减少写锁;不要在页面级静态字段里缓存连接对象。并发高的内部系统限制同时查询人数,或者加一个短的 MemoryCache 缓存热点结果,降低打开频率。数据库文件尽量放在本机磁盘,不要放共享盘。应用池回收能缓解,但不能替代正确的释放习惯。
5.4 现象:中文条件查不到数据
现象是英文部门名能查,中文部门名查出来是空,SQL 不报错。原因常见于参数顺序错位:SQL 里有多个?,代码里先加了日期参数,导致部门值落到别的位置;也可能是字段类型是备注或短文本,参数类型给成了数字;还有拼接 SQL 时中文没有加引号,或者日期格式被 Access 按区域设置解析。
解决时先数 SQL 里?的个数,再按出现顺序加参数,不要依赖参数名。文本参数用OleDbType.VarWChar,日期用OleDbType.Date,数字用OleDbType.Integer。字段名含中文或保留字时用[]包起来。把查询条件、参数类型和实际执行 SQL 摘要记到日志里,对照一次就能看出错位。中文本身不需要额外转码,OLE DB 文本参数会按 Unicode 处理,问题多半在顺序和类型。
// 两个 ? 必须按顺序对应:先部门,后日期 string sql = "SELECT ID FROM 员工 WHERE 部门 = ? AND 入职日期 >= ?"; cmd.Parameters.Add("p1", OleDbType.VarWChar, 50).Value = dept; cmd.Parameters.Add("p2", OleDbType.Date).Value = startDate;5.5 现象:分页结果重复或漏行
现象是翻到第二页、第三页时,某些 ID 重复出现,或者中间少了几条。原因通常是ORDER BY用了不唯一字段,比如按部门排序,同部门多行顺序不稳定,NOT IN子查询排除的行和上一页取的行对不上。TOP后面的数字如果是字符串拼出来的,还可能带出语法错误。
解决时排序字段用唯一主键,必要时加第二排序键,例如ORDER BY ID。分页参数先转成整数并限制范围,再拼进 SQL;文本条件仍然参数化。总数单独查,不要用当前页结果推算总页数。翻页测试至少连续翻十页,核对 ID 不重复、不漏行。数据量大的查询加时间范围或部门过滤,避免用户直接点到很深的页码。
6. 上线前的验证与收口:连接自检、权限清单和一个封装技巧
上线前不要只看首页能不能打开,按检查清单把连接、权限、参数和分页各跑一遍。下面这张表适合贴在发布单里,每项都有可操作的动作和通过标准,比“看起来正常”可靠。
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 进程位数 | 页面输出Environment.Is64BitProcess | 与 ACE 驱动位数一致 |
| 连接探测 | 调第 2 章AccessProbe.Probe | 能列出用户表,不抛异常 |
| 目录权限 | 用应用池账号访问数据库目录 | 可读,且能创建.ldb锁文件 |
| 参数顺序 | 用中文、单引号、空值各查一次 | 不报错,结果符合预期 |
| 分页稳定性 | 连续翻 10 页并记录 ID | 不重复、不漏行 |
连接字符串不要散落在每个页面里,收口到一个工厂方法,查询页统一用只读模式打开。这样驱动名、路径和模式只改一处,排查时也能确认所有查询走的是同一套配置。
using System.Data.OleDb; public static class AccessConnectionFactory { public static OleDbConnection OpenReadOnly(string dbPath) { var builder = new OleDbConnectionStringBuilder { Provider = "Microsoft.ACE.OLEDB.12.0", DataSource = dbPath }; builder["Mode"] = "Read"; builder["Persist Security Info"] = false; var conn = new OleDbConnection(builder.ConnectionString); conn.Open(); return conn; } }这段工厂代码用OleDbConnectionStringBuilder拼连接字符串,避免手写分号漏项;Mode=Read固定只读,Persist Security Info=false避免连接打开后还能取回敏感信息。调用方仍然要用using包住返回的连接,工厂只负责创建和打开,不负责长期持有。路径由调用方从Server.MapPath或配置传入,不从请求参数取。
需要拿这份示例工程对照改的人,先把探测代码和分页查询跑通,再替换成自己的表名和字段;资源入口按原包获取即可,先别急着改前端样式。从那以后我每次把 Access 搬到 Web 上,都先跑一遍位数、权限、参数顺序这三项自检,再谈页面美化。希望帮到你。
本文还有配套的精品资源,点击获取