记录 ChatWidget.astro 这个悬浮水滴桌宠的完整实现:它在整站架构里的位置、 内部五层结构(SVG 视觉层 → CSS 状态机 → JS 编排层 → i18n 内容层 → 后端问答服务)、 每个子系统的关键代码,以及把这套模式搬到另一个项目时可以直接照做的复用清单。
网站是一个 Astro 7 静态站点,按 [lang] 目录做三语路由(en 默认 / zh / fr), 所有页面共享同一个 Layout.astro 外壳。桌宠不是某个页面的内容, 而是挂在这层共享 Layout 里的一个全局悬浮组件 —— 每一种语言、每一张页面都会渲染它。
关键点:桌宠不是页面级组件,改一次 ChatWidget.astro 全站生效; 它和 SearchModal 一样通过 lang prop 拿到当前语言, 自己内部再用 useTranslations(lang) 取文案 —— 与页面内容走的是同一套 i18n 管线。
单个 .astro 文件里其实叠着五层,越往下越"慢"、越不常触发。 自上而下:视觉层(纯 SVG,被动)→ CSS 状态机(类名开关,被动响应)→ JS 编排层(定时器 + 互斥判断,主动决策)→ 内容层(i18n 文案池)→ 后端(只在用户主动点开面板时才唤醒)。
整个形象是一份 viewBox="0 0 64 64" 的手写 SVG,没有任何位图资源。 主体渐变、光泽、耳朵高光都是叠加的 <path>/<ellipse> 加线性 / 径向渐变, 颜色全部走 CSS 变量(--pet-light / --pet-mid / --pet-deep 等),亮 / 暗主题各定义一套。
图层顺序(文档序即绘制顺序,后画的盖在先画的上面):
| 顺序 | 元素 | 常驻 / 皮肤 | 说明 |
|---|---|---|---|
| 1 | .desk-pet-legs | 皮肤 | 细腿 + 脚掌,仅 is-walking 时显示,走路时颔首摆动 |
| 2 | .desk-pet-drop-body | 常驻 | 水滴主体,线性渐变 + 暗部径向渐变叠加出立体感 |
| 3 | .desk-pet-drop-ear-l/r | 常驻 | 玻璃质感双耳,92% 不透明度 + 高光条纹 |
| 4 | .desk-pet-hat | 皮肤 | 礼帽,仅 is-walking 时显示("绅士散步"皮肤) |
| 5 | .desk-pet-wand | 皮肤 | 魔法棒,仅 is-flying / is-casting 时显示(飞向导航栏时) |
| 6 | .desk-pet-robe / -robe-collar | 皮肤 | 茶色围裙 + 领口滚边,仅 is-welcoming / is-farewell |
| 7 | .desk-pet-tray(plate/saucer/cup/handle) | 皮肤 | 茶盘一整套,仅 is-welcoming |
| 8 | .desk-pet-nub-l/r | 常驻,会变形 | 两侧小凸起;is-welcoming 时 translate + rotate 伸向茶盘 |
| 9 | .desk-pet-lantern | 皮肤 | 红灯笼,仅暗色主题 + is-welcoming,viewBox x=68 明显偏离主体 |
| 10 | eyes / brows / mouth / cheeks | 常驻,形状切换 | 见第 04 节 |
"皮肤"统一走同一个模式:默认 opacity: 0,只在对应状态类下设为 1, 而不是用 display:none 或动态插入 / 移除 DOM —— 这样可以配 transition: opacity 0.2s 做平滑淡入淡出,且元素始终存在、不会引发布局抖动。新增任何一套"皮肤"都照此模式写。
/* 茶盘一套:底盘 + 茶碟 + 茶杯 + 把手,都画在脸部以下(y>52), 避免和眼睛(cy=42.5) / 嘴(y=48.5) 撞在一起 —— 这是第一版翻车的原因 */ <path class="desk-pet-robe" d="M16,52 Q32,48.5 48,52 L45,62 Q32,65.5 19,62 Z" /> <path class="desk-pet-robe-collar" d="M16,52 Q32,48.5 48,52" /> <g class="desk-pet-tray"> <ellipse class="desk-pet-tray-plate" cx="32" cy="61.5" rx="10" ry="2.8" /> <ellipse class="desk-pet-tray-saucer" cx="32" cy="57.8" rx="4.2" ry="1.4" /> <path class="desk-pet-tray-cup" d="M28.6,53.5 h6.8 v3.2 a3.4,3 0 0 1 -6.8,0 Z" /> <path class="desk-pet-tray-cup-handle" d="M35.6,54.4 q2.2,0.4 1.6,2.3 q-0.5,1.5 -2.1,1.3" /> </g> /* 两侧小凸起放在 tray 之后,动画时才会盖在茶盘上,读作"手伸过去端着" */ <path class="desk-pet-nub desk-pet-nub-l" d="M11,50 C7,54 5,57 6,60 a3.5,3.5 0 1 0 7,-1 C14,55 13,52 11,50 Z" /> <path class="desk-pet-nub desk-pet-nub-r" d="M53,50 C57,54 59,57 58,60 a3.5,3.5 0 1 1 -7,-1 C50,55 51,52 53,50 Z" />
/* 手伸向茶盘 — transform 而非位移 DOM,配合上面 opacity 淡入同时发生 */ .desk-pet-nub { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } .desk-pet.is-welcoming .desk-pet-nub-l { transform: translate(15px, 5px) rotate(-8deg); } .desk-pet.is-welcoming .desk-pet-nub-r { transform: translate(-15px, 5px) rotate(8deg); }
灯笼第一版挂在 cx≈43–49,紧贴主体右侧,用户反馈"看不出是灯笼、离水滴太近"。 改法:把灯笼整体挪到 cx=68(超出 0–64 的 viewBox),靠父级已有的 overflow: visible 正常渲染而不被裁切 —— 换算到真实像素, 只比容器右边缘多出约 6px,配合 right-4 的 16px 留白完全不会溢出视口。 调试时用 scale() 放大截图会人为放大这 6px 的溢出,被裁到边缘是截图artifact,不是真实渲染的 bug。
眼睛和嘴各有 3–4 个候选形状,同一时刻只有一个 opacity:1, 其余淡出到 0 —— 不是拉伸变形一个形状,而是几套独立路径互相交叉淡入淡出。 好处是每种表情的曲线都可以单独精修(比如"困倦"要垂眼 + 哈欠嘴,"开心"要弧线眼 + 咧嘴 + 腮红), 代价是每加一个新状态,就要把它塞进四组交叉淡入淡出的选择器列表里(eye-round / eye-happy / eye-sleepy / mouth-*)。
| 状态 | 眼睛 | 嘴 | 附加 |
|---|---|---|---|
| 默认 | eye-round | mouth-default | — |
| hover / is-greeting / is-dancing / is-welcoming / is-farewell | eye-happy | mouth-happy | 腮红鼓起 |
| is-yawning | eye-sleepy | mouth-yawn | — |
| is-speaking | eye-round | mouth-talk | "o"形嘴随机开合 |
新状态要联动表情时,别新建第五套眼 / 嘴形状 —— 优先复用已有的 eye-happy / mouth-happy, 只把新状态类名追加进对应选择器列表。这也是为什么 is-welcoming 和 is-farewell 直接复用了"开心"表情,而不是另起一套。
所有装饰性动作都是 #chat-toggle 上的一个类名开关, 但真正防止"打哈欠时又开始跳舞"的,是每个调度函数前置的一个 canXxx() 守卫 —— 逐条 !classList.contains(...) 检查所有其它互斥状态。新增一个动作, 必须同时把它加进所有其它动作的排除列表,否则会撞车。
function canWander() { return ( !prefersReducedMotion && !userPositioned && !isDragging && panel.hidden && window.innerWidth >= 640 && !toggle.classList.contains('is-flying') && !toggle.classList.contains('is-casting') && !toggle.classList.contains('is-dancing') && !toggle.classList.contains('is-welcoming') && !toggle.classList.contains('is-farewell') ); }
| 类名 | 触发者 | 持续时长 | 会被谁排除 |
|---|---|---|---|
| is-blinking | scheduleBlink | 140ms | —(不参与互斥,太短) |
| is-walking / is-catwalk | walkAlongGround | 按距离/36px·s⁻¹ | idle bit · nav-flight |
| is-dancing + dance-* | playDance | 5000–5500ms | wander · idle bit |
| is-yawning / -stretching / -peeking / -spinning | scheduleIdleBit | 600–1400ms | wander · nav-flight(互斥,非同时触发) |
| is-flying / is-casting | tryFlyToNav | 飞行时长 + 5200ms | wander · idle bit |
| is-welcoming | playWelcome(加载 1.8s 后) | 4200ms | wander · idle bit · nav-flight |
| is-farewell | playFarewell(面板关闭 transitionend 后) | 1300ms | 同上 |
| is-dragging | pointermove(超过 6px 阈值) | 直到 pointerup | —(拖拽期间不调度任何自动行为) |
早期版本会在加载时用 JS 算出一个"目标静止位置"再 transform 过去, 肉眼可见从右下角"跳"到最终位置。修复方式是让 offsetY === 0 永远等于 CSS 自带的锚点位置(bottom-4 或 sm:bottom-[34vh]), 彻底不再有"JS 算出来再套上去"的初始位移 —— 没有计算,就没有跳变。
// CSS 默认位置本身就是"静止位"——offsetY 0 永远代表"在原位", // 加载时没有任何东西需要计算或补间,自然也就没有跳变。 function computeBounds() { const margin = 12; const width = widget.offsetWidth; const height = widget.offsetHeight; const baseLeft = window.innerWidth - 16 - width; const anchorBottom = window.innerWidth < 640 ? 16 : window.innerHeight * 0.34; const baseTop = window.innerHeight - anchorBottom - height; const groundY = window.innerWidth < 640 ? 0 : Math.max(0, anchorBottom - 16); return { baseLeft, baseTop, minX: margin - baseLeft, maxX: window.innerWidth - margin - width - baseLeft, minY: margin - baseTop, maxY: groundY }; }
导航栏飞行交互(tryFlyToNav)曾经自己重新算了一遍 baseTop, 锚点改成 34vh 后这份"重复计算"没跟着更新,导致飞过去的落点算错,正好挡住 Projects 文字。 凡是要算 offsetX/offsetY 目标值的地方,一律复用 computeBounds() 返回的 baseLeft/baseTop,禁止再单独重算一份 —— 唯一数据源原则。
四条独立的"待机小剧场"各自用 setTimeout 自我调度、自我重新排期, 互不知道彼此存在,纯靠第 05 节的互斥守卫协调:
const DANCES = [ { name: 'swan', duration: 5500 }, // 天鹅湖:双圈 pirouette + 踮脚 + 屈膝礼 { name: 'kungfu', duration: 5000 }, // 功夫足球:蹲马步 → 蓄力 → 大力踢腿 → 得意站定 { name: 'rain', duration: 5500 }, // 雨中曲:转圈 + 欢快小跳 + 经典后仰 + 歪礼帽 ] as const; function playDance(onDone: () => void) { const dance = DANCES[Math.floor(Math.random() * DANCES.length)]; const danceClass = `dance-${dance.name}`; toggle.classList.add('is-dancing', danceClass); window.setTimeout(() => { toggle.classList.remove('is-dancing', danceClass, 'is-walking'); onDone(); }, dance.duration); }
第一版跳舞用户完全没注意到——动作幅度太小、时长太短。修复方式不是加提示文案,而是直接放大动作本体: 加长 duration、加大 transform 的位移/旋转角度、 把"到角落跳舞"的概率从 35% 提到 45%。UI 反馈类问题优先考虑"动作是否真的够大",而不是"要不要加提示"。
所有桌宠台词走 t('chat.KEY') 在 Astro frontmatter 里取出, 序列化进一个 <script type="application/json" id="chat-i18n">, 客户端 JSON.parse 出来当纯数据用 —— 服务端渲染负责翻译,客户端只管随机挑选和展示。
| 文案池 | 触发时机 | 语言范围 |
|---|---|---|
| bubbleIntro | 访客有史以来第一次访问(localStorage 门控) | 三语 |
| bubblePool | 随机环境气泡(intro/hint/poke/dry) | 三语 |
| welcomePool(bubbleWelcome1/2/3) | 老访客再次打开页面 | 仅 zh |
| bubbleFarewell | 关闭聊天面板 | 三语 |
| bubbleNav* | 飞向某个导航项时 | 三语 |
"客官您是打尖还是住店呀"这类梗只写进了 zh.json,en / fr 版本用了完全不同的、 更符合当地语境的欢迎语(比如英文走"中世纪旅店老板"调子而非直译)。方言 / 网络梗类文案不做逐字翻译, 按目标语言的文化语境重新写一句功能等价的话。
这是桌宠"人设"最具体的一层落地:访客像是被小水滴接待的客人。设计取材于中国传统待客礼仪 (奉茶、掌灯、鞠躬送客),但刻意只做"进门 / 出门"两个时间点,不做成常驻装扮 —— 逻辑和"走路才戴礼帽"完全一致。
function setOpen(open: boolean) { toggle.setAttribute('aria-expanded', String(open)); if (open) { panel.hidden = false; requestAnimationFrame(() => panel.classList.add('is-open')); input.focus(); } else { panel.classList.remove('is-open'); // playFarewell() 必须等 panel.hidden 真正变成 true 之后再调用 —— // canShowBubble() 要求 panel.hidden,而它只在 CSS 关闭过渡结束后才会被设置, // 挂在 transitionend 里,不能在 setOpen(false) 里同步调用。 panel.addEventListener('transitionend', () => { panel.hidden = true; playFarewell(); }, { once: true }); } }
双重把关:JS 侧读一次 prefersReducedMotion,为 true 就直接不注册 任何调度函数(scheduleWander / scheduleIdleBit / scheduleNavVisit / scheduleBlink 全部跳过); CSS 侧再镜像一份 @media (prefers-reduced-motion: reduce),把所有相关 animation / transition 置为 none 兜底。 每加一个新的动画属性,都要记得把选择器补进这条媒体查询。
functions/api/chat.ts 是一个 Cloudflare Pages Function, 只在访客点开面板并发消息时才被调用,与桌宠的待机动画系统完全解耦。
| 机制 | 实现 |
|---|---|
| 问答依据 | 系统提示词内嵌 SITE_CONTEXT,仅可用这份公开资料回答 |
| 防注入 | 明确拒绝"忽略之前指令 / 打印系统提示词"等套话,不逐字转述参考资料 |
| 限流 | 20 次 / 小时 / IP |
| 消息校验 | MAX_MESSAGES=8, MAX_MESSAGE_LENGTH=2000 |
| Provider | provider-agnostic:LLM_PROVIDER 切 deepseek(默认) / anthropic / openai,前端零改动 |
这是一份真正按顺序执行的清单 —— 每一步都依赖上一步已经落地:
只做常驻主体 + 表情交叉淡入淡出(第 03、04 节)。把每个"皮肤"元素默认设 opacity:0,即便还没接状态类。
任何需要算目标坐标的功能(拖拽、漫游、飞行)都必须调这一个函数,禁止各自重算 baseLeft/baseTop。
新状态类名要出现在其它所有互斥函数的排除列表里,否则会和现有动作打架。
Astro frontmatter 用 t('chat.KEY') 取值,序列化进 <script type="application/json">,客户端只做随机选取。
先确认哪些台词是"文化限定梗",只在对应语言文件里写,其它语言换成功能等价但语境合适的说法。
JS 侧读一次做调度总开关,CSS 侧镜像一份媒体查询兜底 —— 缺一个都不完整。
把任何需要网络请求的功能(AI 问答、数据拉取)绑定到明确的用户交互(点击展开),不要在待机循环里发起请求。
用 getBoundingClientRect() 核对真实(1x)尺寸下的定位,再决定是否真的需要改坐标 —— 本手册第 03 节的灯笼就是一次假警报。