🛠 聊天框实现逻辑

← 返回回声洞

整体架构

选择皮肤
内置 6 款 / 广场 / 自定义
保存全局
写入 app_config(active_skin)
所有人拉取
打开页面时读取全局皮肤并应用
注入 <style>
CSS 写进 style 标签,覆盖默认样式

皮肤是个人专属的:应用皮肤时会记录作者昵称,页面拉取后只给该作者的消息渲染皮肤(别人和 AI 的消息保持默认)。CSS 选择器会被自动作用域化:.msg.msg.skin-author#chat / body 背景规则自动失效(避免影响他人视角)。

核心:作用域化

function scopeSkinCSS(css){
  return css
    .replace(/body(?![a-zA-Z-])/g, 'body.skin-ignored')   // body 背景失效
    .replace(/#chat/g, '#chat.skin-ignored')           // #chat 背景失效
    .replace(/\.msg(?![-a-zA-Z])/g, '.msg.skin-author'); // 只作用于作者消息
}
// 渲染时:只有作者的 .msg 带 skin-author 类
const isSkinAuthor = activeSkinCss && activeSkinAuthor && nick === activeSkinAuthor;
div.className = 'msg ' + (isSelf?'self':'other') + (isSkinAuthor?' skin-author':'');

全局皮肤:保存与拉取

// 保存全局皮肤(附作者,只渲染 TA 的消息)
async function saveActiveSkin(obj){
  if(nickname) obj.author = nickname;   // 记录作者
  const value = JSON.stringify(obj);
  const rows = await fetch(.../app_config?key=eq.active_skin, {headers: HEADERS}).then(r=>r.json());
  if(rows.length) {
    await fetch(.../app_config?key=eq.active_skin, {method:'PATCH', headers:HEADERS, body:JSON.stringify({value})});
  } else {
    await fetch(.../app_config, {method:'POST', headers:HEADERS, body:JSON.stringify({key:'active_skin', value})});
  }
}

// 页面加载时拉取全局皮肤(所有人一致)
async function loadActiveSkin(){
  try{
    const rows = await fetch(.../app_config?key=eq.active_skin&select=value, {headers:HEADERS}).then(r=>r.json());
    if(rows.length){
      const obj = JSON.parse(rows[0].value);   // {type:'builtin',name} | {type:'market',id} | {type:'css',css}
      applySkinObject(obj);
    }
  } catch(e){ /* 失败回退默认 */ }
}

function applySkinObject(obj){
  if(obj.type === 'css') applySkinCSS(obj.css);
  else if(obj.type === 'market') applySkin(String(obj.id));
  else applySkin(obj.name || 'default');
}

核心:皮肤应用

选择皮肤后,把 CSS 注入到页面。失败自动回退默认:

function applySkin(name){
  let css = '';
  if(name === 'default'){ css = ''; }
  else if(BUILTIN_SKINS[name]){ css = BUILTIN_SKINS[name].css; }
  else{
    // 广场皮肤:从数据库拉取(已缓存到 marketSkins)
    const found = marketSkins.find(s => String(s.id) === String(name));
    if(found) css = found.css;
    else throw new Error('skin not found');
  }

  let styleEl = document.getElementById('skin-style');
  if(css){
    if(!styleEl){ styleEl = document.createElement('style'); styleEl.id='skin-style'; document.head.appendChild(styleEl); }
    styleEl.textContent = css;      // 注入皮肤 CSS
  } else if(styleEl){ styleEl.remove(); }  // 默认皮肤:移除

  localStorage.setItem('echo_skin', name);  // 记住选择
}

核心:实时预览(iframe 隔离)

编辑器输入 → 400ms 防抖 → 生成完整 HTML → 塞进 srcdoc 刷新预览。iframe 用 sandbox 隔离,用户 CSS 里的恶意内容也无法逃逸:

function buildPreviewHTML(css){
  return `<!DOCTYPE html><html><head><style>
    ...基础样式...
    /* ===== USER SKIN ===== */
    ${css}      <-- 用户代码原样注入 -->
  </style></head><body><div id="chat">
    三条示例消息(self / other / ai)
  </div></body></html>`;
}
// 防抖更新
editor.addEventListener('input', ()=>{
  clearTimeout(timer);
  timer = setTimeout(updatePreview, 400);
});
function updatePreview(){
  iframe.srcdoc = buildPreviewHTML(editor.value);
}

核心:消息渲染结构

每条消息按固定 DOM 结构渲染,皮肤 CSS 就是围绕这套结构写选择器:

function addMessage(nick, text, time, isSelf, msgId, deleted, doNotify, replyTo){
  const div = document.createElement('div');
  div.className = 'msg ' + (isSelf ? 'self' : (isAI ? 'ai' : 'other'));

  const meta = document.createElement('div');
  meta.className = 'meta';
  const nickSpan = document.createElement('span');
  nickSpan.className = 'nick' + (isAdmin ? ' admin' : '');
  nickSpan.textContent = nick;
  meta.appendChild(nickSpan);

  const bubble = document.createElement('div');
  bubble.className = 'bubble';
  bubble.innerHTML = isRecalled ? escape(nick)+':消息已撤回' : renderBubbleHTML(text);

  div.appendChild(meta);
  div.appendChild(bubble);
  chat.appendChild(div);
  scrollToBottom();
  return div;
}

核心:皮肤数据与发布

内置皮肤是代码内的对象;用户发布的皮肤走 Supabase 数据库,别人打开页面时拉取列表:

const BUILTIN_SKINS = {
  'default':  { name:'默认',     css:'' },
  'ocean':    { name:'海洋蓝调', css:`#chat{...}` },
  'amethyst': { name:'暗夜紫晶', css:`...` },
  'macaron':  { name:'马卡龙甜', css:`...` },
  'minimal':  { name:'极简白',   css:`...` },
  'skeuo':    { name:'拟物化',   css:`...` }
};

// 拉取广场(失败静默 → 空列表,不影响使用)
async function loadMarketSkins(){
  try{
    const r = await fetch(SUPABASE_URL + '/rest/v1/chat_skins?select=id,name,author,css&order=created_at.desc', { headers: HEADERS });
    marketSkins = await r.json();
  } catch(e){ marketSkins = []; }  // 失败降级
}

// 发布:POST 到 chat_skins 表
async function publishSkin(){
  await fetch(SUPABASE_URL + '/rest/v1/chat_skins', {
    method: 'POST', headers: HEADERS,
    body: JSON.stringify({ name, author, css })
  });
}

安全设计

只允许 CSS:编辑器是纯 CSS,不执行 JS,从源头杜绝脚本注入

iframe 沙箱:预览区用 sandbox="allow-same-origin",即使 CSS 有异常也不影响主页面

选择器受限:教程引导用户只用预定义类名,避免样式污染其他区域

渲染失败回退:广场皮肤拉取失败/不存在 → 自动使用默认皮肤,不影响聊天

数据库表结构

CREATE TABLE chat_skins (
  id BIGSERIAL PRIMARY KEY,
  name TEXT,
  author TEXT,
  css TEXT,                 -- 用户写的 CSS
  built_in BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE app_config (
  key TEXT PRIMARY KEY,     -- 'active_skin':当前全局皮肤
  value TEXT,               -- JSON:{type,name} / {type,id} / {type,css}
  updated_at TIMESTAMPTZ DEFAULT now()
);