资讯动态

layim + 环信WebIM:PC端网页即时聊天集成实践

发布时间:2026/10/29 10:11:07 来源:尧图企业网站定制
简介这是一份面向前端开发者的layim与环信PC端网页即时聊天Demo帮助快速理解如何基于layim界面组件对接环信WebIM服务实现浏览器端文字与表情消息收发并预留图片文件扩展能力。压缩包共190个文件约1.7MB以JS逻辑、CSS样式、HTML页面为核心辅以75个gif表情、37个png图标及字体配置文件可直接在Web工程中引用。Demo内置联系人与会话假数据接口替换为自身后端即可复用环信AppKey也需换成自有凭证。目前仅张三、李四两个账号可互通测试时请选这两个用户名且接口用户名键需使用id默认打通文字和表情图片与文件上传需接入自己的接口。整套代码结构紧凑适合学习IM界面集成、环信SDK调用及消息UI渲染已有1529人学习下载。1. 为什么是 layim 环信一个 PC 端网页即时聊天的落地组合如果公司内部要做一个“网页版客服工作台”或者“站内信”产品经理通常只会丢给你一句话跟微信的聊天窗口差不多。自己从零写消息收发、会话列表、未读角标没有一两个月下不来。我当时接到的需求更具体PC 端浏览器里用 layim 的界面做聊天框消息通道用环信的 webIM。layim 负责 UI环信负责把消息送到对方那里两者一拍即合。这个 demo 不是玩具它把登录、收发文本/图片/文件、离线消息、会话列表都串了起来。适合刚接触网页即时通讯、想把聊天能力快速接进现有前端项目的人。核心思路一句话UI 归 layim通道归环信前端只做粘合。2. 环信 WebIM 接入前的准备SDK 选型与初始化参数2.1 WebIM SDK 两种引入方式npm 包还是静态脚本我最早拿到环信文档第一反应是先分辨它给的是 REST API 还是 WebIM SDK。REST API 只能做服务端到服务端的消息推送浏览器里跑聊天要的是 WebIM它内部封装了 WebSocket 长连接。环信 WebIM SDK 的引入方式有两种一种是 npm 包另一种是官方提供的静态 JS 文件直接在 HTML 里用script引入。如果你用的是 Vue 或 React 工程我建议走 npm 包npm install easemob-websdk --save然后在项目里 import。注意不同版本的包名、导出对象差异很大。老文档里常见的是import WebIM from easemob-websdk新版本可能是import { EasemobChat } from easemob-websdk。装完先打印一下模块对象看它暴露了connection还是EasemobChat别照着老文档硬抄。如果你的 demo 是纯 HTML 页面或者像 layim 这种直接挂在 layui 模块里的引入静态 JS 更省事。静态脚本会在 window 上挂一个全局对象通常是WebIM后面所有初始化都通过这个全局对象走。两种方式本质一样但混用容易出问题npm 包和静态 JS 同时引入会重复建立 WebSocket 连接消息回调触发两遍这是很常见的翻车现场。2.2 初始化必须填对的参数appKey、tenantId 与多端同步开关环信控制台建完应用之后会给你一串 appKey格式一般是orgname#appname。注意很多人在这一步直接抄 demo把 appKey 写死成官方示例的easemob-demo#chatdemoui。这会导致初始化成功但登录永远提示用户不存在。我自己吃过这个亏后来每次接项目都先在控制台确认 appKey 是不是当前项目的。初始化代码大致长这样不同版本参数名略有差别// 以静态 JS 方案为例使用全局 WebIM 对象 var conn new WebIM.connection({ appKey: 你的orgname#你的appname, // 多端同步同一账号在多个页面同时登录时是否互踢或共存 isMulti: true, // 是否开启本地日志排查问题时建议先打开 isLog: true }); // 全局配置里还要把 appKey 再放一份部分接口依赖这个配置 WebIM.config { appKey: conn.appKey, isMulti: true }; console.log(conn created, conn);参数说明appKey是应用唯一标识拆成 orgname 和 appname 两部分中间用#连接isMulti决定同一个账号能不能在多个标签页同时在线demo 里如果只是客服工作台建议设false防止一个客服在俩页面里抢消息isLog会往控制台打消息收发的日志联调阶段别关。这里有个容易忽略的点环信新版的鉴权体系里会用到 tenantId也就是租户 ID。它不是 appKey 的一部分而是在控制台应用详情里单独给的。部分版本的初始化参数会要求传入tenantId或者登录的时候用它拼 accessToken。如果你的初始化代码里没有这个字段但登录报 auth failed就去控制台找找多半是它没传。除了 appKey环信云版本还有一个 endpoint 参数需要确认。如果你用的是环信公有云SDK 默认端点即可但如果公司用的是私有化部署的环信必须把 WebSocket 地址和 REST 地址改成内网地址否则初始化永远连不上。这个参数在 demo 里不写也能跑通反而是私有化场景第一个报错的地方。检查方法初始化后看 Network 里 WebSocket 的请求域名如果显示的是官方默认域名而你们用的是内网那就是配置少了 endpoint。2.3 先跑通登录拿 token再谈 UI很多人一上来就写 layim 的聊天窗口结果消息一条发不出去最后才发现登录压根没成功。我的习惯是先写一个最简页面只做初始化 登录 打印连接状态全部通了再动 UI。环信网页端一般用的是 token 登录token 由你自己的服务端通过环信 REST API 换取再下发到浏览器。demo 阶段为了方便也可以用用户名直接登录新版本大多要求先获取 token。我按 token 方式写// 假设你的后端已经通过 /token 接口换好了 accessToken const token await fetch(/api/easemob/token, { method: POST }).then(r r.json()); conn.open({ user: 客服001, // 环信登录用户名 accessToken: token.access_token, // 后端换来的 token success: function() { console.log(环信登录成功); }, error: function(err) { console.error(环信登录失败, err); } });逻辑说明conn.open之前需要保证WebIM.connection已经创建open里的success只是建立长连接成功不代表能收发消息。真正的消息收发要等监听器注册之后。如果error里报 504 或者 Network Error先看是不是本地开发环境没开代理WebSocket 地址被浏览器拦了如果报 token 过期则是后端换 token 的接口问题。这一步跑通之后你会在 Network 面板里看到一条 WebSocket 连接状态是 101。看到这个再往下做 UI 就有底气了。无论你是 Vue 后台还是 uni-app 前端登录拿 token 这一步的逻辑完全一样只是 WebSocket 的 API 在 uni-app 里要换成小程序版但 token 的获取和刷新策略可以原样平移植过去。3. 把 layim 嵌进业务模板改造与消息字段映射3.1 laytpl 模板改造从模拟数据切到真实消息layim 的界面基于 layui 的 laytpl 模板默认的 demo 里聊天记录是一堆写死的模拟数据。要做真实消息得先找到 layim 初始化后挂载的聊天记录区域把数据源替换成环信消息数组。我自己用的是 layui 2.x 的模块化引入方式layui.use([layim, layer], function() { var layim layui.layim; var layer layui.layer; layim.init({ // 当前登录用户信息对应环信用户名 mine: { username: 客服001, id: kefu001, avatar: /static/avatar.png, sign: 在线 }, // 初始好友列表强制置空后面用环信消息动态生成 friend: [], group: [] }); });这里的mine.username建议和环信登录的user保持一致。如果两边对不上发出去的消息在对方会话里会显示成另一个人排查起来很折磨。friend和group置空是因为这些列表应由消息驱动而不是写死。常见做法是先渲染一个空白框架等收到第一条消息时再用layim.setChatList()插入会话。模板方面layim 的消息气泡结构在 laytpl 里已经写好了你不需要把整个模板删了重写。要改的是模板里的字段名把模拟数据里的 content、mine 等字段替换成环信消息对象里对应的字段。我一般做法是先把一条环信消息打印到控制台再照着它的字段去改模板不要凭印象猜字段名。3.2 WebIM 消息类型与 layim 消息体的字段映射环信的消息对象分很多种文本消息、图片消息、文件消息、系统通知。layim 的展示层只认三类文本、图片、文件。所以中间层必须做一个映射函数。我通常这么写function convertMsgToLayim(msg) { if (msg.type txt) { return { type: text, content: msg.data, mine: msg.from currentUser, username: msg.from, timestamp: msg.time }; } if (msg.type img) { return { type: image, src: joinFileUrl(msg.url), // 环信返回的 url 需要拼接域名前缀 mine: msg.from currentUser }; } if (msg.type file) { return { type: file, name: msg.filename, url: joinFileUrl(msg.url), extension: msg.filename.split(.).pop() }; } return null; // 其他类型先丢弃避免 layim 报错 }参数说明msg.type是环信的原始消息类型txt、img、file 是常见取值msg.data是文本内容某些版本里是msg.value需要以实际打印为准msg.from是发送方环信 IDmsg.time是毫秒时间戳layim 显示时可以直接用。joinFileUrl()是拼接环信文件服务地址的函数后面避坑章会细说。这个映射函数是整个 demo 的核心。消息能不能正常显示取决于你有没有把环信的字段完整喂给 layim。我见过有人只在文本消息上映射成功图片消息直接塞srcmsg.url结果图片裂了。原因见 5.3。3.3 会话列表和未读数的更新时机layim 的会话列表由layim.setChatList()控制数据里要有id、name、avatar、type等字段。收到新消息时先判断这个会话在不在列表里不在就 add在就 update 最后一条消息和未读数。我的处理方式是在消息回调里统一调一个refreshChatListfunction refreshChatList(peerId, lastMsg) { var chatList layim.cache().chatList; // 读取当前会话列表 var exists chatList.some(function(item) { return item.id peerId; }); if (!exists) { layim.setChatList([ { id: peerId, name: peerId, type: friend, avatar: /static/friend.png, msg: lastMsg.content || [图片], timestamp: lastMsg.time } ]); } else { // 更新最后一条消息和时间未读数交给 layim 自增 layim.updateChatList(peerId, { msg: lastMsg.content || [文件], timestamp: lastMsg.time }); } }逻辑说明layim.cache().chatList拿到的不是响应式对象直接 push 不会触发 UI 更新所以新增会话要用setChatList更新已有会话用updateChatList。timestamp必须是毫秒数如果环信返回的是秒乘 1000 再传不然时间显示成 1970 年。未读数 layim 会在新消息进来时自动 1但前提是你走的 layim 内部事件。如果自己手动渲染未读数就得自己维护别指望 UI 自己动。3.4 历史消息加载让 layim 聊天窗口支持向上滚动翻页layim 默认不提供“加载更多历史消息”的能力。点开一个会话时如果只显示登录后收到的消息用户会觉得聊天记录丢了。环信的getHistoryMessages接口按分页拉取历史消息配合 layim 滚动条向上滚动可以做出翻页效果。conn.getHistoryMessages({ from: currentChatPeerId, to: currentUser, pageSize: 20, // 每次拉 20 条 chatType: singlechat, success: function(result) { // result 里有 messages注意顺序是倒序的 var list result.messages.reverse(); list.forEach(function(msg) { layim.getMessage({ username: msg.from, avatar: /static/friend.png, mine: msg.from currentUser, content: msg.data || [消息], timestamp: msg.time }); }); } });参数说明from和to决定了拉取哪个会话的历史消息顺序不能反pageSize建议 20 左右一次拉太多会卡 UI。result.messages返回的数组通常是新的在前所以要reverse()再插入 layim否则聊天记录是倒着显示的。这个接口也有坑如果当前会话是群聊chatType要改成groupchat否则拉不到数据。4. 核心链路发送消息、接收消息与离线消息处理4.1 发送文本、图片、文件三种消息的构造方式环信 webIM 的发送接口在不同版本里名字不一样老版是conn.sendTextMessage新版可能是conn.sendMessage。demo 里为了方便我会封装一个统一发送函数内部判断消息类型function sendEasemobMessage(targetId, type, content) { if (!conn || conn.isOpened() ! true) { console.error(连接未建立); return; } if (type text) { conn.sendTextMessage({ to: targetId, msg: content, chatType: singlechat, // 单聊群聊用 groupchat success: function() { console.log(文本发送成功); }, error: function(e) { console.error(发送失败, e); } }); } else if (type image) { // 先上传图片文件再发图片消息 conn.sendImageMessage({ to: targetId, file: document.getElementById(fileInput).files[0], chatType: singlechat, success: function() { console.log(图片发送成功); }, error: function(e) { console.error(图片发送失败, e); } }); } // 文件消息类似sendFileMessage }参数说明targetId是对方的环信 IDchatType的值直接影响消息路由单聊必须写singlechat写错了消息会发到群里去file参数在浏览器里是 File 对象SDK 内部会做分片上传。注意图片消息并不是直接传 base64而是先上传拿 url再发送消息体。如果你在 Network 里看到图片发不出去先看有没有触发上传接口没有就是 SDK 版本不对或未登录。我一般会在发送前把消息先乐观地渲染到 layim 窗口发送失败再回滚。这是聊天类应用的标准做法但 demo 里可以简化等success回调再渲染缺点是有延迟快速连续发消息时气泡顺序会乱。如果你想要体验好一点就在调用 send 的同时立刻渲染加一个 pending 状态等失败后再把气泡标红。4.2 接收消息监听 onTextMessage 还是全局 onMessage环信 webIM 提供了两种监听方式一种是单独监听文本消息onTextMessage一种是全局onMessage接收所有类型。demo 里常见的问题是把两种都写了导致同一条消息被渲染两次。我的习惯是只监听全局onMessage然后在回调里按msg.type分发conn.onMessage function(msg) { // 消息可能是文本、图片、文件甚至是撤回通知 var layimMsg convertMsgToLayim(msg); if (!layimMsg) return; var peerId msg.from; // 已经打开的这个聊天窗口直接追加气泡 if (currentChatPeerId peerId) { layim.getMessage({ username: peerId, avatar: /static/friend.png, mine: false, content: layimMsg.content || layimMsg.src || layimMsg.name, timestamp: msg.time || Date.now() }); } // 总是更新会话列表和未读 refreshChatList(peerId, layimMsg); };逻辑说明conn.onMessage是把回调赋给连接对象这是在conn.open成功之后、消息到来之前就要做的。如果onMessage是在open的success回调里才赋值的可能会漏掉连接建立后立刻到达的离线消息。layim.getMessage是 layim 提供的“在聊天窗口追加一条消息”的接口mine:false表示这是对方发的如果mine:true会显示成右侧气泡。有的版本里getMessage的参数需要content字段甚至type字段照着 layim 的源码过一遍最稳妥。千万不要在onMessage里做 setTimeout 延迟渲染消息一多会变成乱序。环信 SDK 内部对消息有去重但你的渲染层没有所以收到消息要先判断这条消息的msg.id是不是已经渲染过可以用一个 Set 存最近的消息 id。这里是消息链路的黑匣子部分一旦出现重复或丢失先怀疑自己的渲染逻辑而不是环信。4.3 离线消息与漫游demo 里最容易丢消息的一环离线消息是指对方在你不在线时发的消息你重新登录后应该能收到。环信对离线消息的处理依赖两个东西一是登录时是否开启消息漫游二是 SDK 的onOfflineMessage回调。很多 demo 只写了onMessage没写onOfflineMessage导致离线消息静默丢失。我一般在登录后这样处理conn.onOfflineMessage function(msgList) { msgList.forEach(function(msg) { var layimMsg convertMsgToLayim(msg); if (layimMsg) { refreshChatList(msg.from, layimMsg); // 如果当前正和这个人聊天直接渲染 } }); };参数说明msgList是环信在登录成功后一次性下发的离线消息数组注意它可能很大几千条离线消息如果直接渲染页面会卡死。正确做法是先更新会话列表统一显示未读 N用户点开某个会话时再按需渲染历史消息。如果想要点开会话时加载历史可以用环信的getHistoryMessages接口它按分页拉取demo 里拉最近 20 条即可。离线消息的坑还在于时间顺序。环信下发离线消息不一定按时间正序所以要在refreshChatList里对会话列表按timestamp重新排序不然列表里最后一条消息可能是旧的。另外如果isMulti设成了false同一个账号在另一个设备登录当前设备的连接会被踢掉这个不算离线消息要走onKickedOut回调提示用户重新登录。4.4 消息幂等与发送失败补偿聊天的稳定性除了收发还要考虑网络抖动带来的重复和丢失。环信不会保证消息只渲染一次前端必须自己做幂等。我在 demo 里维护了一个receivedMsgIds的 Setvar receivedMsgIds new Set(); conn.onMessage function(msg) { if (receivedMsgIds.has(msg.id)) return; // 已经处理过跳过 receivedMsgIds.add(msg.id); // 后续渲染逻辑... };逻辑说明msg.id是环信消息的唯一 ID同一个消息在重连、离线补发时可能重复回调用 Set 去重是最廉价的做法。Set 不能无限增长我一般保留最近 1000 条消息 id超过就清掉最早的。发送失败时不要自动重发因为用户可能已经点击了多次。正确做法是发送失败后在气泡上给出一个重发按钮由用户决定是否重发避免消息重复。5. 避坑layim 与环信 WebIM 对接的常见问题排查5.1 消息重复渲染reconnect 后重复注册监听现象消息收发正常但每收到一条消息界面上出现两条一模一样的气泡。原因环信 WebIM 在conn.open失败或网络断开时会自动重连重连成功后如果你在success回调里再次执行conn.onMessage xxx新的回调会覆盖旧的但如果你的代码是conn.onMessage fn; conn.onMessage fn;这种赋值式理论上不会叠加。真正的重复来自你在open之前用conn.addHandler注册了事件处理函数每次重连又addHandler一次处理函数被加入列表多次。解决不要用addHandler重复注册。我习惯直接在new WebIM.connection()之后一次性赋属性式回调重连只是重新open不会重新赋值监听。如果一定要用addHandler就在注册前先removeHandler。这是最典型的翻车现场排查时先在 console 里打印回调被触发的次数一目了然。5.2 会话列表不更新chatList 是内部数组需要手动更新现象收到新消息会话列表没有任何变化但聊天窗口里能看到气泡。原因我在 3.3 提过layim.cache().chatList返回的是普通数组直接 push 或者修改 item 的属性不会触发 layim 的重新渲染。很多从 jQuery 时代过来的同学习惯list.push(newItem)完事结果 UI 没反应。解决统一走layim.setChatList(data)和layim.updateChatList(id, data)。setChatList传入的是数组新增会话时把整个数组传进去更新时用updateChatList它内部会 diff。还有额外的两个好处这两个接口会自动处理排序和未读数重置。如果你改了数组但 UI 没动先用console.log(layim.cache().chatList)确认数据再怀疑 UI。5.3 图片消息裂开环信返回的 url 需要拼接前缀现象文本消息正常图片消息在 layim 里显示成一个裂开的图标控制台报 404。原因环信 webIM 的图片、文件消息返回的url字段经常是相对路径比如/images/xxx.jpg需要拼上环信文件服务域名才能组成完整的可访问地址。不同部署环境的前缀不一样国内版和国际版、独立部署版的前缀都不同。解决在convertMsgToLayim里统一处理function joinFileUrl(url) { if (!url) return ; // 已经带了 http直接返回 if (/^https?:\/\//.test(url)) return url; // 否则拼上你从控制台拿到的文件服务前缀 return https://your-easemob-file-prefix url; }这样做完以后所有图片和文件都走同一个出口不用在模板里到处改。另外还要确认这条消息的url是否带了访问令牌有些文件服务要求临时 token不带就会 403。血泪经验图片消息裂开时不要先调 UI先看完整 URL 在浏览器里能不能打开。5.4 本地开发跨域CORS 与代理配置现象本地npm run dev启动页面环信初始化正常但登录或发图片时报跨域错误Network 里红色请求。原因环信的 WebIM SDK 需要连接 WebSocketwss://...以及 REST 接口上传文件、换 token。如果你的页面在localhost:8080浏览器会检查目标服务器的 CORS 头。环信云服务默认对 WebSocket 不做同源限制但 REST 接口可能不允许跨域特别是你自己部署的环信服务CORS 配置缺失是常事。解决开发环境下用 Vite 或 Webpack 代理。以 Vite 为例// vite.config.js export default { server: { proxy: { /easemob: { target: https://your-easemob-api-domain, changeOrigin: true, rewrite: path path.replace(/^\/easemob/, ) } } } };注意WebSocket 也要走代理默认会转发但环信 SDK 的 WebSocket 地址如果是写死的 wss 域代理不一定拦得住。更稳妥的做法是把环信相关域名在本地 hosts 里指到测试环境或者让后端接口也走同一个域名避免浏览器 CORS 检查。这个现象在联调阶段特别多先分清是 WebSocket 连不上还是 REST 接口跨域再决定用代理还是改 hosts。5.5 退出登录后 token 失效logout 与本地缓存清理现象用户点退出登录再重新登录失败报 token 无效或者前一个用户的消息出现在后一个用户页面上。原因退出只清了页面上的局部变量没有调环信的conn.close()和conn.logout()。WebSocket 长连接还在服务端 token 也没失效重新登录时带着旧 token 自然失败。更糟的是如果 localStorage 里缓存了之前用户的聊天记录和 token新用户登录后 layim 初始化会读到旧数据。解决写一个完整的登出函数function logout() { conn.close(); // 断开 WebSocket conn.logout conn.logout(); // 服务端踢出 // 清理本地缓存 localStorage.removeItem(easemob_token); localStorage.removeItem(easemob_user); layer.msg(已退出); }在logout之前务必要先conn.close()再conn.logout()顺序反了可能抛异常。清理缓存时除了自己存的自定义 key还要看看环信 SDK 是否在 localStorage 里放了内部数据有些版本会有EasemobCache之类的 key逐个清掉。不清理干净的话下一个用户登录就会看到上一个用户的聊天记录这是严重的功能事故。6. 进阶把 demo 变成可交付模块——登录态、多标签页与已读回执6.1 登录态与环信 token 的缓存策略demo 能跑通和能交付是两回事。第一个要处理的就是 token 过期。环信的 accessToken 有效期一般是几天但页面刷新后会丢失导致用户每次刷新页面都要重新登录。我的做法是把 token 和用户信息存进 localStorage然后在初始化时先读缓存function getStoredToken() { return localStorage.getItem(easemob_token); } function initFromStore() { var user localStorage.getItem(easemob_user); var token getStoredToken(); if (user token) { conn.open({ user: user, accessToken: token }); } }逻辑说明token 存 localStorage 有 XSS 风险但内部系统或 demo 阶段可以接受。更稳妥的方案是存 httpOnly cookie由后端统一换取 token浏览器无感续期。我在交付正式项目时会把 token 放 cookiedemo 里为了直观先用 localStorage。注意环信消息里的敏感信息如果走本地缓存一定要在登出时清掉。6.2 多标签页同步登录BroadcastChannel 与 storage 事件如果你开了多个标签页登录同一个环信账号isMulti为true两个页面会同时收到消息导致未读数在两个页面里各加一遍。要解决这个问题可以用 BroadcastChannel 在标签页之间同步谁在处理消息const bc new BroadcastChannel(easemob_chat); // 当前页面收到消息并渲染后通知其他页面 bc.postMessage({ type: msg_read, peerId: peerId, msgId: msg.id }); // 其他页面监听后从自己的缓存里去掉这条 bc.onmessage (event) { if (event.data.type msg_read) { markMsgRead(event.data.peerId, event.data.msgId); } };参数说明BroadcastChannel只在同源页面间生效浏览器不支持时降级用window.addEventListener(storage)事件。这个机制不是环信的功能是前端自己做的“消息已被某页面处理”的协调。如果没有这层处理用户开俩标签页每边都会弹未读体验很差。这个协调逻辑有点玄学但只要记住“多页面只让一个页面处理 UI其余页面只同步状态”的原则就不会乱。6.3 已读回执与输入状态两个小技巧环信 webIM 支持已读回执但 layim 默认不消费。我给 demo 加了一个很简单的回执提示对方读了消息后气泡下方显示已读。监听onDeliveredMessage回调conn.onDeliveredMessage function(msg) { // msg.id 对应发出去的那条消息 id var el document.querySelector([data-msg-id${msg.id}]); if (el) el.innerHTML span classread-tag已读/span; };输入状态正在输入更麻烦环信有 typing 消息但需要对方也接入同样的逻辑。demo 里我一般用发送“正在输入”文本消息的变体实现或者干脆不做。要做得精致可以在输入框的oninput里节流发送 typing 信号对方收到后在聊天窗口顶部显示“正在输入...”。记住typing 信号不能太频繁300ms 一次足够否则会刷爆消息列表。这套 demo 我已经按这个结构整理好你拿到手后先跑自检页面再替换 appKey基本不用改其他代码。最后说一个习惯从那以后我每次接环信相关项目都会先写一个“连接自检页面”里面就三个按钮初始化、登录、发消息任何问题先在这个页面上复现再回到 layim 里去查。这个习惯帮我省了大量查 UI 和查消息通道的时间。demo 拿过去之后建议你也保留这个自检页遇到问题先在自检页验证是不是环信通道的问题再回头看 layim 的渲染。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑