LOGO OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 开发文档 其他文档  
 
网站管理员

使用 HTML + JavaScript 实现群聊 @ 功能(附完整代码)

admin
2025年12月31日 10:49 本文热度 874
群聊中的 @ 功能能够让用户精准地提醒特定成员参与讨论,把群聊从“大喇叭”变成“定向呼叫器”,提高沟通效率。本文将介绍如何使用 HTML、CSS 和 JavaScript 实现群聊 @ 功能。

效果演示

该群聊 @ 功能包含完整的聊天界面,支持发送消息、接收消息显示,以及核心的 @ 提醒功能。当用户在输入框中键入 @ 符号时,会自动弹出群成员列表,支持实时搜索过滤、键盘方向键导航选择、回车确认等功能。选中用户后,会在消息中插入 @ 用户名,并在聊天区域中高亮显示。

页面结构

页面主要包括:头部标题区域、聊天消息显示区域、输入框区域和 @ 成员列表区域。输入框下方包含一个隐藏的 @ 列表,当检测到 @ 符号时会动态显示。
<div class="container">  <div class="header">群聊</div>  <div class="chat-area" id="chatArea"></div>  <div class="footer">    <div class="input-box">      <textarea id="input" placeholder="输入消息..." rows="1"></textarea>      <div class="at-list" id="atList"></div>    </div>    <button id="sendBtn">发送</button>  </div></div>

核心功能实现

消息渲染与 @ 高亮处理

appendMsg 函数实现消息的渲染功能,特别是处理消息中的 @ 标识。通过正则表达式识别消息中的 @ 用户名格式,并将其转换为高亮显示。
function appendMsg(m, scroll = true) {  const isMe = m.who === me;  const div = document.createElement('div');  div.className = 'msg ' + (isMe ? 'me' : 'other');  const html = escapeHtml(m.txt).replace(/@(\S+)/g, (s, name) => members.some(mm => mm.name === name) ? `<span class="at">@${name}</span>` : s);  div.innerHTML = isMe ? `<div class="bubble">${html}</div>
"avatar" style="background:${nameColor(m.who)}">${m.who.charAt(0)}</div>` :    `<div class="avatar" style="background:${nameColor(m.who)}">${m.who.charAt(0)}</div>
"bubble">${html}</div>`;  chatArea.appendChild(div);  if (scroll) chatArea.scrollTop = chatArea.scrollHeight;}

@ 模式检测与列表显示

当用户在输入框中输入 @ 符号时,需要检测当前是否进入 @ 模式,并显示对应的用户列表。通过获取光标位置和文本内容来判断是否需要显示提示列表。
function handleAtMode() {  const val = input.value, cur = input.selectionStart;  const last = val.lastIndexOf('@', cur - 1);  if (last === -1 || val.substring(last + 1, cur).includes(' ')) {    hideAt();    return;  }  atQuery = val.substring(last + 1, cur);  showAt();}

成员筛选与键盘操作

实现键盘事件处理,支持方向键导航选择用户,回车确认选择,以及 ESC 键取消提示列表。
input.addEventListener('keydown', e => {  if (e.key === 'Enter' && !e.shiftKey) {    if (atMode) {      e.preventDefault();      const users = filterUsers();      if (atSel >= 0 && users.length > 0) pickAt(users[atSel].name);      return;    }    e.preventDefault();    send();  } else if (atMode && ['ArrowDown''ArrowUp''Escape'].includes(e.key)) {    e.preventDefault();    const users = filterUsers();    if (users.length === 0return;    if (e.key === 'ArrowDown') atSel = (atSel + 1) % users.length;    else if (e.key === 'ArrowUp') atSel = (atSel - 1 + users.length) % users.length;    else if (e.key === 'Escape'return hideAt();    renderAt();  }});

成员选择与文本插入

当用户通过点击或键盘选择某个用户时,需要将该用户名插入到输入框的正确位置,并更新光标位置。
function pickAt(name) {  const val = input.value;  const cur = input.selectionStart;  let lastAt = -1;  for (let i = cur - 1; i >= 0; i--) {    if (val[i] === '@') {      lastAt = i;      break;    } else if (val[i] === ' ') {      break;    }  }  if (lastAt === -1return;  const before = val.substring(0, lastAt);  const after = val.substring(cur);  input.value = before + '@' + name + ' ' + after;  const newCursorPos = lastAt + name.length + 2;  input.setSelectionRange(newCursorPos, newCursorPos);  input.focus();  hideAt();  autoHeight();}

完整代码

git地址:https://gitee.com/ironpro/hjdemo/blob/master/im-at/index.html
<!DOCTYPE html><html lang="zh-CN"><head>  <meta charset="utf-8" />  <meta name="viewport" content="width=device-width, initial-scale=1.0" />  <title>群聊@功能</title>  <style>      * {          margin0;          padding0;          box-sizing: border-box;      }      body {          height100vh;          padding20px;          display: flex;          justify-content: center;          background-color#f5f5f5;      }      .container {          width800px;          height700px;          display: flex;          flex-direction: column;          background#fff;          overflow: hidden;          border1px solid #eee;      }      .header {          height50px;          background#fff;          color#333;          display: flex;          align-items: center;          justify-content: center;          font-size16px;          font-weight500;          border-bottom1px solid #eee;          box-shadow0 1px 2px rgba(0000.03);      }      .chat-area {          flex1;          overflow-y: auto;          padding16px;          background#EDEDED;      }      .chat-area::-webkit-scrollbar {          width6px;      }      .chat-area::-webkit-scrollbar-track {          background: transparent;      }      .chat-area::-webkit-scrollbar-thumb {          background#ddd;          border-radius3px;      }      .chat-area::-webkit-scrollbar-thumb:hover {          background#bbb;      }      .msg {          display: flex;          align-items: center;          margin-bottom14px;      }      .msg.me {          justify-content: flex-end;      }      .avatar {          width36px;          height36px;          border-radius4px;          display: flex;          align-items: center;          justify-content: center;          color#fff;          font-size14px;          margin0 8px;          flex-shrink0;          font-weight500;      }      .msg.me .avatar {          order2;          margin-left8px;          margin-right0;      }      .bubble {          max-width75%;          padding10px 14px;          border-radius4px;          font-size15px;          line-height1.4;          word-break: break-word;      }      .msg.me .bubble {          background#95EC69;          color#fff;          border-bottom-right-radius4px;      }      .msg.other .bubble {          background#fff;          border-bottom-left-radius4px;          box-shadow0 1px 2px rgba(0000.05);      }      .at {          color#3462E3;      }      .footer {          background#fff;          padding14px 16px;          display: flex;          align-items: center;          border-top1px solid #eee;      }      .input-box {          flex1;          position: relative;      }      #input {          width100%;          min-height40px;          max-height120px;          padding10px 14px;          border1px solid #e5e5e5;          border-radius4px;          font-size15px;          resize: none;          overflow-y: auto;          background#f8f8f8;          display: block;          box-sizing: border-box;          transition: border-color 0.2s;      }      #input:focus {          outline: none;          border-color#07c160;          box-shadow0 0 0 2px rgba(7193960.1);      }      #sendBtn {          margin-left14px;          padding10px 20px;          background#07c160;          color#fff;          border: none;          border-radius2px;          font-size15px;          cursor: pointer;          transition: all 0.2s;          font-weight500;      }      #sendBtn:hover:not(:disabled) {          background#06a050;      }      #sendBtn:disabled {          background#ccc;          cursor: not-allowed;      }      .at-list {          position: absolute;          bottomcalc(100% + 8px);          left0;          width220px;          max-height200px;          overflow-y: auto;          background#fff;          border1px solid #e5e5e5;          border-radius8px;          display: none;          z-index100;          box-shadow0 4px 14px rgba(0000.1);      }      .at-list::-webkit-scrollbar {          width6px;      }      .at-list::-webkit-scrollbar-track {          background: transparent;      }      .at-list::-webkit-scrollbar-thumb {          background#ddd;          border-radius3px;      }      .at-list::-webkit-scrollbar-thumb:hover {          background#bbb;      }      .at-item {          padding10px 14px;          font-size14px;          cursor: pointer;          transition: background-color 0.2s;      }      .at-item:hover {          background#f5f5f5;      }      .at-item.on {          background#e9f7ef;          color#07c160;      }  </style></head><body><div class="container">  <div class="header">群聊</div>  <div class="chat-area" id="chatArea"></div>  <div class="footer">    <div class="input-box">      <textarea id="input" placeholder="输入消息..." rows="1"></textarea>      <div class="at-list" id="atList"></div>    </div>    <button id="sendBtn">发送</button>  </div></div>
<script>  const chatArea = document.getElementById('chatArea');  const input = document.getElementById('input');  const atList = document.getElementById('atList');  const sendBtn = document.getElementById('sendBtn');  const members = [    {name'张三'wx'zs001'}, {name'李四'wx'ls002'},    {name'王五'wx'ww003'}, {name'赵六'wx'zl004'},    {name'钱七'wx'qq005'}, {name'孙八'wx'sb006'},    {name'周九'wx'zj007'}, {name'吴十'wx'ws008'}  ];  const me = '张三';  let msgs = [    {who'李四'txt'大家好,今天我们要讨论新功能的开发计划'},    {who'王五'txt'@张三 产品需求文档准备好了吗?'},    {who'赵六'txt'UI设计稿已经发给大家了,请查收'}  ];
  let atMode = false, atQuery = '', atSel = -1, sending = false;
  function nameColor(name) {    let hash = 0;    for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);    const hue = Math.abs(hash) % 360;    return `hsl(${hue}, 60%, 60%)`;  }  // 渲染单条消息  function appendMsg(m, scroll = true) {    const isMe = m.who === me;    const div = document.createElement('div');    div.className = 'msg ' + (isMe ? 'me' : 'other');    const html = escapeHtml(m.txt).replace(/@(\S+)/g(s, name) => members.some(mm => mm.name === name) ? `<span class="at">@${name}</span>` : s);    div.innerHTML = isMe ? `<div class="bubble">${html}</div>
${nameColor(m.who)}">${m.who.charAt(0)}</div>` :      `<div class="avatar" style="background:${nameColor(m.who)}">${m.who.charAt(0)}</div>
${html}</div>`;    chatArea.appendChild(div);    if (scroll) chatArea.scrollTop = chatArea.scrollHeight;  }  // 初次批量渲染  function renderMsgs() {    chatArea.innerHTML = '';    msgs.forEach(m => appendMsg(m, false));    chatArea.scrollTop = chatArea.scrollHeight;  }  function escapeHtml(str) {    return str.replace(/[&<>"']/gs => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[s]));  }  // 简化输入事件处理  input.addEventListener('input'e => {    autoHeight();    handleAtMode();    requestAnimationFrame(() => autoHeight());  });  // 统一处理@模式检测  function handleAtMode() {    const val = input.value, cur = input.selectionStart;    const last = val.lastIndexOf('@', cur - 1);    if (last === -1 || val.substring(last + 1, cur).includes(' ')) {      hideAt();      return;    }    atQuery = val.substring(last + 1, cur);    showAt();  }  // 按键处理  input.addEventListener('keydown'e => {    if (e.key === 'Enter' && !e.shiftKey) {      if (atMode) {        e.preventDefault();        const users = filterUsers();        if (atSel >= 0 && users.length > 0pickAt(users[atSel].name);        return;      }      e.preventDefault();      send();    } else if (atMode && ['ArrowDown''ArrowUp''Escape'].includes(e.key)) {      e.preventDefault();      const users = filterUsers();      if (users.length === 0return;      if (e.key === 'ArrowDown') atSel = (atSel + 1) % users.length;      else if (e.key === 'ArrowUp') atSel = (atSel - 1 + users.length) % users.length;      else if (e.key === 'Escape'return hideAt();      renderAt();    }  });
  document.addEventListener('click'e => {    if (!e.target.closest('.input-box')) hideAt();  });
  const filterUsers = () => members.filter(m => m.name.includes(atQuery) && m.name !== me);
  function showAt() {    atMode = true;    const users = filterUsers();    atSel = users.length > 0 ? 0 : -1;    renderAt();    atList.style.display = 'block';  }
  function hideAt() {    atMode = false;    atList.style.display = 'none';    atQuery = '';    atSel = -1;  }
  function renderAt() {    const arr = filterUsers();    if (arr.length === 0return hideAt();    atSel = Math.max(0Math.min(atSel, arr.length - 1));    atList.innerHTML = arr.map((m, i) => `<div class="at-item ${i === atSel ? 'on' : ''}" data-name="${m.name}">@${m.name}</div>`).join('');    const selectedItem = atList.querySelector('.at-item.on');    if (selectedItem) {      const itemTop = selectedItem.offsetTop;      const itemBottom = itemTop + selectedItem.offsetHeight;      const listHeight = atList.clientHeight;      const listScrollTop = atList.scrollTop;      if (itemTop < listScrollTop) atList.scrollTop = itemTop;      else if (itemBottom > listScrollTop + listHeight) atList.scrollTop = itemBottom - listHeight;    }  }
  atList.addEventListener('click'e => {    const item = e.target.closest('.at-item');    if (item) pickAt(item.dataset.name);  });
  function pickAt(name) {    const val = input.value;    const cur = input.selectionStart;    let lastAt = -1;    for (let i = cur - 1; i >= 0; i--) {      if (val[i] === '@') {        lastAt = i;        break;      } else if (val[i] === ' ') {        break;      }    }    if (lastAt === -1return;    const before = val.substring(0, lastAt);    const after = val.substring(cur);    input.value = before + '@' + name + ' ' + after;    const newCursorPos = lastAt + name.length + 2;    input.setSelectionRange(newCursorPos, newCursorPos);    input.focus();    hideAt();    autoHeight();  }
  const autoHeight = () => {    input.style.height = 'auto';    input.style.height = Math.min(input.scrollHeight120) + 'px';  };  // 发送  sendBtn.addEventListener('click', send);
  async function send() {    if (sending) return;    const txt = input.value.trim();    if (!txt) return;    sending = true;    sendBtn.disabled = true;    const m = { who: me, txt };    msgs.push(m);    appendMsg(m);    input.value = '';    input.style.height = 'auto';    hideAt();    setTimeout(() => {      sendBtn.disabled = false;      sending = false;    }, 100);  }
  renderMsgs();</script></body></html>

阅读原文:https://mp.weixin.qq.com/s/Gfk7gw0iISBOan0MLjcLrw


该文章在 2025/12/31 10:49:38 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved