VR 带看3(TIM 版本)集成指南
本文档面向接入众趣 VR 带看3(TIM 版本)功能的开发者,说明如何在自己的项目中一步步完成远程 VR 带看:从环境配置、SDK 初始化,到启动会话、双端 VR 同步、UI 同步、画笔标注,以及结束带看的完整流程。按顺序完成以下步骤即可实现「一方操作,另一方同步跟随」的远程带看体验。
前置阅读: - VR 初始化展示指南 — 带看依赖 VR 场景已完成初始化(
housePlay可用)。 - 带看3 SDK 对接指导文档 — SDK 底层 API 的完整参考。
一、功能简介与整体架构
1.1 什么是 VR 带看3
VR 带看3 是基于 TIM(腾讯即时通讯)的远程带看方案。两个用户各自打开同一模型的 VR 页面,通过 IM 通道实时同步:
- VR 视角同步:相机旋转、缩放、场景切换、模式切换等核心操作
- UI 状态同步:房源详情、楼层切换、场景列表、免责声明等应用层 UI 操作
- 画笔标注同步:双方可在画面上实时绘制标注,笔画实时同步
- 语音通话:通过 SDK 内置的音频通道进行语音沟通
1.2 整体架构
带看3 模块采用三层架构:
┌─────────────────────────────────────────────┐
│ Vue 组件层(UI) │
│ MobileTakeLookContainer / ControlStrip / │
│ Panel / Brush / PersonnelList 等 │
├─────────────────────────────────────────────┤
│ Pinia Store 层(业务编排) │
│ stores/takelook3.ts │
│ ├── 生命周期管理(init / start / end) │
│ ├── SDK 事件绑定与分发 │
│ ├── viewer-sync(VR 视角同步) │
│ ├── ui-sync(UI 状态同步) │
│ └── brush(画笔标注) │
├─────────────────────────────────────────────┤
│ SDK 封装层(桥接) │
│ services/takelook3/sdk.ts │
│ └── 对 @takelook3-sdk (NestTakelook3) 的封装 │
├─────────────────────────────────────────────┤
│ 带看3 SDK(底层) │
│ @takelook3-sdk / NestTakelook3 │
│ └── TIM 连接、消息收发、音频通道等 │
└─────────────────────────────────────────────┘
1.3 整体时序图
下图展示了带看3从创建会议室到结束带看的完整交互时序:

1.4 前置条件
- VR 场景已初始化:
housePlay实例可用(参见 VR 初始化展示指南)。 - 众趣开放平台账号:需要
APP_ID、APP_SECRET用于获取 token。 - 带看3 SDK:项目中已引入
@takelook3-sdk依赖(即vr-takelook-plugin.js)。 - URL 参数:带看功能通过 URL 参数控制开启与角色分配。
1.5 角色说明
| 角色 | 标识 | 说明 |
|---|---|---|
| 发起者(Caller) | userId === callerUserId |
发起带看的一方,通常是经纪人。拥有操作主导权,新成员加入时广播当前视角 |
| 接听者(Viewer) | userId !== callerUserId |
被邀请进入带看的一方,通常是客户。默认跟随发起者视角 |
二、集成步骤概览
| 步骤 | 内容 |
|---|---|
| 1 | 配置环境变量(API 地址、APP_ID、APP_SECRET) |
| 2 | 引入带看3 SDK 并配置模块别名 |
| 3 | 解析 URL 参数,判断是否开启带看 |
| 4 | 在 VR 初始化完成后初始化带看 SDK(initTakelookSDK) |
| 5 | 启动带看会话(startTour) |
| 6 | 注册 SDK 事件监听与处理 |
| — | 理解 SDK 消息通道与收发对照(三条通道、发送 API 与监听事件的对应关系) |
| 7 | 处理 VR 视角同步(viewer-sync) |
| 8 | 处理 UI 状态同步(ui-sync) |
| 9 | 集成画笔标注功能(brush) |
| 10 | 结束带看(endTour) |
以下按步骤展开。
三、第一步:配置环境变量
在项目根目录的 .env 文件中配置以下变量:
# 众趣开放平台 APP 凭证(用于获取 token)
VITE_APP_ZQ_APPID='你的AppId'
VITE_APP_ZQ_SECRET='你的AppSecret'
# 带看3后端 API 地址(SDK 的 baseUrl)
VITE_APP_TAKELOOK3_API_BASE_URL='https://opendev.3dnest.cn'
# 带看3服务 ID(可选,也可通过 URL 参数 takeSeeToken 传入)
VITE_APP_TAKELOOK3_SERVICE_ID='your-service-id'
说明:
- VITE_APP_ZQ_APPID / VITE_APP_ZQ_SECRET:在众趣开放平台申请获得,用于计算签名并获取 token。
- VITE_APP_TAKELOOK3_API_BASE_URL:带看3服务的后端地址,SDK 的所有 API 请求都会发往此地址。
- VITE_APP_TAKELOOK3_SERVICE_ID:服务 ID,优先从 URL 参数 takeSeeToken 获取,回退到此环境变量。
四、第二步:引入带看3 SDK 并配置模块别名
4.1 SDK 文件
带看3 SDK 打包产物为 vr-takelook-plugin.js,放置在 src/services/takelook3/lib/ 目录下。该文件导出 NestTakelook3 类(即 GuidedTourFacade),是所有带看操作的入口。
4.2 Vite 别名配置
在 vite.config.ts 中为 SDK 配置模块别名,方便在代码中以 @takelook3-sdk 导入:
// vite.config.ts
import { defineConfig } from 'vite'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'@takelook3-sdk': path.resolve(__dirname, 'src/services/takelook3/lib/vr-takelook-plugin.js'),
},
},
})
配置完成后,在业务代码中即可如下导入:
import { NestTakelook3 } from '@takelook3-sdk'
import type { StartTourOptions, GuidedTourCustomAction } from '@takelook3-sdk'
重要:业务层不应直接
importSDK,而是通过services/takelook3/sdk.ts封装层间接调用,确保所有 SDK 调用有统一的入口。
五、第三步:解析 URL 参数
带看功能通过 URL query 参数控制。以下是参数列表:
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
useVrTakeSee |
0 \| 1 |
是 | 是否开启带看功能,1 为开启 |
startTour |
0 \| 1 |
否 | 是否自动进入带看(无需用户点击按钮) |
userId |
String | 是 | 当前用户 ID |
callerUserId |
String | 是 | 发起者用户 ID(与 userId 相同则为发起者) |
meetingId |
String | 否 | 带看会议 ID(不传则自动创建) |
roomId |
String | 否 | TIM 群组 ID(不传则自动创建) |
packageId |
String | 否 | 套餐/房源包 ID(callOut / createMeetingRoom 时使用) |
pattern |
String | 否 | 呼叫模式,callout 表示呼叫模式 |
imPlatform |
String | 否 | 平台类型:web / app / mp,默认 web |
noAudioCall |
0 \| 1 |
否 | 是否禁用语音通话,1 为禁用 |
isInMPWeb |
0 \| 1 |
否 | 是否在微信小程序 webview 中运行 |
takeSeeToken |
String | 否 | 服务 token(同时作为 serviceId) |
kfApiURL |
String | 否 | API 地址(优先级高于 .env) |
ext |
String | 否 | 自定义扩展参数 |
5.1 参数解析实现
参数解析封装在 services/takelook3/params.ts 中:
import type { StartTourOptions } from '@takelook3-sdk'
import type { TakeLook3PageParams } from './types'
const ENV_API_BASE_URL = import.meta.env.VITE_APP_TAKELOOK3_API_BASE_URL as string ?? ''
const ENV_SERVICE_ID = import.meta.env.VITE_APP_TAKELOOK3_SERVICE_ID as string ?? ''
function getParam(key: string): string {
return new URLSearchParams(window.location.search).get(key) ?? ''
}
export function parseTakeLook3PageParams(): Partial<TakeLook3PageParams> {
const enabled = getParam('useVrTakeSee') === '1'
const userId = getParam('userId')
const callerUserId = getParam('callerUserId')
const isCaller = userId !== '' && callerUserId !== '' && userId === callerUserId
const autoStart = getParam('startTour') === '1'
return {
enabled,
isCaller,
autoStart,
userId,
callerUserId,
meetingId: getParam('meetingId'),
roomId: getParam('roomId'),
packageId: getParam('packageId'),
serviceId: getParam('takeSeeToken'),
kfApiURL: getParam('kfApiURL') || ENV_API_BASE_URL,
imPlatform: getParam('imPlatform') || 'web',
sdkAppId: getParam('sdkAppId'),
userSig: '',
groupId: getParam('meetingId'),
noAudioCall: getParam('noAudioCall') === '1',
pattern: getParam('pattern'),
ext: getParam('ext'),
isInMPWeb: getParam('isInMPWeb') === '1',
takeSeeToken: getParam('takeSeeToken'),
}
}
关键逻辑:
- 角色判定:当 userId === callerUserId 时,判定为发起者(isCaller = true)。
- 回退机制:kfApiURL 优先取 URL 参数,否则回退到 .env 中的 VITE_APP_TAKELOOK3_API_BASE_URL。
5.2 URL 示例
发起者(经纪人)端 URL:
https://your-domain.com/vr?m=模型ID&useVrTakeSee=1&userId=agent_01&callerUserId=agent_01&packageId=pkg001&startTour=1&imPlatform=web
接听者(客户)端 URL:
https://your-domain.com/vr?m=模型ID&useVrTakeSee=1&userId=customer_01&callerUserId=agent_01&meetingId=xxx&roomId=xxx&startTour=1&imPlatform=web
六、第四步:初始化带看 SDK
带看 SDK 的初始化发生在 VR 场景加载完成之后(init_UI 事件触发后)。
6.1 获取 Token
初始化 SDK 前需先通过众趣开放平台 API 获取 token。本仓库实现在 src/api/getTakelookToken.ts 中:
import md5 from 'md5'
const APP_ID = import.meta.env.VITE_APP_ZQ_APPID
const APP_SECRET = import.meta.env.VITE_APP_ZQ_SECRET
const BASE_URL = import.meta.env.VITE_APP_TAKELOOK3_API_BASE_URL
const url = BASE_URL + '/api/v2/op/token/takelook/'
export async function getOpenToken() {
const request_id = Math.random().toString(36).substring(2, 15)
const timestamp = Date.now()
const sign = md5(APP_ID + APP_SECRET + timestamp + request_id)
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: APP_ID,
timestamp: timestamp,
request_id: request_id,
sign: sign,
}),
})
const data = await res.json()
return data.data.token
}
安全提醒:生产环境中,签名计算应在服务端完成,避免将
APP_SECRET暴露到前端代码中。本仓库中的前端直接计算仅作为演示用途。
6.2 SDK 封装层初始化
SDK 封装层 services/takelook3/sdk.ts 提供了 initTakeLook3Sdk 函数:
import { NestTakelook3 } from '@takelook3-sdk'
export async function initTakeLook3Sdk(
token: string,
baseUrl: string,
): Promise<{ sdkAppId?: string | number }> {
if (!token || !baseUrl) {
console.warn('[带看3] initTakeLook3Sdk: token 或 baseUrl 为空,跳过初始化')
return {}
}
// 设置 SDK 参数(token 作为 serviceId)
await NestTakelook3.setSDKParams(token, baseUrl)
// 获取配置(sdkAppId 等)
const configResult = await NestTakelook3.getConfig()
return extractSdkAppIdFromConfig(configResult)
}
流程说明:
1. NestTakelook3.setSDKParams(token, baseUrl) — 设置 SDK 的服务凭证和 API 地址。
2. NestTakelook3.getConfig() — 获取 TIM 配置信息,返回 sdkAppId 等。
6.3 Store 层初始化编排
Store 层的 initTakelookSDK 方法编排了完整的初始化流程:
async function initTakelookSDK(housePlay: HousePlayInstance): Promise<void> {
initPageParams() // 1. 重新解析 URL 参数
housePlayRef = housePlay // 2. 保存 housePlay 引用
sceneList.value = getScenesFromHousePlay(housePlay) // 3. 提取场景列表
if (!enabled.value || housePlayBound) return // 4. 带看未开启或已绑定则跳过
initUiSyncReplay() // 5. 注册 UI 同步回放处理器
bindSdkHandlers() // 6. 注册 SDK 事件回调
// 7. 获取 token 并初始化 SDK
const token = await getOpenToken()
const configResult = await initTakeLook3Sdk(token, OPEN_API_BASE_URL)
if (configResult.sdkAppId) {
pageParams.value = { ...pageParams.value, sdkAppId: String(configResult.sdkAppId) }
}
// 8. App 端需要额外初始化同步器
if (pageParams.value.imPlatform === 'app') {
initAppRemoteSyncer()
}
housePlayBound = true
status.value = 'bound'
}
6.4 触发时机
在 VR Store 的 init_UI 事件回调中触发带看初始化:
// stores/vr.ts 中 init_UI 事件回调
housePlay.on('init_UI', async () => {
// ... 其他初始化逻辑 ...
// 带看3 初始化(延迟 1 秒,确保 housePlay 完全就绪)
setTimeout(async () => {
if (isTakeLook3Enabled.value) {
await takeLook3Store.initTakelookSDK(housePlay)
await takeLook3Store.maybeAutoStart()
}
}, 1000)
})
七、第五步:启动带看会话
7.1 启动方式
带看有两种启动方式:
- 自动启动:URL 参数
startTour=1,SDK 初始化完成后自动调用maybeAutoStart()。 - 手动启动:用户点击带看按钮触发
startTour(true)。
7.2 构建启动参数
buildTakeLook3StartPayload 将页面参数转换为 SDK 所需的 StartTourOptions:
export function buildTakeLook3StartPayload(params: TakeLook3PageParams): {
startOptions: StartTourOptions
} {
const startOptions: StartTourOptions = {
userId: params.userId,
callerUserId: params.callerUserId,
imPlatform: params.imPlatform,
userSig: params.userSig,
noAudioCall: Boolean(params.noAudioCall),
}
if (params.serviceId) startOptions.serviceId = params.serviceId
if (params.packageId) startOptions.packageId = params.packageId
if (params.kfApiURL) startOptions.kfApiURL = params.kfApiURL
if (params.meetingId) startOptions.meetingId = params.meetingId
if (params.roomId) startOptions.roomId = params.roomId
if (params.groupId) startOptions.groupId = params.groupId
if (params.sdkAppId) startOptions.sdkAppId = params.sdkAppId
if (params.pattern) startOptions.pattern = params.pattern
if (params.ext) startOptions.ext = params.ext
if (params.isInMPWeb) {
startOptions.isInMPWeb = true
startOptions.isWeChatMiniProgram = true
}
return { startOptions }
}
7.3 会话启动流程(SDK 封装层)
startTakeLook3Session 是 SDK 封装层的启动入口,包含会议室创建与 startTour 调用:
export async function startTakeLook3Session(
options: StartTourOptions,
hooks?: StartTakeLook3SessionHooks,
): Promise<void> {
// 呼叫模式(callout):发起呼叫并获取 meetingId
if (options.pattern === 'callout' && options.userId && options.packageId) {
const callOutResult = await NestTakelook3.callOut(options.userId, options.packageId)
const { meetingId, roomId } = extractMeetingIdFromResult(callOutResult)
options.meetingId = meetingId
options.roomId = roomId
hooks?.onSessionIdAllocated?.({ meetingId, roomId })
}
// 会议室模式(无 roomId 时):创建会议室
else if (!options.roomId && options.userId && options.packageId) {
const meetingRoomResult = await NestTakelook3.createMeetingRoom(options.packageId)
const { meetingId, roomId } = extractMeetingIdFromResult(meetingRoomResult)
options.meetingId = meetingId
options.roomId = roomId
hooks?.onSessionIdAllocated?.({ meetingId, roomId })
}
// 启动带看(SDK 内部会处理 TIM 登录、加群等)
await NestTakelook3.startTour(options)
}
两种模式的区别:
- callout 模式:通过 NestTakelook3.callOut() 发起呼叫,会推送通知到对方。
- 会议室模式:通过 NestTakelook3.createMeetingRoom() 创建会议室,需将 URL 分享给对方。
7.4 Store 层启动编排
Store 层的 startTour 方法编排了完整的启动流程:
async function startTour(manual = false): Promise<void> {
if (!enabled.value || !housePlayRef) return
if (status.value === 'starting' || isTakeLook.value) return
status.value = 'starting'
showToast('call', '正在连接...')
// 发起者启动 60s 连接倒计时
if (isCaller.value) {
startConnectingCountdown()
}
try {
const payload = buildTakeLook3StartPayload(pageParams.value)
// 启动会话,并通过回调回写新分配的 meetingId/roomId
await startTakeLook3Session(payload.startOptions, {
onSessionIdAllocated: ({ meetingId: newMeetingId, roomId: newRoomId }) => {
pageParams.value = {
...pageParams.value,
meetingId: newMeetingId != null ? String(newMeetingId) : pageParams.value.meetingId,
roomId: newRoomId != null ? String(newRoomId) : pageParams.value.roomId,
}
},
})
// 启用本地操作同步
enableTakeLook3LocalSync(isCaller.value)
// 初始化 VR 视角同步器
initViewerSync()
isTakeLook.value = true
status.value = 'connected'
} catch (error) {
status.value = 'error'
showToast('disconnected', '连接失败')
stopConnectingCountdown()
if (manual) throw error
}
}
7.5 连接状态流转
idle → bound → starting → connected → ended
↓
error
| 状态 | 说明 |
|---|---|
idle |
初始状态 |
bound |
housePlay 已绑定、SDK 已初始化 |
starting |
正在建立连接 |
connected |
连接成功,带看进行中 |
ended |
带看已结束 |
error |
连接失败 |
八、第六步:SDK 事件监听与处理
SDK 事件通过 registerTakeLook3SdkHandlers 统一注册。以下是各事件的用途及处理逻辑:
8.1 事件注册
export function registerTakeLook3SdkHandlers(handlers: TakeLook3SdkHandlers): void {
if (handlers.onSdkReady) NestTakelook3.onSdkReady(handlers.onSdkReady)
if (handlers.onUserConnected) NestTakelook3.onUserConnected(handlers.onUserConnected)
if (handlers.onEnd) NestTakelook3.onEndTour(handlers.onEnd)
if (handlers.onRemoteControllingNotify) NestTakelook3.onRemoteControllingNotify(handlers.onRemoteControllingNotify)
if (handlers.onNetworkStatusChanged) NestTakelook3.onNetworkStatusChanged(handlers.onNetworkStatusChanged)
if (handlers.onRemoteCustomAction) NestTakelook3.onSyncRemoteCustomActionToLocal(handlers.onRemoteCustomAction)
if (handlers.onMemberJoin) NestTakelook3.onMemberJoin(handlers.onMemberJoin)
if (handlers.onMemberExit) NestTakelook3.onMemberExit(handlers.onMemberExit)
if (handlers.onVoiceForbidden) NestTakelook3.onVoiceForbidden(handlers.onVoiceForbidden)
if (handlers.onChangeHouse) NestTakelook3.onChangeHouse(handlers.onChangeHouse)
if (handlers.onMessageToUI) NestTakelook3.onMessageToUI(handlers.onMessageToUI)
}
8.2 核心事件说明
| 事件 | SDK 方法 | 说明 |
|---|---|---|
| SDK 就绪 | onSdkReady |
SDK 内部初始化完成 |
| 用户连接成功 | onUserConnected |
TIM 登录并加群成功。接听方在此标记连接完成;发起方需等对方加入后标记 |
| 带看结束 | onEndTour |
任何一方结束带看时触发。参数 isMe 标识是否本人触发 |
| 远程核心操作 | onRemoteControllingNotify |
收到远端的 VR 视角操作数据(相机旋转、模式切换等) |
| 网络状态变化 | onNetworkStatusChanged |
网络质量变化,level 可为 good / normal / poor / offline |
| 远程自定义操作 | onSyncRemoteCustomActionToLocal |
收到远端的自定义 UI 操作(画笔、UI 同步等) |
| 成员加入 | onMemberJoin |
有新成员加入 TIM 群组 |
| 成员退出 | onMemberExit |
有成员退出 TIM 群组。群内少于 2 人时自动结束带看 |
| 语音禁言 | onVoiceForbidden |
某成员被静音/取消静音 |
8.3 连接完成判定逻辑
发起者与接听者的连接判定逻辑不同:
- 接听者:
onUserConnected触发后即标记为已连接(TIM 登录成功 = 带看就绪)。 - 发起者:需等待
onMemberJoin事件(有人加入群组),或通过fetchAndSyncGroupMembers轮询群成员列表后,检测到至少 2 人才标记为已连接。
function markViewerConnected(toastMsg?: string): void {
if (viewerConnected.value) return
viewerConnected.value = true
stopConnectingCountdown()
startConnectedDurationTimer()
setHousePlayConnectState(true)
// 将自身加入成员列表
const selfId = pageParams.value.userId
if (selfId && !members.value.some((m) => m.userId === selfId)) {
members.value = [
{ userId: selfId, name: selfId, role: pageParams.value.isCaller ? '发起者' : '参与者' },
...members.value,
]
}
if (toastMsg) showToast('connected', toastMsg)
// 发起方连接完成后播放进入动画
if (pageParams.value.isCaller) {
enterAnimationVisible.value = true
}
}
其中 setHousePlayConnectState(true) 内部调用了 housePlay.setConnectionStatus(true):
function setHousePlayConnectState(state: boolean) {
if (!housePlayRef) return
viewerConnected.value = state
housePlayRef.setConnectionStatus(state)
}
关键:
housePlay.setConnectionStatus(true)是连接带看时必不可少的一步。housePlay 内部依赖此状态来决定是否启用操作同步——只有设置为true后,uploadLocalActions事件才会触发、syncRemoteActions才会生效。带看结束时必须调用setConnectionStatus(false)重置。
8.4 连接超时处理
发起者在 startTour 时启动 60 秒倒计时。如果超时未连接成功,自动结束带看:
const CONNECT_TIMEOUT_SECONDS = 60
function startConnectingCountdown(): void {
connectingCountdown.value = CONNECT_TIMEOUT_SECONDS
countdownTimer = window.setInterval(() => {
connectingCountdown.value -= 1
if (connectingCountdown.value <= 0) {
stopConnectingCountdown()
if (!viewerConnected.value) {
showToast('disconnected', '连接超时')
void endTour('connect_timeout')
}
}
}, 1000)
}
九、SDK 消息通道与收发对照
带看3 SDK 的核心通信能力基于 TIM 消息系统。理解 SDK 提供的三条消息通道及其对应的「发送 API → 监听事件」关系,是集成带看功能的关键。
9.1 三条消息通道
SDK 内部所有 TIM 消息都封装在统一信封中,通过 category 字段区分消息类别:
{
"namespace": "nest-takelook3",
"category": "cmd | viewerAction | customAction",
"payload": { ... }
}
| 通道 | category | 用途 | 发送 API | 监听事件 |
|---|---|---|---|---|
| VR 状态同步 | viewerAction |
同步 VR 视角(相机旋转、缩放、场景切换等) | syncLocalActionToRemote(vrState) |
onRemoteControllingNotify(cb) |
| 自定义业务动作 | customAction |
同步 UI 操作、画笔、楼层切换等业务逻辑 | syncLocalCustomActionToRemote(action) |
onSyncRemoteCustomActionToLocal(cb) |
| 底层指令 | cmd |
静音、踢人等控制指令 | sendCmd(targetUserId, msg) / voiceForbidden() / kickOutById() |
onVoiceForbidden(cb) / onKickOut(cb) |
SDK 自动过滤
namespace ≠ 'nest-takelook3'的消息,不影响宿主应用自身的 TIM 消息处理。
9.2 VR 状态同步通道(viewerAction)
用于 VR 渲染器的视角同步,是带看最核心的消息通道。
发送端:本地 VR 操作发生时,调用 syncLocalActionToRemote 将状态广播到远端所有参与者:
// 方式一:直接调用 SDK
NestTakelook3.syncLocalActionToRemote(vrState)
// 方式二:通过封装层(本仓库推荐)
// viewer-sync 会监听 housePlay.uploadLocalActions 事件,自动节流后调用
await sendTakeLook3CoreAction({ action: 'viewer/core', payload: vrState })
接收端:远端操作到达时,onRemoteControllingNotify 回调触发,将数据应用到本地 VR 渲染器:
NestTakelook3.onRemoteControllingNotify((action) => {
// action 中包含远端的 VR 状态数据
// 常规增量同步
housePlay.syncRemoteActions(action.payload)
// 首次快照同步(新成员加入时)
// housePlay.firstSyncRemoteActions(action.payload.state)
})
同步开关:建议在 startTour 完成前关闭同步,连接成功后再开启,避免产生无效消息:
// startTour 前关闭
NestTakelook3.enableSyncLocalActionToRemote(false)
// onUserConnected 后开启
NestTakelook3.enableSyncLocalActionToRemote(true, isCaller)
9.3 自定义业务动作通道(customAction)
用于 VR 视角以外的所有业务逻辑同步,包括 UI 面板联动、画笔标注、楼层切换等。
发送端:将自定义动作广播到远端所有参与者:
// 通过 SDK
await NestTakelook3.syncLocalCustomActionToRemote({
action: 'app/mobile/ui/floor/change', // 动作标识符
payload: { value: 2, timestamp: Date.now() }, // 携带数据
})
// 通过封装层(本仓库推荐)
await sendTakeLook3CustomAction({ action, payload })
接收端:远端自定义动作到达时,onSyncRemoteCustomActionToLocal 回调触发:
NestTakelook3.onSyncRemoteCustomActionToLocal((action, from) => {
// action.action — 动作标识符(字符串)
// action.payload — 动作携带的数据
// from — 发送方的用户 ID
switch (action.action) {
case 'app/mobile/ui/floor/change':
// 楼层切换
break
case 'brush/stroke':
// 远端画笔笔画
break
case 'viewer/core':
// VR 核心操作(也可能通过此通道)
break
}
})
本仓库中通过此通道同步的所有动作:
| 动作标识(action) | 通道 | 发送时机 | 接收处理 |
|---|---|---|---|
viewer/core |
核心操作 | VR 视角变化时(viewer-sync 发送) | housePlay.syncRemoteActions() 同步到本地 VR |
viewer/ui |
核心操作 | VR UI 相关操作 | viewer-sync 分发处理 |
app/mobile/ui/disclaimer/visible |
UI 同步 | 免责声明显隐 | useDisclaimerStore().applyRemoteDisclaimerVisible() |
app/mobile/ui/house-detail/visible |
UI 同步 | 房源详情面板显隐 | useHouseInfoStore().applyRemoteHouseDetailVisible() |
app/mobile/ui/house-detail/tab-change |
UI 同步 | 房源详情 tab 切换 | useHouseInfoStore().applyRemoteDetailActiveSlide() |
app/mobile/ui/detail-image-swiper/visible |
UI 同步 | 房源详情图片轮播显隐 | useHouseInfoStore().applyRemoteImgSwiper() |
app/mobile/ui/big-map/visible |
UI 同步 | 大地图显隐 | useHouseInfoStore().applyRemoteBigMapVisible() |
app/mobile/ui/floor/change |
UI 同步 | 楼层切换 | useFloorStore().applyRemoteFloor() |
app/mobile/ui/takelook-scene/switch |
UI 同步 | 带看空间切换 | applyRemoteSceneSwitch() → housePlay.warpToScene() |
app/mobile/ui/takelook-scene-list/visible |
UI 同步 | 空间列表面板显隐 | toggleSceneList(visible, true) |
app/mobile/ui/share/visible |
UI 同步 | 分享组件显隐 | 暂未实现 |
brush/enter |
画笔 | 一端进入画笔模式 | 另一端自动进入画笔模式 |
brush/exit |
画笔 | 一端退出画笔模式 | 另一端自动退出画笔模式 |
brush/stroke |
画笔 | 完成一笔绘制 | 远端绘制该笔画 brushInstance.addRemoteStroke() |
brush/undo |
画笔 | 撤销自己的一笔 | 远端删除该笔画 brushInstance.removeStrokeByUuid() |
brush/clear |
画笔 | 清空所有笔画 | 远端清空画布 brushInstance.clear() |
member/mute |
SDK 内置 | 静音/取消静音 | 更新成员静音状态 |
9.4 底层指令通道(cmd)
用于静音、踢人等控制类指令。与 customAction 不同,cmd 支持点对点发送(C2C 消息)。
静音/取消静音:
// 发送:对指定用户设置静音
NestTakelook3.voiceForbidden(userId, true) // 静音
NestTakelook3.voiceForbidden(userId, false) // 取消静音
// 监听:收到静音指令
NestTakelook3.onVoiceForbidden((userId, status) => {
// userId — 被操作的用户 ID
// status — true=被静音, false=取消静音
updateMemberMuteState(userId, status)
})
踢出用户:
// 发送:踢出指定用户
NestTakelook3.kickOutById(userId)
// 监听:当前用户被踢出
NestTakelook3.onKickOut(() => {
// 被踢出后应退出带看页面
navigateToHome()
})
点对点消息:
// 发送:向指定用户发送私聊消息(不经过群消息)
await NestTakelook3.sendCmd('target_user_id', { type: 'custom', data: '...' })
// 接收:通过 receivedCmd 将外部消息注入 SDK 解析(当宿主有独立消息通道时使用)
NestTakelook3.receivedCmd(externalMessage)
9.5 SDK 内置保留动作名
以下动作名为 SDK 内部使用,自定义动作请勿使用这些名称:
| 动作名 | 说明 | 触发 API |
|---|---|---|
member/mute |
静音指令 | voiceForbidden() 内部发送 |
moderation/kickOut |
踢出指令 | kickOutById() 内部发送 |
viewer/syncHouseState |
VR 状态同步 | syncLocalActionToRemote() 内部发送 |
9.6 完整收发对照速查表
下表汇总了 SDK 所有「发送 API → 对端监听事件」的对应关系:
| 场景 | 本端调用(发送) | 对端回调(接收) | 说明 |
|---|---|---|---|
| VR 视角同步 | syncLocalActionToRemote(vrState) |
onRemoteControllingNotify(cb) |
持续调用,建议节流 ~30fps |
| 自定义动作广播 | syncLocalCustomActionToRemote(action) |
onSyncRemoteCustomActionToLocal(cb) |
UI 同步、画笔等业务逻辑 |
| VR 同步开关 | enableSyncLocalActionToRemote(enabled) |
— | 仅控制本端是否发送,不产生消息 |
| 静音 | voiceForbidden(userId, status) |
onVoiceForbidden(cb) |
通过 IM 指令消息同步 |
| 踢出用户 | kickOutById(userId) |
onKickOut(cb) |
被踢方收到回调 |
| 点对点消息 | sendCmd(targetUserId, msg) |
— | 需配合 receivedCmd 或自定义处理 |
| 发起呼叫 | callOut(userId, packageId) |
— | 后端推送通知到对方 |
| 创建会议室 | createMeetingRoom(packageId) |
— | 返回 meetingId/roomId,需通过 URL 分享 |
| 启动带看 | startTour(options) |
onSdkReady(cb) + onUserConnected(cb) |
对端也需调用 startTour 加入 |
| 结束带看 | endTour(reason) |
onEndTour(cb) |
双方都会收到回调 |
| 心跳保活 | sendHeartBeat(userId, meetingId) |
— | 建议每 30 秒调用一次 |
9.7 消息流向示意
发起方(经纪人) SDK / TIM 接收方(客户)
│ │ │
│ syncLocalActionToRemote(vrState) │ │
│ ─────────────────────────────────> │ │
│ │ ── TIM 群消息 (viewerAction) ──────> │
│ │ │ onRemoteControllingNotify(cb)
│ │ │
│ syncLocalCustomActionToRemote(act) │ │
│ ─────────────────────────────────> │ │
│ │ ── TIM 群消息 (customAction) ──────> │
│ │ │ onSyncRemoteCustomActionToLocal(cb)
│ │ │
│ voiceForbidden(userId, true) │ │
│ ─────────────────────────────────> │ │
│ │ ── TIM 群消息 (cmd) ──────────────> │
│ │ │ onVoiceForbidden(cb)
│ │ │
│ │ <── TIM 群消息 (viewerAction) ───── │
│ onRemoteControllingNotify(cb) │ │ syncLocalActionToRemote(vrState)
│ │ │
│ │ <── TIM 群消息 (customAction) ───── │
│ onSyncRemoteCustomActionToLocal(cb) │ │ syncLocalCustomActionToRemote(act)
│ │ │
双向对等:发送和接收是完全对称的。无论哪一方调用发送 API,另一方都会收到对应的监听回调。发起者和接听者使用完全相同的 API。
十、第七步:VR 视角同步(viewer-sync)
VR 视角同步是带看的核心功能,负责将一方的 VR 操作实时同步到另一方。
9.1 同步器创建
viewer-sync.ts 封装了视角同步逻辑。在 startTour 成功后创建:
function initViewerSync(): void {
if (!housePlayRef || viewerSyncInstance) return
viewerSyncInstance = createViewerSync(housePlayRef, {
sendAction: async (action) => {
if (action.action === 'viewer/core') {
await sendTakeLook3CoreAction(action) // 核心操作走 SDK 核心通道
} else if (action.action === 'viewer/ui') {
await sendTakeLook3CustomAction(action) // UI 操作走自定义通道
}
},
onOperatorChanged: (op) => {
currentOperator.value = op
isRemoteControlling.value = op === 'remote'
housePlayRef?.setValidOperator?.(op)
if (op === 'remote') flashRemoteControllingOverlay()
},
})
viewerSyncInstance.bind()
}
9.2 操作权限状态机
视角同步器维护一个三态操作权限状态机:
| 状态 | 说明 |
|---|---|
free |
自由状态,任何一方都可操作 |
local |
本地操作中,本地操作会同步到远端 |
remote |
远端操作中,本地操作被屏蔽 |
状态转换规则:
- 本地操作 → 进入 local,发送操作到远端
- 收到远端操作 → 进入 remote,开始远控超时计时
- 远控超时(1500ms 无新远端操作)→ 回到 free
9.3 本地操作发送
监听 housePlay.uploadLocalActions 事件,节流后发送到远端:
function handleUploadLocalActions(data: unknown): void {
if (destroyed || operator === 'remote') return // 远控中不发送
// changemode 等关键操作直接发送,不走节流
if (data?.type === 'changemode') {
callbackHandlers.sendAction({ action: 'viewer/core', payload: data })
setOperator('local')
return
}
pendingLocalAction = data
if (!throttleTimer) {
throttleTimer = setTimeout(flushLocalAction, 33) // ~30fps 节流
}
}
9.4 远端操作接收
收到远端的 viewer/core 消息后,调用 housePlay.syncRemoteActions() 执行同步:
function handleRemoteCoreAction(payload: Record<string, unknown> | undefined): void {
if (!payload) return
// 首次同步(快照):调用 firstSyncRemoteActions
if (payload.isFirstSyncAction && typeof housePlay.firstSyncRemoteActions === 'function') {
housePlay.firstSyncRemoteActions(payload.state)
return
}
// 增量同步:调用 syncRemoteActions
if (typeof housePlay.syncRemoteActions === 'function') {
housePlay.syncRemoteActions(payload.data ?? payload)
}
setRemoteControlling()
}
9.5 视角广播(新成员加入时)
发起者在新成员加入时,将当前 VR 状态广播给所有人:
function broadcastHouseState(): void {
if (typeof housePlay.getCurrentState !== 'function') return
const state = housePlay.getCurrentState()
if (!state) return
callbackHandlers.sendAction({
action: 'viewer/core',
payload: { state, isFirstSyncAction: true },
})
}
十一、第八步:UI 状态同步(ui-sync)
除了 VR 视角同步外,应用层的 UI 操作(打开/关闭面板、切换楼层等)也需要双端同步。
10.1 同步协议
UI 同步使用统一前缀 app/mobile/ui/,定义在 ui-sync/protocol.ts 中:
| 事件名 | 说明 |
|---|---|
app/mobile/ui/disclaimer/visible |
免责声明显隐 |
app/mobile/ui/house-detail/visible |
房源详情显隐 |
app/mobile/ui/house-detail/tab-change |
房源详情 tab 切换 |
app/mobile/ui/detail-image-swiper/visible |
房源详情图片轮播 |
app/mobile/ui/big-map/visible |
大地图显隐 |
app/mobile/ui/floor/change |
楼层切换 |
app/mobile/ui/takelook-scene/switch |
带看模式空间切换 |
app/mobile/ui/takelook-scene-list/visible |
空间列表面板显隐 |
app/mobile/ui/share/visible |
分享组件显隐 |
10.2 发送端:dispatcher(本地 → 远端)
组件中的 UI 操作通过 dispatchUiSyncEvent 发送到远端:
// 在组件/store 中调用
await dispatchUiSyncEvent(UI_SYNC_EVENT.FLOOR_CHANGE, floorValue)
// dispatcher 内部会包装为 GuidedTourCustomAction 并发送
export async function dispatchUiSyncEvent(
eventName: string,
payloadOrValue?: unknown,
): Promise<void> {
if (muted) return // 静默态时不发送(防止消息环路)
const payload = isPlainObject(payloadOrValue)
? { ...payloadOrValue, timestamp: Date.now() }
: { value: payloadOrValue, timestamp: Date.now() }
await sendTakeLook3CustomAction({ action: eventName, payload })
}
10.3 接收端:replayer(远端 → 本地)
收到远端 UI 同步消息后,replayUiSyncAction 按事件名分发到对应的处理函数:
export function replayUiSyncAction(action: GuidedTourCustomAction): boolean {
if (!isUiSyncAction(action.action)) return false
muteUiSync() // 进入静默态
try {
switch (action.action) {
case UI_SYNC_EVENT.FLOOR_CHANGE:
handlers.onFloorChange?.(action.payload?.value)
break
case UI_SYNC_EVENT.HOUSE_DETAIL_VISIBLE:
handlers.onHouseDetailVisible?.(Boolean(action.payload?.value))
break
// ... 其他事件
}
} finally {
unmuteUiSync() // 退出静默态
}
return true
}
防消息环路:回放远端消息时先 muteUiSync()(进入静默态),回放完成后 unmuteUiSync()。在静默态中,dispatchUiSyncEvent 会自动忽略发送,避免「远端回放 → 触发本地 watcher → 再发回远端」的死循环。
10.4 在业务组件中添加 UI 同步
如需同步新的 UI 操作,按以下步骤:
步骤 1:在 protocol.ts 中新增事件常量:
export const UI_SYNC_EVENT = {
// ... 现有事件
MY_NEW_EVENT: `${UI_SYNC_PREFIX}my-module/action`,
} as const
步骤 2:在组件操作入口处发送同步:
// 组件中
const takeLookStore = useTakeLook3Store()
function handleMyAction(value: boolean) {
// 本地操作
myStore.setVisible(value)
// 同步到远端
takeLookStore.sendUiSyncAction(UI_SYNC_EVENT.MY_NEW_EVENT, value)
}
步骤 3:在 replayer.ts 的 UiSyncReplayHandlers 中新增回调接口,并在 replayUiSyncAction 的 switch 中添加分支。
步骤 4:在 Store 层的 initUiSyncReplay 中注册处理函数:
registerUiSyncReplayHandlers({
// ... 现有处理函数
onMyNewEvent: (value) => {
useMyStore().applyRemoteAction(value)
},
})
十二、第九步:画笔标注功能(brush)
画笔标注允许双方在 VR 画面上实时绘制标注线条。
11.1 画笔架构
画笔模块封装在 services/takelook3/brush.ts 中,独立于 viewer-sync,使用 custom action 通道同步。
核心设计要点:
- 坐标使用百分比(相对于视口宽高),保证不同屏幕尺寸下兼容
- 本地笔画绿色 rgba(40, 144, 91, 1),远端笔画橙色 rgba(246, 88, 50, 1)
- 支持撤销(仅自己的笔画)和清空(双向广播)
11.2 画笔动作协议
| 动作 | 说明 |
|---|---|
brush/enter |
进入画笔模式(联动:一端进入 → 另一端自动进入) |
brush/exit |
退出画笔模式(联动:一端退出 → 另一端自动退出) |
brush/stroke |
发送笔画数据 |
brush/undo |
撤销一笔(通过 uuid 标识) |
brush/clear |
清空所有笔画(双向广播) |
11.3 画笔实例创建与绑定
// store 中
function initBrush(canvasEl: HTMLCanvasElement): void {
const currentUserId = pageParams.value.userId || 'local'
brushInstance = createBrush(canvasEl, currentUserId, {
onStrokeComplete: (stroke: BrushStroke) => {
// 笔画完成后发送到远端
void sendTakeLook3CustomAction({
action: BRUSH_ACTION.stroke,
payload: stroke,
})
},
onUndoStroke: (uuid: string) => {
// 撤销后通知远端
void sendTakeLook3CustomAction({
action: BRUSH_ACTION.undo,
payload: { uuid },
})
},
onCanUndoChanged: (canUndo: boolean) => {
brushCanUndo.value = canUndo
},
})
brushInstance.bind() // 绑定 canvas 事件
}
11.4 画笔模式切换联动
画笔模式的打开/关闭是双端联动的:
function toggleBrush(open: boolean, fromRemote = false): void {
if (brushOpen.value === open) return
brushOpen.value = open
if (!open) destroyBrush()
// 非远端触发时,广播到对方
if (!fromRemote && isTakeLook.value) {
void sendTakeLook3CustomAction({
action: open ? BRUSH_ACTION.enter : BRUSH_ACTION.exit,
})
}
}
11.5 笔画数据结构
export interface BrushStroke {
uuid: string // 唯一标识,用于撤销
userId: string // 绘制者 ID
strokeStyle: string // 颜色
lineWidth: number // 线宽
strokeData: Array<{ // 坐标点序列(百分比)
percentX: number
percentY: number
}>
}
十三、第十步:结束带看
12.1 结束方式
- 用户手动结束:点击控制条上的「结束」按钮。
- 对方退出:
onMemberExit检测到群内少于 2 人时自动结束。 - 连接超时:60 秒倒计时结束后自动终止。
12.2 结束流程
async function endTour(reason?: unknown): Promise<void> {
if (!isTakeLook.value && status.value !== 'starting') return
try {
status.value = 'ended'
isTakeLook.value = false
destroyViewerSync() // 销毁 VR 同步器
await endTakeLook3Session(reason) // 调用 SDK 结束会话
} finally {
destroyBrush() // 销毁画笔
stopConnectingCountdown() // 停止倒计时
stopConnectedDurationTimer() // 停止计时器
// 重置所有状态
connectedDuration.value = 0
brushOpen.value = false
personnelListVisible.value = false
sceneListVisible.value = false
viewerConnected.value = false
members.value = []
// 清除会话级标识,避免复用旧 roomId/meetingId
pageParams.value = {
...pageParams.value,
meetingId: '',
roomId: '',
groupId: '',
userSig: '',
}
}
}
注意:结束带看时会清空
meetingId、roomId、groupId、userSig,避免再次发起带看时复用旧的会话标识,导致 TIM 报无效 groupId 错误。
十四、UI 组件说明
本仓库提供了一套完整的移动端带看 UI 组件:
| 组件 | 文件路径 | 职责 |
|---|---|---|
MobileTakeLookBtn |
modules/mobile/takelook/MobileTakeLookBtn.vue |
带看入口按钮(useVrTakeSee=1 时展示) |
MobileTakeLookContainer |
modules/mobile/takelook/MobileTakeLookContainer.vue |
带看进行中的壳层容器,编排子组件 |
MobileTakeLookControlStrip |
modules/mobile/takelook/MobileTakeLookControlStrip.vue |
底部控制条:头像、计时、空间切换、画笔、结束按钮 |
MobileTakeLookPanel |
modules/mobile/takelook/MobileTakeLookPanel.vue |
场景列表面板,展示可切换的空间 |
MobileTakeLookBrush |
modules/mobile/takelook/MobileTakeLookBrush.vue |
画笔画布与工具栏(撤销、清空、退出) |
MobileTakeLookPersonnelList |
modules/mobile/takelook/MobileTakeLookPersonnelList.vue |
参与人员列表(静音、听筒/扬声器切换) |
MobileTakeLookConfirmDialog |
modules/mobile/takelook/MobileTakeLookConfirmDialog.vue |
确认弹窗(结束带看等操作前确认) |
MobileTakeLookCountdown |
modules/mobile/takelook/MobileTakeLookCountdown.vue |
连接中倒计时显示 |
MobileTakeLookEnterAnimation |
modules/mobile/takelook/MobileTakeLookEnterAnimation.vue |
进入带看的过渡动画 |
MobileTakeLookLock |
modules/mobile/takelook/MobileTakeLookLock.vue |
远端操控时的锁定遮罩(提示「对方正在操作」) |
MobileTakeLookOverlay |
modules/mobile/takelook/MobileTakeLookOverlay.vue |
远端操控中的闪现提示 |
MobileTakeLookStatePop |
modules/mobile/takelook/MobileTakeLookStatePop.vue |
连接状态 Toast 提示 |
14.1 Layout 层编排
在 MobileHouseLayout.vue 中,带看模式下会隐藏常规 UI(底部工具栏、讲房按钮、换装按钮等),只保留带看相关的组件:
<!-- 非带看模式才显示 -->
<template v-if="!takeLookStore.isTakeLook">
<MobileBottomBar />
<MobilePanoDressBtn />
<MobileIntroducePanel />
<NestSceneTourPanel />
</template>
<!-- 带看相关 UI(仅全景模式下显示面板与控制条) -->
<MobileTakeLookPanel v-if="isPanoramaMode" />
<MobileTakeLookOverlay />
<MobileTakeLookStatePop />
<MobileTakeLookContainer v-if="isPanoramaMode" />
十五、完整流程串联
以下梳理从页面打开到带看结束的完整时序:
1. 页面加载
├── HouseView.onMounted
│ ├── urlParams.initUrlParams()
│ ├── takeLook3Store.initPageParams() ← 解析 URL 参数
│ ├── houseInfoStore.initHouseInfo()
│ └── vrStore.initSdk() ← 初始化 VR
│
2. VR 加载完成 (init_UI)
├── takeLook3Store.initTakelookSDK(housePlay)
│ ├── initUiSyncReplay() ← 注册 UI 同步回放
│ ├── bindSdkHandlers() ← 注册 SDK 事件
│ ├── getOpenToken() ← 获取 token
│ ├── initTakeLook3Sdk(token, baseUrl) ← SDK 初始化
│ └── status → 'bound'
│
└── takeLook3Store.maybeAutoStart() ← 若 startTour=1 则自动启动
└── startTour()
├── buildTakeLook3StartPayload() ← 构建启动参数
├── startTakeLook3Session() ← 创建会议室 + SDK startTour
├── enableTakeLook3LocalSync() ← 开启本地同步
├── initViewerSync() ← 创建 VR 同步器
└── status → 'connected'
3. 带看进行中
├── onUserConnected ← TIM 连接成功
│ ├── 接听方: markViewerConnected()
│ └── fetchAndSyncGroupMembers() ← 拉取成员列表
├── onMemberJoin ← 新成员加入
│ ├── 发起方: markViewerConnected()
│ └── 发起方: broadcastHouseState() ← 广播当前视角
│
├── housePlay.uploadLocalActions ← 本地 VR 操作
│ └── viewer-sync → sendTakeLook3CoreAction ← 发送到远端
├── onRemoteControllingNotify ← 远端 VR 操作
│ └── viewer-sync → housePlay.syncRemoteActions
│
├── dispatchUiSyncEvent ← 本地 UI 操作
│ └── sendTakeLook3CustomAction ← 发送到远端
├── onRemoteCustomAction ← 远端 UI 操作
│ └── replayUiSyncAction ← 回放到本地
│
├── brush/stroke ← 画笔同步
└── brush/enter|exit ← 画笔模式联动
4. 结束带看
├── endTour()
│ ├── destroyViewerSync()
│ ├── endTakeLook3Session() ← SDK 退出
│ ├── destroyBrush()
│ └── 重置所有状态
└── status → 'ended'
十六、API 速查表
16.1 SDK 封装层(services/takelook3/sdk.ts)
| 函数 | 说明 |
|---|---|
registerTakeLook3SdkHandlers(handlers) |
注册 SDK 事件回调 |
initTakeLook3Sdk(token, baseUrl) |
初始化 SDK(setSDKParams + getConfig) |
fetchUserSig(userId, packageId, roomId?) |
获取 userSig |
startTakeLook3Session(options, hooks?) |
启动带看会话(callOut/createMeetingRoom + startTour) |
enableTakeLook3LocalSync(isCaller) |
启用本地操作同步 |
endTakeLook3Session(reason?) |
结束带看会话 |
sendTakeLook3CustomAction(action) |
发送自定义 UI 操作到远端 |
sendTakeLook3CoreAction(action) |
发送核心 VR 操作到远端 |
initAppRemoteSyncer() |
初始化 App 端同步器 |
voiceForbidden(userId, isForbidden) |
设置用户静音/取消静音 |
setAudioRoute(mode) |
设置声音通道(0=扬声器, 1=听筒) |
getGroupMemberList({groupID, count, offset}) |
获取群组成员列表 |
16.2 Store 层(stores/takelook3.ts)
| 方法/属性 | 说明 |
|---|---|
initPageParams() |
重新解析 URL 参数 |
initTakelookSDK(housePlay) |
初始化带看 SDK(VR 就绪后调用) |
startTour(manual?) |
启动带看会话 |
maybeAutoStart() |
若 startTour=1 则自动启动 |
endTour(reason?) |
结束带看 |
switchScene(scene, listIndex?) |
切换场景(本地 + 同步) |
sendUiSyncAction(eventName, value?) |
发送 UI 同步事件 |
toggleBrush(open, fromRemote?) |
切换画笔模式 |
initBrush(canvasEl) |
初始化画笔实例 |
brushUndo() |
撤销最后一笔 |
brushClear() |
清空所有笔画 |
togglePersonnelList(visible) |
切换人员列表 |
toggleSceneList(visible, fromRemote?) |
切换场景列表 |
toggleSelfMute() |
切换自身静音 |
toggleAudioRoute() |
切换扬声器/听筒 |
showConfirmDialog(title, content, onConfirm?) |
显示确认弹窗 |
showToast(type, message?) |
显示 Toast 提示 |
| 响应式状态 | 类型 | 说明 |
|---|---|---|
enabled |
boolean |
带看功能是否开启 |
isCaller |
boolean |
当前用户是否为发起者 |
isTakeLook |
boolean |
是否正在带看中 |
status |
TakeLook3Status |
当前状态(idle/bound/starting/connected/ended/error) |
viewerConnected |
boolean |
是否已与对方建立连接 |
isConnecting |
boolean |
是否正在连接中 |
connectedDurationText |
string |
已连接时长(mm:ss 格式) |
members |
TakeLook3Member[] |
当前群组成员列表 |
brushOpen |
boolean |
画笔模式是否打开 |
currentOperator |
ViewerSyncOperator |
当前操作者(free/local/remote) |
networkStatus |
object \| null |
网络状态(level/code/message) |
sceneList |
TakeLook3SceneItem[] |
可切换的场景列表 |
showTakeLookBtn |
boolean |
是否显示带看入口按钮 |
selfMuted |
boolean |
自身是否处于静音 |
十七、相关文件索引
| 文件路径 | 职责 |
|---|---|
src/services/takelook3/sdk.ts |
SDK 封装层,所有 SDK 调用的唯一入口 |
src/services/takelook3/params.ts |
URL 参数解析与 SDK 启动参数构建 |
src/services/takelook3/types.ts |
业务层类型定义 |
src/services/takelook3/viewer-sync.ts |
VR 视角同步器 |
src/services/takelook3/ui-sync/protocol.ts |
UI 同步协议常量 |
src/services/takelook3/ui-sync/dispatcher.ts |
UI 同步事件派发(本地→远端) |
src/services/takelook3/ui-sync/replayer.ts |
UI 同步事件回放(远端→本地) |
src/services/takelook3/brush.ts |
画笔标注模块 |
src/stores/takelook3.ts |
带看3 Pinia Store(业务编排) |
src/api/getTakelookToken.ts |
众趣开放平台 token 获取 |
src/modules/mobile/takelook/ |
移动端带看 UI 组件目录 |
src/layouts/mobile/MobileHouseLayout.vue |
移动端布局(带看模式下的 UI 编排) |
src/views/HouseView.vue |
主页面(初始化入口) |
十八、Demo 演示与测试模式
本仓库内置了测试模式,方便开发者在本地快速验证双端带看功能,无需依赖 App 端或第三方推送通道。
18.1 测试模式原理
开启测试模式后,发起者点击"带看"按钮时,会在正常带看流程的基础上叠加弹出一个二维码弹窗。二维码内容是经纪人(接听方)端的完整带看链接,开发者可以用另一台设备(手机/电脑浏览器)扫码或复制链接打开,模拟接听方加入带看,从而在一台电脑上完成双端联调。
测试模式不会影响带看的正常流程——连接倒计时、TIM 连接、VR 同步等都照常进行。二维码弹窗只是一个额外的辅助工具。
18.2 开启测试模式
在 URL 中添加参数 takeSeeTestMode=1 即可开启。该参数与其他带看参数一起使用。
发起者端完整 URL 示例:
http://localhost:8080/?m=你的模型ID&useVrTakeSee=1&userId=caller_01&callerUserId=caller_01&packageId=你的packageId&takeSeeTestMode=1
参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
m |
模型 ID | VR 模型标识,从众趣平台获取 |
useVrTakeSee |
1 |
开启带看功能(必填) |
userId |
如 caller_01 |
当前用户 ID |
callerUserId |
与 userId 相同 |
发起者 ID(userId === callerUserId 表示发起者) |
packageId |
套餐 ID | 从众趣平台获取,用于创建会议室 |
takeSeeTestMode |
1 |
开启测试模式(仅发起者端生效) |
注意:测试模式仅在发起者端(
userId === callerUserId)生效。接听方无需也不应添加此参数。
18.3 演示操作流程
以下是从零开始运行 Demo 并验证双端带看的完整步骤:
步骤 1:配置环境变量
在项目根目录创建 .env 文件,填入你的众趣开放平台凭证:
VITE_APP_ZQ_APPID='你的AppId'
VITE_APP_ZQ_SECRET='你的AppSecret'
VITE_APP_TAKELOOK3_API_BASE_URL='https://opendev.3dnest.cn'
步骤 2:启动开发服务器
npm install
npm run dev
服务默认运行在 http://localhost:8080。
步骤 3:打开发起者端页面
在浏览器中打开以下 URL(替换为你的实际参数):
http://localhost:8080/?m=你的模型ID&useVrTakeSee=1&userId=caller_01&callerUserId=caller_01&packageId=你的packageId&takeSeeTestMode=1
等待 VR 场景加载完成后,底部工具栏会出现"带看"按钮。
步骤 4:发起带看并获取二维码
点击底部工具栏的"带看"按钮。此时会同时发生两件事:
- 带看正常启动:进入连接等待状态,显示 60 秒连接倒计时
- 弹出二维码弹窗:显示经纪人端链接的二维码和完整 URL
弹窗提供两种方式获取接听方链接: - 扫码:用另一台设备扫描二维码直接打开 - 复制链接:点击"复制"按钮,在另一个浏览器窗口/标签页中粘贴打开
步骤 5:接听方加入带看
在另一台设备(或同一台电脑的另一个浏览器窗口)中打开接听方链接。接听方页面加载完成后会自动加入带看会话。
同一台电脑测试提示:如果用同一台电脑的两个浏览器标签页测试,建议使用不同的浏览器(如一个 Chrome、一个 Edge),或者将其中一个标签页在隐身模式下打开,以避免 TIM 登录态冲突。
步骤 6:验证双端同步
两端都成功连接后: - 在发起者端旋转/缩放 VR 场景,接听方应实时跟随 - 在接听方端操作 VR 场景,发起者端应实时跟随 - 尝试打开画笔功能,验证双端标注同步
18.4 测试模式涉及的代码
测试模式相关的代码变更集中在以下位置:
| 文件 | 说明 |
|---|---|
src/services/takelook3/sdk.ts |
startTakeLook3Session 返回经纪人端完整 URL(agentUrl) |
src/stores/takelook3.ts |
startTour 中检测 takeSeeTestMode=1,弹出二维码弹窗 |
src/modules/mobile/takelook/MobileTakeLookTestQr.vue |
二维码弹窗组件(生成二维码 + 复制链接) |
src/layouts/mobile/MobileHouseLayout.vue |
挂载二维码弹窗组件 |
Store 层关键逻辑(stores/takelook3.ts 中 startTour 方法):
const { agentUrl } = await startTakeLook3Session(payload.startOptions, { ... });
// 测试模式:叠加弹出经纪人链接二维码,带看流程正常继续
const isTestMode = new URLSearchParams(window.location.search).get('takeSeeTestMode') === '1'
if (isTestMode && isCaller.value) {
testQrAgentUrl.value = agentUrl
testQrOverlayVisible.value = true
}
// 以下为正常带看流程,不受测试模式影响
enableTakeLook3LocalSync(isCaller.value)
initViewerSync()
// ...
18.5 经纪人端链接格式说明
二维码中的链接由 sdk.ts 内 startTakeLook3Session 自动拼接,格式如下:
{origin}{pathname}?m={模型ID}&useVrTakeSee=1&meetingId={meetingId}&roomId={roomId}&userId=agent_01&callerUserId={callerUserId}&packageId={packageId}&imPlatform=app&house_id=1
| 参数 | 来源 | 说明 |
|---|---|---|
m |
当前页面 URL | 模型 ID |
useVrTakeSee |
固定值 1 |
开启带看 |
meetingId |
SDK 返回 | 由 callOut 或 createMeetingRoom 分配 |
roomId |
SDK 返回 | TIM 群组 ID |
userId |
固定值 agent_01 |
经纪人端用户 ID |
callerUserId |
发起者传入 | 发起者用户 ID |
packageId |
发起者传入 | 套餐/房源包 ID |
imPlatform |
固定值 app |
平台类型 |
该链接中
userId为固定值agent_01且与callerUserId不同,因此打开该链接的一方会被识别为接听方。
18.6 注意事项
- 仅用于开发测试:
takeSeeTestMode参数仅用于开发调试,生产环境不应使用。 - 关闭弹窗不影响带看:关闭二维码弹窗后,带看会话仍在进行中(倒计时仍在继续)。
- 超时自动结束:如果 60 秒内接听方未加入,带看会自动结束。需要重新点击"带看"按钮发起。
- 链接一次性有效:每次点击"带看"按钮会创建新的会议室(新的
meetingId和roomId),上次的链接不可复用。
十九、常见问题与注意事项
-
VR 必须先初始化完成 带看 SDK 的初始化依赖
housePlay实例。必须在 VR 的init_UI事件触发后才能调用initTakelookSDK。 -
Token 获取应在服务端完成 本仓库中
getOpenToken在前端直接计算签名仅为演示目的。生产环境应将签名计算放在服务端,避免APP_SECRET泄露。 -
会话标识需在结束后重置 每次结束带看后,
meetingId、roomId、groupId、userSig都会被清空。再次发起时由 SDK 重新分配,否则会因复用旧的 TIM 群组 ID 而报错。 -
UI 同步防消息环路 所有 UI 同步操作都需通过
dispatchUiSyncEvent发送,且远端回放时进入静默态,确保不会形成消息循环。新增同步事件时务必遵循此模式。 -
画笔坐标使用百分比 画笔的笔画坐标以视口百分比存储,保证不同分辨率的设备间兼容。组件初始化时需正确设置 canvas 尺寸为视口宽高。
-
操作权限互斥 viewer-sync 的操作权限状态机确保同一时刻只有一方在操作。远端操作中时本地操作会被忽略,远端操作停止 1.5 秒后自动恢复为自由状态。
-
成员列表兜底
onMemberJoin事件在某些场景下(如双方已在群内)可能不触发。因此在onUserConnected后会延迟 1 秒调用fetchAndSyncGroupMembers拉取完整成员列表进行兜底。 -
App 端额外初始化 当
imPlatform=app时,需额外调用initAppRemoteSyncer()初始化 App 客户端同步器,以适配 App 内嵌 WebView 的通信方式。
完成以上步骤后,即可实现完整的远程 VR 带看功能。更详细的 SDK API 说明请参考 带看3 SDK 对接指导文档。本仓库的完整实现可参考 src/stores/takelook3.ts(业务编排)、src/services/takelook3/(SDK 封装与同步模块)、以及 src/modules/mobile/takelook/(UI 组件)。