皮肤是个人专属的:应用皮肤时会记录作者昵称,页面拉取后只给该作者的消息渲染皮肤(别人和 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); // 记住选择 }
编辑器输入 → 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() );