从预订问到退房:一间山野民宿的数字人「旅程地图」------Theme07_Homestay.vue 端到端拆解
住民宿和住酒店最大的差别,是这中间站着一个「人」------他记得你订的是山景还是阁楼,知道今晚院子里的星空好不好看,也清楚退房那天中午 12 点前要把房卡放在哪儿。
所以这篇不按「背景 → 接入 → CSS → 知识 → 小结」那套通用骨架写。我们跟着一位旅客,把界面从头到位走一遍:预订、到店、入住、在店、退房 。每一站停一下,聊一个技术话题,顺手把 Theme07_Homestay.vue 里对应的那一段样式拆开看。走完这趟,你会发现这个组件的样式不是装饰,而是「旅途进度」的可视化。
先把这次旅程要带的四件行李摆出来,它们会在不同站点反复出现:
-
专属智脑:让智能体知道「我是谁、知道什么、应该做什么」。这一篇的主概念,会花最多笔墨。
-
多模态感知 :持续知道这间民宿正在发生什么------听觉、视觉、业务数据多通道并行输入,是「在店」那一站的核心。持续感知,而不是一次输入。
-
多模态表达 :实时生成统一、连续、同步的具身表达------语言、口型、表情、眼神、头部、手势统一实时生成,会在「入住」的
ask()里登场。 -
AI 端渲 :让实时交互真正变成终端基础能力,把渲染下沉到终端侧,是「在店」弱网场景里的兜底。省带宽、省机器、弱网也不掉链子。
而贯穿全程的那条链路,也早已不是早年的写法。项目已经从「前端自建 DeepSeek 流式 + 前端按标点拆句 + 前端手写 SSML」的三段拼装,迁移到了魔珐星云 Xingyun 端到端(端对端)SDK:全局对象 window.XingyunAvatarAgent 负责把 LLM 回复、TTS、口型、表情、动作一次编排到底。组件做的,只剩下「把话说出去」和「把界面点亮」。
组件主题前缀统一为 stay-,配色是一套奶咖(#fbf7f0 / #efe6da)+ 橄榄绿(#6b7a54)+ 落日橘(#e08a4e)的庭院色调。

第一站 · 预订:智脑先「读懂人」,再谈房
旅客第一次开口,往往不是「我要订房」,而是「我想找个安静点、能看星星的地方,就我们俩」。这句话里没有房型名,也没有日期。能接住它的,不是关键词表,而是专属智脑。
它得先知道「我是谁、我知道什么、我应该做什么」
-
我是谁:民宿管家「小山」,一个亲切、周到、会说「您打算什么时候来山野间呀」的在地服务者,不是通用客服。
-
我知道什么:三种房型的可住人数、床型、面积、参考价,以及周边怎么玩、早餐几点、退改怎么算。
-
我应该做什么:在旅客还没说清需求时,主动往「匹配房型」上收敛,而不是闲聊。
专属智脑要同时回答三层问题:它知道什么(民宿的房型、价格、退改规则、周边玩法这些在地知识),它是谁(民宿管家「小山」这个具体身份),它要做什么(在旅客还没说清需求时主动收敛到匹配房型,而不是闲聊)。三层都答上了,才谈得上从「通用回答」升级为面向具体身份和业务的智能体能力。
这三件事在项目里是被显式约定的。src/constants/index.ts 为每个主题准备了一段 SYSTEM_PROMPT,民宿这一份(主题 7)的定位就是「民宿预订智能管家」,约束它「语言亲切自然、重点区分不同房型适合人群、入住与退房流程、周边玩法搭配」。换句话说,同一套底座,换一段场景提示词,数字人就从侍酒师变成了管家。
不绑死某一家大模型,也不把住客数据交出去
智脑这一层的设计态度很关键:它不绑死某一家大模型 ------豆包、DeepSeek、ChatGPT 等用惯的模型都可以接;没有现成模型时,可以直接用魔珐星云自研的智脑。自研这条路的四个特点是:低延迟、强逻辑、场景化理解、数据安全可控,专为具身交互场景优化。
对民宿这个行业来说,「数据安全可控」不是一句口号。住客的身份证信息、订单金额、门锁密码、行踪时间,全是敏感数据。让智脑跑在可控的环境里、按场景边界使用数据,比模型榜单上的分数重要得多。
而「场景化理解」落到具体动作上,就是组件里的 sendChatMsg 那串关键词分支:旅客话里带「订房 / 预订 / 价格」,走报价;带「房型 / 哪种 / 推荐」,走选型;带「包栋 / 团建 / 派对」,走包场方案。这就是智脑在用业务数据做场景收敛------三种房型的真实价签是它推荐的锚点:
typescript
// Theme07_Homestay.vue ------ 房型数据(智脑推荐的锚点)
{ id: "p1", icon: "⛰️", title: "山景大床房", 参考价: "¥680", content: "落地大窗正对山间云雾......" },
{ id: "p2", icon: "🌿", title: "庭院亲子房", 参考价: "¥880", content: "独立小院带秋千......" },
{ id: "p3", icon: "✨", title: "星空阁楼", 参考价: "¥760", content: "斜顶天窗躺看星空......" },
让「房型」以明信片的形式被递出去
预订这一站对应的是左侧的 stay-postcard-grid。三张明信片房型卡,交错旋转、贴邮票、盖邮戳,把「一封信寄到你手里」的意象搬进界面。旋转角度由模板里的 inline style 控制:
vue
<!-- 模板中的旋转:三张卡分别 -2° / 1° / -1° -->
<div
v-for="(product, pIdx) in productList"
:key="product.id"
class="stay-postcard"
:style="{ transform: `rotate(${[-2, 1, -1, 0][pIdx]}deg)` }"
@click="playProductContent(product)"
>
卡片右上角的 stay-stamp 是 40×40 的落日橘方块 + 虚线描边,模拟邮票锯齿边;stay-postmark 是 52×52 的圆形橄榄绿边框邮戳,opacity: 0.4 加 rotate(-15deg),做出「盖下去的粗糙感」。这两件东西都不是纯装饰------邮票里装着房型图标,邮戳在暗示「这家店有口碑」。
css
.stay-postcard:hover {
transform: rotate(0deg) translateY(-3px) !important;
box-shadow: 0 10px 24px rgba(107, 122, 84, 0.28);
}
.stay-stamp {
position: absolute;
top: 8px; right: 8px;
width: 40px; height: 40px;
background: #e08a4e;
border: 2px dashed #fbf7f0;
border-radius: 4px;
}
.stay-postmark {
position: absolute;
top: 10px; right: 54px;
width: 52px; height: 52px;
border: 2px solid #6b7a54;
border-radius: 50%;
opacity: 0.4;
transform: rotate(-15deg);
}
明信片正中间的竖分隔线 stay-divider 用 repeating-linear-gradient 做「橄榄绿 3px + 透明 3px」的循环,比手写 dashed 稳定:
css
.stay-divider {
position: absolute;
top: 56px; bottom: 34px; left: 50%;
width: 1px;
background: repeating-linear-gradient(180deg, #6b7a54 0 3px, transparent 3px 6px);
transform: translateX(-50%);
}
右侧那四行 stay-metric(可住人数 / 床型 / 面积 / 参考价),是智脑推荐时最常用的四个维度。stay-m-value 用了落日橘加手写体 Caveat,让数字看起来像笔写上去的。
第二站 · 到店:让数字人「出场」,把第一句话说顺
旅客推门进来,管家得先露面、先开口。这一站对应组件的初始化流程。
连接、初始化、再把问候直接播出去
typescript
// Theme07_Homestay.vue ------ 到店即出场
const initAvatar = async () => {
const initContent =
"你好呀,我是民宿管家小山!订房、入住指引、周边行程,都可以直接问我。您打算什么时候来山野间呀?";
try {
await appStore.connectAvatar();
isAvatarReady.value = true;
ElMessage.success("民宿管家小山数字人已就绪");
subtitleText.value = initContent;
appStore.speakGreeting(initContent);
} catch (err) {
console.error("数字人初始化失败:", err);
ElMessage.error("数字人加载失败,请刷新页面重试");
} finally {
isLoading.value = false;
}
};
这里有个容易忽略的细节:问候语走的是 speakGreeting ,不是 ****ask 。问候是「直接播报」,不需要过一遍大模型。appStore.speakGreeting 内部先 generateSSML(text) 把文本包成 <speak pitch="1" speed="1" volume="1">...</speak>,再调 avatar.speak(ssml, true, true)。省掉一次 LLM 往返,进门第一句就更跟得上人。
底层连接的真正入口是 avatarService.connect()。它负责构造端到端 SDK:
typescript
// src/services/avatar.ts ------ 端到端 SDK 构造参数
const agent = new window.XingyunAvatarAgent({
container: containerEl, // 组件里的 .stay-avatar-canvas
appId: APP_ID,
appSecret: APP_SECRET,
enableDebugger: false,
gatewayServer: url.toString(), // SDK_CONFIG.GATEWAY_URL + data_source/custom_id
onWidgetEvent, onStateChange, onMessage,
agentCallbacks: { onError, onConversationChange, onAgentStateChange },
});
await agent.init({ onDownloadProgress, onClose });
网关固定在 SDK_CONFIG.GATEWAY_URL = "https://nebula-agent.xingyun3d.com/user/v1/ttsa_v2/session",并带上 DATA_SOURCE: "2"、CUSTOM_ID: "demo" 两个查询参数。容器 #containerId 由 avatarService.getContainerId() 生成,对应模板里的 <div :id="containerId" class="stay-avatar-canvas">------SDK 会在里面挂 canvas,所以组件还特意等了容器布局非零才初始化。
木牌与光晕:让「出场」有场景感
到店的视觉是顶部那块手绘木牌 stay-wood-plaque:橄榄绿 2px 边框 + 奶咖底 + 12px 圆角,box-shadow 叠了外层橄榄绿投影和内层落日橘 inset 描边,撑出木头的厚度。两侧的圆铆钉由 ::before / ::after 画成 16px 圆点。
css
.stay-wood-plaque {
background: #efe6da;
border: 2px solid #6b7a54;
border-radius: 12px;
padding: 12px 34px 8px;
box-shadow: 0 6px 16px rgba(107, 122, 84, 0.22),
inset 0 0 0 1px rgba(224, 138, 78, 0.25);
}
.stay-wood-plaque h1 {
font-family: "Caveat", "Bradley Hand", cursive, "PingFang SC";
color: #6b7a54;
letter-spacing: 4px;
text-shadow: 1px 1px 0 rgba(224, 138, 78, 0.2);
}
数字人身后那圈暖光来自 .stay-halo:radial-gradient 从中心 rgba(224,138,78,0.3) 向外渐隐到 70%,像傍晚照进庭院的落日;脚下的 stay-grass 直接摆 emoji 🌿🌱🌾🌿,letter-spacing: 6px 撑开。
css
.stay-halo {
position: absolute;
top: 50%; left: 50%;
width: 90%; height: 80%;
transform: translate(-50%, -60%);
background: radial-gradient(circle at center, rgba(224,138,78,0.3) 0%, transparent 70%);
pointer-events: none;
}
光晕和木牌不是为了好看而好看:木牌给出「这是一间什么样的店」,光晕给出「管家站在庭院的什么位置」。到店这一站,数字人先被放进了一个有坐标的场景里------顺序很讲究,因为只有先「看见」现场,后面才谈得上感知。
第三站 · 入住:一次 ask(),从「回话」走到「动作」
办入住的时候,旅客开始抛具体问题。这一站是整条链路的心脏。
先说清楚「以前」和「现在」
项目早年的链路是:前端自己拼 DeepSeek 流式请求 → 前端按标点拆句 → 前端手写 SSML 再喂给数字人。拆句和 SSML 全靠前端,口型和语音很容易对不上。
现在换成端到端 SDK,前端的活儿被砍到只剩一层薄薄的转发。核心就是 ask():
typescript
// src/stores/app.ts ------ 一条链路直达
async sendMessage(): Promise<string | undefined> {
const { ui, avatar } = appState;
if (!ui.text || !avatar.instance) return;
await avatar.instance.ask(ui.text); // LLM 回复 → TTS → 口型 → 表情 → 动作
return ui.text;
}
把「前端自建 LLM 流式 + 前端按标点拆句 + 前端手写 SSML」的三段拼装,收成一次 avatar.instance.ask(text):LLM 回复 → 语音合成 → 口型/表情/动作一次编排完成,前端不再插手中间环节------这就是端到端(端对端) :一条 ask() 打通整条链路。
而它要交付的表达形态,正是多模态表达------实时生成统一、连续、同步的具身表达:
-
讲到「星空阁楼躺着数流星」时,语气自动放柔、眼神往上一飘,像真的在看那扇斜顶天窗;
-
报出「880 元、含四份早餐」时,手势比出数字、微微点头,重音落在价格上;
-
说到「三天内不退」时,表情收敛、语速放缓,把退改规则的严肃感传出去。
语言、口型、表情、眼神、头部动作、手势和身体动作统一实时生成------统一、连续、同步,民宿对话不再是干巴巴的读表。
组件侧的 speakText:只管把界面点亮
组件不直接碰 SDK,它通过 appState.ui.text + appStore.sendMessage() 转发:
typescript
// Theme07_Homestay.vue ------ 组件侧转发
const speakText = (text: string) => {
isAvatarSpeaking.value = true;
subtitleText.value = text;
appState.ui.text = text;
appStore.sendMessage().then(() => {
setTimeout(() => {
isAvatarSpeaking.value = false;
}, text.length * 50);
});
};
text.length * 50 只是用来估算「管家还在说话」这个动画状态的长度,让界面上的 speaking 态和实际节奏大致同步。真正的语音时长由 SDK 自己管理。
入住这一站的界面:房卡套看板
左侧「今日民宿」是一排房卡套,正好对应「办入住时前台那一眼状态」:外层橄榄绿容器 stay-keycard-slot、顶部 30px 深色卡槽横条 stay-slot-bar(渐变 #4d5a3d → #6b7a54)、底部露出一大半的米白房卡 stay-keycard。
css
.stay-keycard-slot {
position: relative;
height: 96px;
background: #6b7a54;
border-radius: 6px 6px 8px 8px;
box-shadow: 0 4px 12px rgba(107, 122, 84, 0.25),
inset 0 -2px 0 rgba(0, 0, 0, 0.22);
}
.stay-slot-bar {
height: 30px;
background: linear-gradient(180deg, #4d5a3d, #6b7a54);
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}
.stay-keycard {
position: absolute;
bottom: 0; left: 6px; right: 6px;
background: #fbf7f0;
border-radius: 4px 4px 6px 6px;
padding: 8px 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
}
卡面上是四个运营指标:今日可订房数 12 间、近 30 天入住率 92%、好评评分 4.92、回头客占比 38%。趋势值用 stay-kc-trend.up(橄榄绿)/ .down(落日橘)区分升降------这四张房卡,就是「入住率好不好」的一句话体检报告。
第四站 · 在店:自己说话时,也要听得见你插话
住下来之后,交互进入高频状态。这一站的主概念是多模态感知 ,副概念是 AI 端渲。
多通道并行,关键是「插话」
多模态感知要做的,是持续知道这间民宿正在发生什么。它的目标,不是简单把旅客的话转成文字,也不是把门口摄像头里的人脸识别一下就算数。真正要解决的是:旅客一句「想找个安静点、能看星星的地方,就我们俩」,得从中同时读出人数、偏好和大致日期,而不是等他把房型名报全;院子里有人随口问「今晚能不能看到星星」,得结合时间、天气和朝向接得住;而且------即使管家自己正在说话,也能继续听见旅客的插话,让交流继续往下走,而不是被迫听完再重来。
落到组件上,就是聊天面板 stay-chat-input 始终可输入:哪怕小山的问候或播报还在进行,旅客敲一句话回车(@keyup.enter="sendChatMsg"),新问题立刻被接住,不会因为「管家正在讲话」而卡住。业务数据通道则由 sendChatMsg 的分支承担,覆盖订房、房型、早餐、包栋、退改、宠物、周边七类高频诉求------这就是「听觉 + 业务数据」两条通道同时在跑。持续感知,而不是一次输入,这正是多模态感知与「录一句、答一句」的语音助手的分水岭。
字幕事件:把「正在说的话」同步到界面
语音通道的输出,通过 SDK 的 onWidgetEvent 事件回传。构造参数里对字幕事件做了判断:
typescript
onWidgetEvent: (event: any) => {
if (event.type === 'subtitle_on') {
onSubtitleOn(event.text); // → appState.ui.subTitleText
} else if (event.type === 'subtitle_off') {
onSubtitleOff(); // → 清空
}
}
组件再用一个 watch 把全局状态同步成局部字幕:
typescript
watch(
() => appState.ui.subTitleText,
(val) => { subtitleText.value = val || ""; }
);
字幕卡 stay-subtitle-card 在 speaking 时切换成橄榄绿描边并跑 stay-glow 呼吸动画------说话这件事,在界面上是「看得见」的。
css
.stay-subtitle-card.speaking {
border-color: #6b7a54;
animation: stay-glow 2s ease-in-out infinite;
}
@keyframes stay-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(107, 122, 84, 0.3); }
50% { box-shadow: 0 0 0 9px rgba(107, 122, 84, 0); }
}
AI 端渲:山里的弱网,得靠终端扛
民宿常在信号不好的山里,弱网甚至断网都是常态。如果渲染全靠云端,一旦网络抖动,画面就会跟着卡。AI 端渲要做的,是让实时交互真正变成终端的基础能力:把渲染下沉到终端侧完成,降低云端 GPU 成本、降低带宽占用、降低延迟、提升稳定性。也就是说,字幕、口型这些高频更新的部分,不必每一次都等云端回包------网络差的时候,现场体验不会直接塌掉。省带宽、省机器、弱网也不掉链子------山里的民宿能用,才是真的能用。
在店的其余两处界面
底部印章快捷按钮 stay-seal 用 border: 3px double #6b7a54 做出双圈印章感,选中态 transform: scale(0.92) + 降低不透明度,模拟盖下去时的形变与墨迹变淡;间隔的 .alt 换成落日橘,做节奏对比。
css
.stay-seal {
width: 66px; height: 66px;
border-radius: 50%;
border: 3px double #6b7a54;
background: #fbf7f0;
}
.stay-seal.active {
transform: scale(0.92);
opacity: 0.7;
box-shadow: inset 0 0 10px rgba(107, 122, 84, 0.3);
}
.stay-seal.alt { border-color: #e08a4e; }
右侧「住客手账」FAQ 每条上面贴着一小片橘色胶带 stay-tape(70×16、rgba(224,138,78,0.55)、rotate(-3deg)),卡片底色叠了一层层手账横线:
css
.stay-faq-item {
background-image: repeating-linear-gradient(
180deg,
transparent 0 21px,
rgba(107, 122, 84, 0.12) 21px 22px
);
}
.stay-tape {
position: absolute;
top: -8px; left: 8px;
width: 70px; height: 16px;
background: rgba(224, 138, 78, 0.55);
transform: rotate(-3deg);
}
展开箭头由 stay-faq-arrow.expanded { transform: rotate(180deg); } 翻转,答案用 stay-fadeIn 淡入。这些细节堆在一起,FAQ 就不再是冷冰冰的折叠面板,而是「管家随手记下的住客常问」。
第五站 · 退房:断得干净,才有下一次入住
旅程的最后一步是退房结账、挂断。技术上也一样------连接得干净地关掉。
typescript
// Theme07_Homestay.vue ------ 双重保障断开
onMounted(() => {
window.addEventListener("beforeunload", () => {
avatarService.disconnect(appState.avatar.instance);
});
});
onUnmounted(() => {
if (appState.avatar.instance) {
avatarService.disconnect(appState.avatar.instance);
appState.avatar.instance = null;
}
});
avatarService.disconnect() 内部只做一件事:agent.destroy('user_disconnect')。onUnmounted 覆盖组件切换(十个主题之间跳转),beforeunload 覆盖关页/刷新,两条路都指向同一个 destroy,避免 WebSocket 泄漏。
断开的同时,SDK 的回调也会给出状态:
typescript
agentCallbacks: {
onError(error) { console.error('[AgentError]', error?.code, error?.message || error); },
onConversationChange(event) {
if (event?.state === 'completed') { /* 一轮对话结束 */ }
},
onAgentStateChange(state) { /* 状态流转 */ },
}
onConversationChange 里的 state === 'completed' 可以理解为「这一轮话说完、结完账了」;配合 init({ onClose }) 里把状态清空,整个退出过程是有序收尾,而不是直接拔电源。
尾声 · 民宿这门生意的六组数字
界面上的数据来自四个数组,它们本身就很「民宿」:quickServices 六项快捷服务(房型预订 / 入住指引 / 周边行程 / 设施咨询 / 包栋团建 / 退改政策);productList 三种房型(山景大床 2 人 · 1.8m 大床 · 28㎡ · ¥680;庭院亲子 3-4 人 · 1.8m+1.2m · 36㎡ · ¥880;星空阁楼 2 人 · 1.8m 榻榻米 · 22㎡ · ¥760);statsData 四个运营指标;faqList 五条住客高频问题。UI 不是静态画布,而是这些数据的视觉投影。
下面六条是民宿这门生意真正在运转的规则,也解释了「为什么界面要长成订房 + 状态看板 + 手账 FAQ 的样子」:
1. 房型定价逻辑
民宿价格波动比酒店更剧烈。旺季(五一 / 十一 / 暑假)上浮 50-100%,周末上浮 20-30%,工作日是基础价。提前 30 天预订通常有 15% 左右的早鸟折扣。情侣房型、景观房型价格溢价明显,能比基础房型高出 30-50%。
2. 入住退房流程与保洁周转
标准退房时间中午 12:00,入住 14:00 之后,中间 2 小时是保洁窗口。实际运营中保洁需要 4 小时完成全流程------布草换洗送洗、开窗通风 30 分钟、深度清洁、备品补充。如果前一晚客人延迟退房,保洁时间被压缩,最容易出问题的就是卫生间和床品死角。
3. 超售风险应对
民宿房量小(通常 5-15 间),超售的容错率远低于连锁酒店。行业通行做法是保留 10-15% 房数不对外售卖作为缓冲,同时与周边民宿建立紧急房源备份协议。万一真的超售,给客人免费升级房型 + 赠送 50-200 元消费券是最常用的补偿方案。
4. 退改政策设计
三档常见策略:宽松档(入住前 7 天前免费)适合散客、标准档(14 天前免费)适合商旅、严格档(不可退改)适用于旺季和包栋订单。实际运营中会灵活处理------比如遇到台风封路、客人突发疾病,可以破例放行,口碑比规则重要。
5. 周边体验组合思路
把在地手作活动(陶艺、制茶、竹编)、本地私房菜、景区联票打包成「住宿套餐」,可以提升客单价 30-50%。关键是要和本地供应商提前谈好佣金比例(通常 15-25%),并且在入住前一天给客人发推荐短信,转化率最高。
6. 消防与卫生合规
每间房必须有独立烟感报警器 + 走廊灭火器。布草一客一换是底线------床单被套枕套必换,毛巾浴巾通常备两套让客人自己决定是否更换。有浴缸的房型每周要做一次高温消毒并记录。开业前必须过消防验收,每年做一次卫生检测。
这趟旅程走完,把组件重新看一遍就通了:预订是智脑的场景化理解与模型可替换,到店是初始化与问候让数字人带着坐标出场,入住 是 ask() 一条链路统一生成语言与动作,在店是多模态感知接住插话、并用 AI 端渲守住弱网,退房 是 destroy 的干净收尾。明信片、房卡套、木牌、印章、胶带------这些 CSS 物件不是氛围填充,而是这五个时刻各自的信息载体。理解了「旅程」,也就理解了为什么这个界面不多不少,刚好需要这几样东西。