Implementation Guide · Rev. 2026-07-28

数字桌宠(Desk Pet)实现手册 —— 从站点定位到可复用配方

记录 ChatWidget.astro 这个悬浮水滴桌宠的完整实现:它在整站架构里的位置、 内部五层结构(SVG 视觉层 → CSS 状态机 → JS 编排层 → i18n 内容层 → 后端问答服务)、 每个子系统的关键代码,以及把这套模式搬到另一个项目时可以直接照做的复用清单。

ComponentChatWidget.astro
StackAstro 7 · Tailwind 4 · Cloudflare Pages
Lines~1,987
Localesen / zh / fr
01

它在整站里的位置

网站是一个 Astro 7 静态站点,按 [lang] 目录做三语路由(en 默认 / zh / fr), 所有页面共享同一个 Layout.astro 外壳。桌宠不是某个页面的内容, 而是挂在这层共享 Layout 里的一个全局悬浮组件 —— 每一种语言、每一张页面都会渲染它。

astro.config.mjs i18n routing · locales src/pages/[lang]/*.astro — 11 routes index · bio · patents · publications · experience · projects · blog · resources · volunteer · media · news · photos src/layouts/Layout.astro shared shell — header nav · footer · theme + lang switch I18N src/i18n/ ui.ts locales/ en.json zh.json fr.json <slot /> page content <ChatWidget lang /> 桌宠 · YOU ARE HERE <SearchModal lang /> ⌘K 全站搜索 fetch() 仅在面板打开时触发 functions/api/chat.ts Cloudflare Pages Function functions/_lib/context.ts SITE_CONTEXT(问答依据) CHAT_RATE_LIMIT Cloudflare KV · 20 次/时/IP LLM_PROVIDER DeepSeek 默认 / Anthropic / OpenAI 部署:Cloudflare Pages(静态构建 + Pages Functions + KV 绑定),git 分支 main 触发发布
FIG. 1 — 站点整体架构:桌宠是 Layout 里与 slot / SearchModal 并列的第三个常驻子组件 site-digital-twin/src

关键点:桌宠不是页面级组件,改一次 ChatWidget.astro 全站生效; 它和 SearchModal 一样通过 lang prop 拿到当前语言, 自己内部再用 useTranslations(lang) 取文案 —— 与页面内容走的是同一套 i18n 管线。

02

桌宠内部五层结构

单个 .astro 文件里其实叠着五层,越往下越"慢"、越不常触发。 自上而下:视觉层(纯 SVG,被动)→ CSS 状态机(类名开关,被动响应)→ JS 编排层(定时器 + 互斥判断,主动决策)→ 内容层(i18n 文案池)→ 后端(只在用户主动点开面板时才唤醒)。

LAYER 1 SVG 视觉层 — viewBox 0 0 64 64 body · ears · legs(常驻)+ hat / wand / robe+collar+tray / lantern(皮肤,默认 opacity:0) eye-round / eye-happy / eye-sleepy · mouth-default / happy / talk / yawn(表情交叉淡入淡出) LAYER 2 CSS 状态机 — #chat-toggle 上的类名 is-walking / is-catwalk · is-dancing+dance-swan|kungfu|rain · is-flying / is-casting is-welcoming / is-farewell · is-yawning / is-stretching / is-peeking / is-spinning · is-speaking LAYER 3 JS 编排层 — 定时器 + 互斥判断 scheduleWander → canWander() → walkAlongGround() → playDance() scheduleIdleBit → canPlayIdleBit() · scheduleNavVisit → canFlyToNav() → tryFlyToNav() scheduleRandomBubble → canShowBubble() · playGreeting / playWelcome / playFarewell LAYER 4 内容层 — <script id="chat-i18n"> JSON island bubblePool · welcomePool · NAV_TARGETS[].bubbleKey — 三语文案池,JS 随机挑选 LAYER 5 后端(按需唤醒) POST /api/chat toggles classes reacts via CSS feeds showBubble() 用户点击 展开面板 五层里 1–4 层构成"待机时的桌宠",彼此纯前端、无网络请求;只有第 5 层依赖后端, 且只在访客主动点开聊天面板、发送消息时才被调用 —— 待机动画本身不产生任何 API 流量。
FIG. 2 — 内部五层剖面:越往下越"重",第 5 层完全按需唤醒 ChatWidget.astro · 1,987 lines
03

视觉层:SVG 结构与"皮肤"模式

整个形象是一份 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 明显偏离主体
10eyes / brows / mouth / cheeks常驻,形状切换见第 04 节
DECISION

"皮肤"统一走同一个模式:默认 opacity: 0,只在对应状态类下设为 1, 而不是用 display:none 或动态插入 / 移除 DOM —— 这样可以配 transition: opacity 0.2s 做平滑淡入淡出,且元素始终存在、不会引发布局抖动。新增任何一套"皮肤"都照此模式写。

src/components/ChatWidget.astro — 迎宾皮肤(第三轮修复后的最终版)
/* 茶盘一套:底盘 + 茶碟 + 茶杯 + 把手,都画在脸部以下(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。

04

表情系统:切形状而非缩放

眼睛和嘴各有 3–4 个候选形状,同一时刻只有一个 opacity:1, 其余淡出到 0 —— 不是拉伸变形一个形状,而是几套独立路径互相交叉淡入淡出。 好处是每种表情的曲线都可以单独精修(比如"困倦"要垂眼 + 哈欠嘴,"开心"要弧线眼 + 咧嘴 + 腮红), 代价是每加一个新状态,就要把它塞进四组交叉淡入淡出的选择器列表里(eye-round / eye-happy / eye-sleepy / mouth-*)。

状态眼睛附加
默认eye-roundmouth-default
hover / is-greeting / is-dancing / is-welcoming / is-farewelleye-happymouth-happy腮红鼓起
is-yawningeye-sleepymouth-yawn
is-speakingeye-roundmouth-talk"o"形嘴随机开合
复用要点

新状态要联动表情时,别新建第五套眼 / 嘴形状 —— 优先复用已有的 eye-happy / mouth-happy, 只把新状态类名追加进对应选择器列表。这也是为什么 is-welcomingis-farewell 直接复用了"开心"表情,而不是另起一套。

05

状态机:互斥调度

所有装饰性动作都是 #chat-toggle 上的一个类名开关, 但真正防止"打哈欠时又开始跳舞"的,是每个调度函数前置的一个 canXxx() 守卫 —— 逐条 !classList.contains(...) 检查所有其它互斥状态。新增一个动作, 必须同时把它加进所有其它动作的排除列表,否则会撞车。

canWander() — 决定"待机漫游"是否可以开始
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-blinkingscheduleBlink140ms—(不参与互斥,太短)
is-walking / is-catwalkwalkAlongGround按距离/36px·s⁻¹idle bit · nav-flight
is-dancing + dance-*playDance5000–5500mswander · idle bit
is-yawning / -stretching / -peeking / -spinningscheduleIdleBit600–1400mswander · nav-flight(互斥,非同时触发)
is-flying / is-castingtryFlyToNav飞行时长 + 5200mswander · idle bit
is-welcomingplayWelcome(加载 1.8s 后)4200mswander · idle bit · nav-flight
is-farewellplayFarewell(面板关闭 transitionend 后)1300ms同上
is-draggingpointermove(超过 6px 阈值)直到 pointerup—(拖拽期间不调度任何自动行为)
06

定位系统:为什么不再"加载时跳一下"

早期版本会在加载时用 JS 算出一个"目标静止位置"再 transform 过去, 肉眼可见从右下角"跳"到最终位置。修复方式是让 offsetY === 0 永远等于 CSS 自带的锚点位置(bottom-4sm:bottom-[34vh]), 彻底不再有"JS 算出来再套上去"的初始位移 —— 没有计算,就没有跳变。

computeBounds() — 全部位置计算的唯一入口
// 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,禁止再单独重算一份 —— 唯一数据源原则。

07

动作行为:漫游、走位、三支舞、飞导航

四条独立的"待机小剧场"各自用 setTimeout 自我调度、自我重新排期, 互不知道彼此存在,纯靠第 05 节的互斥守卫协调:

  • 空闲小动作 — 每 18–40s 掷一次骰子,40% 概率什么都不做(scheduleIdleBit
  • 地面游走 — 每 3.2–13s(首次 9–14s)下降到地面,横移,45% 概率走到右下角空地并跳舞,30% 概率切换猫步
  • 飞向导航栏 — 每 45–90s 随机飞向 5 个导航项之一,缩小倾斜成"小天使",气泡侧边弹出可点击跳转
  • 随机气泡 — 每 55–100s 弹一句俏皮文案(与上面动作解耦,可以同时发生)
playDance() — 三支舞随机挑选,走路装扮全程保留
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 反馈类问题优先考虑"动作是否真的够大",而不是"要不要加提示"。

08

气泡与三语内容池

所有桌宠台词走 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*飞向某个导航项时三语
DECISION

"客官您是打尖还是住店呀"这类梗只写进了 zh.json,en / fr 版本用了完全不同的、 更符合当地语境的欢迎语(比如英文走"中世纪旅店老板"调子而非直译)。方言 / 网络梗类文案不做逐字翻译, 按目标语言的文化语境重新写一句功能等价的话。

09

待客礼仪:迎宾送客皮肤

这是桌宠"人设"最具体的一层落地:访客像是被小水滴接待的客人。设计取材于中国传统待客礼仪 (奉茶、掌灯、鞠躬送客),但刻意只做"进门 / 出门"两个时间点,不做成常驻装扮 —— 逻辑和"走路才戴礼帽"完全一致。

  • 迎宾playWelcome)— 加载 1.8s 后触发,围裙 + 茶盘淡入 4.2s,两侧凸起伸向茶盘
  • 鞠躬desk-pet-bow keyframe)— 迎宾 / 送客共用同一条关键帧,只是时长不同(1.4s / 1.2s)
  • 送客playFarewell)— 面板关闭动画结束后触发,鞠躬 1.3s + 一句"客官慢走"
  • 掌灯 — 仅暗色主题下,迎宾期间灯笼一起亮起
setOpen() — 送客时机是这里唯一真正踩过坑的地方
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 });
  }
}
10

无障碍:prefers-reduced-motion

双重把关:JS 侧读一次 prefersReducedMotion,为 true 就直接不注册 任何调度函数(scheduleWander / scheduleIdleBit / scheduleNavVisit / scheduleBlink 全部跳过); CSS 侧再镜像一份 @media (prefers-reduced-motion: reduce),把所有相关 animation / transition 置为 none 兜底。 每加一个新的动画属性,都要记得把选择器补进这条媒体查询。

11

后端:AI 问答服务

functions/api/chat.ts 是一个 Cloudflare Pages Function, 只在访客点开面板并发消息时才被调用,与桌宠的待机动画系统完全解耦。

机制实现
问答依据系统提示词内嵌 SITE_CONTEXT,仅可用这份公开资料回答
防注入明确拒绝"忽略之前指令 / 打印系统提示词"等套话,不逐字转述参考资料
限流20 次 / 小时 / IP
消息校验MAX_MESSAGES=8, MAX_MESSAGE_LENGTH=2000
Providerprovider-agnostic:LLM_PROVIDER 切 deepseek(默认) / anthropic / openai,前端零改动
12

复用配方:迁移到新项目

这是一份真正按顺序执行的清单 —— 每一步都依赖上一步已经落地:

画视觉层,先别加任何皮肤

只做常驻主体 + 表情交叉淡入淡出(第 03、04 节)。把每个"皮肤"元素默认设 opacity:0,即便还没接状态类。

先写 computeBounds(),定位逻辑不要分散

任何需要算目标坐标的功能(拖拽、漫游、飞行)都必须调这一个函数,禁止各自重算 baseLeft/baseTop。

每新增一个动作状态,同步更新所有 canXxx() 守卫

新状态类名要出现在其它所有互斥函数的排除列表里,否则会和现有动作打架。

文案走 i18n JSON island,不要在 JS 里硬编码字符串

Astro frontmatter 用 t('chat.KEY') 取值,序列化进 <script type="application/json">,客户端只做随机选取。

非通用梗类文案,按语言重写而非直译

先确认哪些台词是"文化限定梗",只在对应语言文件里写,其它语言换成功能等价但语境合适的说法。

双重接入 prefers-reduced-motion

JS 侧读一次做调度总开关,CSS 侧镜像一份媒体查询兜底 —— 缺一个都不完整。

后端能力按需唤醒,不要预连接

把任何需要网络请求的功能(AI 问答、数据拉取)绑定到明确的用户交互(点击展开),不要在待机循环里发起请求。

调试大幅缩放截图时,先核实是否是缩放本身引入的裁切

getBoundingClientRect() 核对真实(1x)尺寸下的定位,再决定是否真的需要改坐标 —— 本手册第 03 节的灯笼就是一次假警报。

Repo miao-yu-website Worktree site-digital-twin Branch feature/digital-twin → merged to main @ ce9f339 Companion doc 功能架构图(面向产品视角的功能总览)