250 lines
13 KiB
Markdown
250 lines
13 KiB
Markdown
|
|
# 设计文档:客服挂件动态主题色(品牌色)
|
|||
|
|
|
|||
|
|
- 日期:2026-08-31
|
|||
|
|
- 状态:设计定稿,待实施
|
|||
|
|
- 分支:chany_dev_dev
|
|||
|
|
|
|||
|
|
## 1. 背景与目标
|
|||
|
|
|
|||
|
|
当前客服挂件(widget.js)与独立聊天页(chat.html)的配色为固定橙色,
|
|||
|
|
约 40 处(widget.js)+ 24 处(style.css)硬编码色值散落在样式字符串/CSS 中。
|
|||
|
|
|
|||
|
|
目标:像 Intercom / WhatsApp Business 那样,让每个嵌入网站传入自己的品牌色,
|
|||
|
|
整套配色(深色文字、浅背景、边框、阴影、渐变)自动从这一个颜色派生,
|
|||
|
|
不传则回退默认橙,视觉与现状一致(唯一例外见 §9.1 Send 渐变简化)。
|
|||
|
|
|
|||
|
|
### 非目标
|
|||
|
|
|
|||
|
|
- 不做多站点/多租户后台配置(单租户部署,嵌入方在 data 属性里自行控制)
|
|||
|
|
- 不做完整 dark 模式(现状 `data-theme="dark"` 本就只影响气泡/面板背景,维持现状)
|
|||
|
|
- 不做品牌色之外的多色自定义(YAGNI,派生已覆盖)
|
|||
|
|
- admin.html(内部后台,蓝色系)不在范围内
|
|||
|
|
|
|||
|
|
## 2. 需求决策记录
|
|||
|
|
|
|||
|
|
| 决策点 | 结论 |
|
|||
|
|
|---|---|
|
|||
|
|
| 配置入口 | 嵌入方 script 标签 `data-color` 属性;独立页 URL 参数 `?color=` |
|
|||
|
|
| 生效范围 | widget.js 挂件 + chat.html 独立聊天页 |
|
|||
|
|
| 颜色派生 | 单品牌色 + HSL 规则自动派生全套色板 |
|
|||
|
|
| 技术路线 | CSS 自定义属性(语义槽位)+ JS 派生注入(方案 A) |
|
|||
|
|
| Send 渐变 | 接受简化:与邀请卡渐变统一,近乎纯色(用户已确认) |
|
|||
|
|
| 输入格式 | 仅 3/6 位 hex(`#` 可选);其他格式告警并回退默认 |
|
|||
|
|
| 头像图标 | 🤖 emoji 全部替换为图片 `static/kefu.png`,挂件侧支持 `data-avatar` 覆盖(默认 kefu-small.png,原图 kefu.png 为素材),详见 §12 |
|
|||
|
|
|
|||
|
|
## 3. 架构与数据流
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
客户网站
|
|||
|
|
┌──────────────────────────────────────┐
|
|||
|
|
│ <script src=".../static/widget.js" │
|
|||
|
|
│ data-color="#1a73e8"> │
|
|||
|
|
│ │
|
|||
|
|
│ widget.js(自包含,内联派生函数) │
|
|||
|
|
│ 1. 读 data-color(缺失/非法→默认) │
|
|||
|
|
│ 2. derivePalette(hex) → 7 槽位色板 │
|
|||
|
|
│ 3. Shadow DOM style 字符串头部注入 │
|
|||
|
|
│ :host { --w-primary: ...; ... } │
|
|||
|
|
│ 4. 全部 CSS 引用 var(--w-*) │
|
|||
|
|
└──────────────────────────────────────┘
|
|||
|
|
|
|||
|
|
独立聊天页(我们部署)
|
|||
|
|
chat.html?color=1a73e8
|
|||
|
|
→ static/js/theme.js(同一派生函数)
|
|||
|
|
→ 写入 document.documentElement(:root)变量
|
|||
|
|
→ style.css 中 :root 自带默认橙字面量(JS 失败也有兜底)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
要点:
|
|||
|
|
|
|||
|
|
- widget.js 保持**单文件自包含**(嵌入方只加一个 script 标签,不多发请求),
|
|||
|
|
派生函数在 widget.js 内联一份;独立页用 `static/js/theme.js` 共享一份。
|
|||
|
|
两份同步由 pytest 源码级断言保证(§7.2)。
|
|||
|
|
- 变量块与样式字符串**同一次注入**,结构上不存在"挂件显示了但变量丢了"的中间态。
|
|||
|
|
- 后端零改动:WebSocket/REST 协议、会话、邀请卡、流式逻辑全部不动。
|
|||
|
|
|
|||
|
|
## 4. 语义槽位清单(7 个)
|
|||
|
|
|
|||
|
|
现状 20 个相近色值合并为 7 个语义槽位:
|
|||
|
|
|
|||
|
|
| 槽位 | 默认值(现状) | 吸收的现状颜色 | 用途 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `--w-primary` | `#f08a31` | 原值 | 头部背景、按钮、头像、分隔线、用户气泡 |
|
|||
|
|
| `--w-primary-rgb` | `240,138,49` | `rgba(240,138,49,α)`、`rgba(196,91,25,.22)` | 阴影色(alpha 留在 CSS 原处) |
|
|||
|
|
| `--w-gradient-end` | 派生 | `#F08A34→#EC8C2F`、`#f59e0b` | 渐变副色(两处渐变统一) |
|
|||
|
|
| `--w-deep-text` | `#8C4D1D` | `#8C4D1D`、`#A8591F` | 图标描边(SVG→`currentColor`)、按钮文字、移动端标签 |
|
|||
|
|
| `--w-caption-text` | `#C16C2E` | `#C16C2E`、`#d97706` | POPULAR TOPICS 标签、页脚小字 |
|
|||
|
|
| `--w-soft-bg` | `#FFF8F3` | `#fef9f3`、`#FFF9F4`、`#FFF7F1`、`#faf7f3` | 卡片底、输入框底、hover 背景 |
|
|||
|
|
| `--w-border-soft` | `#F0D5BF` | `#EECFB6`、`#E8C4A9`、`#F1CCAE`、`#fef3c7` | 按钮/卡片/邀请卡描边 |
|
|||
|
|
|
|||
|
|
**不派生、保持固定**(语义色而非品牌色):在线绿点 `#22c55e`、AI 气泡灰
|
|||
|
|
`#f3f4f6`、深灰文字 `#1f2937`、结束横幅绿 `#34a853`、白色遮罩 `rgba(255,255,255,x)`。
|
|||
|
|
|
|||
|
|
## 5. 派生规则(derivePalette,纯函数)
|
|||
|
|
|
|||
|
|
实测现状橙 `#f08a31` = hsl(28, 86%, 57%),各派生色与主色的关系:
|
|||
|
|
|
|||
|
|
| 槽位 | 规则(相对输入 h,s,l) | 亮度钳制 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| primary | 原样 | 不动(品牌色必须精确) |
|
|||
|
|
| gradient-end | h+2, s 不变, l-1 | — |
|
|||
|
|
| deep-text | h-2, s×0.76, l-24 | l ∈ [28, 40] |
|
|||
|
|
| caption-text | h-2, s×0.72, l-10 | l ∈ [40, 52] |
|
|||
|
|
| soft-bg | h 不变, s×0.9, l=97.5 | l 固定 |
|
|||
|
|
| border-soft | h-2, s×0.72, l=84 | l 固定 |
|
|||
|
|
|
|||
|
|
- 亮度钳制保证**任意色相输入都产出可读配色**(蓝/绿/紫/红均安全)
|
|||
|
|
- 色相加减需 mod 360(红区 h≈0/360 边界)
|
|||
|
|
- 默认输入 `#f08a31` 的派生结果与现状各槽位代表值偏差 ≤ 每通道 8/255
|
|||
|
|
- border-soft 参数为实测校准值:原设计 `s×0.75, l=82` 对默认橙派生的
|
|||
|
|
蓝通道与现状代表值差 12,超出每通道 8/255 容差,校准为 `s×0.72, l=84`
|
|||
|
|
|
|||
|
|
## 6. 兜底策略(4 层)
|
|||
|
|
|
|||
|
|
| 层 | 场景 | 行为 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| ① 属性缺失 | 无 `data-color` / `?color=` | `#f08a31` 走同一条派生管线(默认是一等公民,非特例) |
|
|||
|
|
| ② 格式非法 | 非 3/6 位 hex | `console.warn` + 回退默认 |
|
|||
|
|
| ③ 派生异常 | 意外输入绕过校验、HSL 计算抛错 | derivePalette 整体 try-catch,catch 返回**预计算静态色板常量**(字面量,零计算) |
|
|||
|
|
| ④ 结构性 | — | widget.js:变量与样式同字符串同注入;chat.html:style.css `:root` 自带默认字面量,JS 全挂也正常显示默认橙 |
|
|||
|
|
|
|||
|
|
不存在"未设置颜色导致挂件不显示/无样式白板"的路径。
|
|||
|
|
|
|||
|
|
## 7. 文件级改动清单
|
|||
|
|
|
|||
|
|
| 文件 | 改动 |
|
|||
|
|
|---|---|
|
|||
|
|
| `static/js/theme.js`(新,~60 行) | derivePalette 纯函数、DEFAULT_COLOR、静态 DEFAULT_PALETTE 常量 |
|
|||
|
|
| `static/widget.js` | ① 内联同款派生函数+静态色板;② style 字符串 ~40 处换 `var(--w-*)`;③ 头部注入 `:host` 变量块;④ SVG `stroke="#8C4D1D"` → `currentColor`(父级已设 color);⑤ toggle 模板 inline `#f08a31` 移入 CSS 类;⑥ 4 处 🤖 emoji → `<img>` 头像(§12) |
|
|||
|
|
| `static/css/style.css` | `:root` 补全 7 槽位默认值,清除散落硬编码(`.chat-header` 等);清理旧变量:`--primary`/`--primary-dark` 为死代码(无规则引用)直接删除,`--user-bubble` 被 `.msg.user` 引用,迁移为 `var(--w-primary)`,避免两套变量并存 |
|
|||
|
|
| `static/chat.html` | 引入 theme.js;SVG → currentColor;头部 🤖 → `<img>` 头像(§12) |
|
|||
|
|
| `static/js/chat.js` | 头部 ~10 行 bootstrap:读 `?color=` → 派生 → `documentElement.style.setProperty`(chat.js 现无任何颜色代码,已核实) |
|
|||
|
|
| `static/widget-test.html` | 颜色选择器 + 预设色(橙/蓝/绿/紫)动态重建 script 标签;内嵌断言面板 |
|
|||
|
|
| `docs/2026-08-31-dynamic-theme-changelog.md` | 按仓库惯例记录改动与嵌入方用法(`data-color` 使用说明) |
|
|||
|
|
|
|||
|
|
### 7.1 测试页重建陷阱(实施注意)
|
|||
|
|
|
|||
|
|
widget-test.html 动态换色预览时,必须先移除旧 `#cc-widget-host`、
|
|||
|
|
移除旧 script 标签、清掉 sessionStorage 的邀请卡标记
|
|||
|
|
(`widget_invite_dismissed_<pathname>`),再注入新 script;
|
|||
|
|
否则重复挂载/邀请卡不弹,会误判为功能损坏。
|
|||
|
|
|
|||
|
|
### 7.2 两份拷贝防漂移(同步断言)
|
|||
|
|
|
|||
|
|
pytest 用正则分别提取 widget.js 与 theme.js 中的 `derivePalette`
|
|||
|
|
函数源码,归一化空白后要求**逐字相等**——单边改动必红。
|
|||
|
|
(比"比对规则常量"强,算法级修改也无法漏检。)
|
|||
|
|
|
|||
|
|
## 8. 测试与验收
|
|||
|
|
|
|||
|
|
1. `tests/test_theme_static.py`(pytest,随现有测试套件本地跑;仓库暂无 CI 配置,未来接入时一并纳入):
|
|||
|
|
- 扫描 widget.js / style.css:所有 `var(--w-*)` 引用均有定义(防漏注入)
|
|||
|
|
- 橙色字面量只允许出现在默认值定义/静态色板/断言用例处(防漏改残留,带行级允许清单;扫描范围仅 widget.js 与 style.css——widget-test.html 自身的 `#f08a31` 不在此网内,随该页重写自然消化)
|
|||
|
|
- 两份 derivePalette 源码归一化后逐字相等(§7.2)
|
|||
|
|
2. widget-test.html 断言面板(浏览器跑,PASS/FAIL 可视):
|
|||
|
|
`derivePalette("#f08a31")` 输出 vs 现状硬编码值表,偏差容忍 ≤ 每通道 8/255
|
|||
|
|
3. 手动验收:
|
|||
|
|
- 默认不传参,改造前后截图对比,除 §9.1 外无可见差异
|
|||
|
|
- 蓝/绿各截一组(挂件+邀请卡+独立页)确认派生效果
|
|||
|
|
- 非法色值(`data-color="red"`)回退默认橙
|
|||
|
|
|
|||
|
|
## 9. 已知限制与风险
|
|||
|
|
|
|||
|
|
### 9.1 Send 按钮渐变简化(唯一默认视觉可见变化)
|
|||
|
|
|
|||
|
|
现状 Send 按钮 `#f08a31→#f59e0b`(双色相差 10°),统一为
|
|||
|
|
`primary→gradient-end` 后接近平纯色。用户已确认接受;
|
|||
|
|
实施后截图验收,若不满意再拆独立槽位 `--w-gradient-accent`(h+8, l-3)。
|
|||
|
|
|
|||
|
|
### 9.2 其余已知项
|
|||
|
|
|
|||
|
|
- 槽位合并决定默认模式是"肉眼不可辨"(ΔRGB≤8/通道),非像素级一致;
|
|||
|
|
若个别合并处(如帮助块边框 `#fef3c7`)实际可见,拆回独立槽位
|
|||
|
|
- caption 色现状 ~3.9:1 本就低于 WCAG AA,派生钳制保持同水平,非回归;
|
|||
|
|
可选把钳制上限收到 48 略改善(实施时定)
|
|||
|
|
- 极浅品牌色(l>65)配白字对比度低,v1 不做自动黑白切换,文档注明
|
|||
|
|
- 视觉正确性最终靠人工截图对比,浏览器断言面板不进 CI(项目无 Node 设施的现实妥协)
|
|||
|
|
|
|||
|
|
## 10. 回滚
|
|||
|
|
|
|||
|
|
纯静态文件改动(widget.js/theme.js/style.css/chat.html/chat.js/
|
|||
|
|
新增 kefu-small.png),无数据迁移、无协议变更;`git revert` 即完整回滚。
|
|||
|
|
(kefu.png 素材已在库中,不涉回滚。)
|
|||
|
|
|
|||
|
|
另:chat.html 第 50 行存在一处注释掉的 `<div class="avatar">🤖</div>`
|
|||
|
|
(零行为影响),实施时顺手删除。
|
|||
|
|
|
|||
|
|
## 11. 明确排除的风险面(不因本设计引入)
|
|||
|
|
|
|||
|
|
- 已嵌入客户网站无需改嵌入代码(不传 `data-color` 即默认橙,向后兼容)
|
|||
|
|
- widget.js URL 不变;浏览器旧缓存=旧样式自包含文件,无新旧行为撕裂
|
|||
|
|
- 聊天功能(WebSocket 消息、会话、邀请卡、断线重连)零改动
|
|||
|
|
|
|||
|
|
## 12. 增补:客服头像图片化(2026-08-31)
|
|||
|
|
|
|||
|
|
用图片替换所有 🤖 emoji 头像。素材:`static/kefu.png`
|
|||
|
|
(1297×1213,RGBA 透明底,近方形)。
|
|||
|
|
|
|||
|
|
### 12.1 替换位置(5 处,全部当前为 🤖)
|
|||
|
|
|
|||
|
|
| 位置 | 文件 | 容器现状 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 聊天面板头部头像 | widget.js `.panel-header .avatar` | 48px 圆,rgba 白底,::after 绿点 |
|
|||
|
|
| 邀请卡图标 | widget.js `.invite-icon` | 40px 圆,rgba 白底,.invite-status-dot 绿点 |
|
|||
|
|
| 桌面 toggle 按钮 | widget.js inline `36px` 圆 | inline 样式,绝对定位绿点 |
|
|||
|
|
| 移动端 toggle 按钮 | widget.js `.mobile-circle` | 56px 圆,橙底,.mobile-dot 绿点 |
|
|||
|
|
| 独立页头部头像 | chat.html `.chat-header .avatar` | 48px 圆,rgba 白底,::after 绿点 |
|
|||
|
|
|
|||
|
|
(消息气泡 `.msg-wrapper .avatar` 的 CSS 存在但当前未渲染任何元素,不在替换范围。)
|
|||
|
|
|
|||
|
|
### 12.2 URL 解析(关键差异)
|
|||
|
|
|
|||
|
|
- **widget.js 嵌入在客户网站,相对路径会 404**:头像 src 必须以
|
|||
|
|
`wsBase`(`new URL(script.src).origin`,代码已有)拼接默认路径;
|
|||
|
|
单一常量 `DEFAULT_AVATAR_PATH = "/static/kefu-small.png"` 落在挂件侧
|
|||
|
|
widget.js(跨域场景所在)(见 §12.6)
|
|||
|
|
- chat.html 为同源直接字面量 `/static/kefu-small.png`,不跨文件共享常量
|
|||
|
|
|
|||
|
|
### 12.3 可配置性
|
|||
|
|
|
|||
|
|
- 挂件新增 `data-avatar` 属性(与 data-color 同模式):
|
|||
|
|
值为完整 URL(http/https 开头)则原样使用;否则视为服务端相对路径按 wsBase 拼接;
|
|||
|
|
缺省 → 默认 `kefu-small.png`(由原图 kefu.png 压缩派生,§12.6)
|
|||
|
|
- 独立页不做 `?avatar=` 参数(我们自己的页面,YAGNI)
|
|||
|
|
|
|||
|
|
### 12.4 兜底(与本文档分层兜底哲学一致)
|
|||
|
|
|
|||
|
|
emoji 不删除,作为 `<img>` 的底层兄弟节点保留;`<img onerror>` 时移除自身
|
|||
|
|
→ 🤖 露出。覆盖场景:客户站 CSP `img-src` 拦截、图片 404、网络失败。
|
|||
|
|
实际遮盖机制(实施校准):load 成功后隐藏 emoji 兄弟节点;error 移除 img
|
|||
|
|
露出 emoji——透明底素材无法物理遮盖,绝对定位(§12.5)仅为视觉层叠。
|
|||
|
|
|
|||
|
|
定位上下文核查(复审结论):5 个容器中 4 个已是 `position:relative`;
|
|||
|
|
桌面 toggle 的 36px 内圆(inline)无定位,其外层 wrapper 才是 relative——
|
|||
|
|
实施时给该内圆补 `position:relative`,否则 img 的 `inset:0` 会锚到
|
|||
|
|
wrapper(box 恰好重合但不显式,易错位)。
|
|||
|
|
|
|||
|
|
### 12.5 CSS 与视觉
|
|||
|
|
|
|||
|
|
- `<img>` 规则:`position:absolute; inset:0; width/height:100%;
|
|||
|
|
object-fit:contain; border-radius:50%`
|
|||
|
|
(img 绝对定位叠于 emoji 之上,load 后 emoji 隐藏,见 §12.4;透明底近方形素材用 contain 不裁切;
|
|||
|
|
容器圆底色透过透明区显示,任意主题色下均成立——头像不参与颜色派生)
|
|||
|
|
- 各绿点(在线状态)为绝对定位兄弟节点/伪元素,不受 emoji→img 替换影响
|
|||
|
|
- alt=""(装饰性),避免读屏重复播报
|
|||
|
|
|
|||
|
|
### 12.6 素材体积(已定决策,非建议)
|
|||
|
|
|
|||
|
|
原图 682KB/1297px,而最大显示尺寸为移动端圆 56px(@3x≈168px),对挂件过重。
|
|||
|
|
实施时产出压缩版 `static/kefu-small.png`(168×168,保留透明通道)供代码
|
|||
|
|
统一引用(§12.2 常量);`kefu.png` 原图保留不动,仅作素材。
|
|||
|
|
若倾向直接用原图,改 `DEFAULT_AVATAR_PATH` 一行常量即可。
|
|||
|
|
|
|||
|
|
### 12.7 测试增补
|
|||
|
|
|
|||
|
|
- pytest 静态断言:widget.js 中头像 src 必须经 wsBase 拼接
|
|||
|
|
(防相对路径在客户站 404 的回归)
|
|||
|
|
- widget-test.html:4 处挂件头像 + data-avatar 覆盖用例 +
|
|||
|
|
故意错误 URL 验证 emoji 兜底
|
|||
|
|
- 手动截图:默认橙 + 蓝/绿主题各确认头像与绿点位置正常
|