跳转至

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从创建会议室到结束带看的完整交互时序:

众趣科技带看3时序图

1.4 前置条件

  • VR 场景已初始化housePlay 实例可用(参见 VR 初始化展示指南)。
  • 众趣开放平台账号:需要 APP_IDAPP_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'

重要:业务层不应直接 import SDK,而是通过 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 启动方式

带看有两种启动方式:

  1. 自动启动:URL 参数 startTour=1,SDK 初始化完成后自动调用 maybeAutoStart()
  2. 手动启动:用户点击带看按钮触发 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.tsUiSyncReplayHandlers 中新增回调接口,并在 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: '',
    }
  }
}

注意:结束带看时会清空 meetingIdroomIdgroupIduserSig,避免再次发起带看时复用旧的会话标识,导致 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:发起带看并获取二维码

点击底部工具栏的"带看"按钮。此时会同时发生两件事:

  1. 带看正常启动:进入连接等待状态,显示 60 秒连接倒计时
  2. 弹出二维码弹窗:显示经纪人端链接的二维码和完整 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.tsstartTour 方法):

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.tsstartTakeLook3Session 自动拼接,格式如下:

{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 返回 callOutcreateMeetingRoom 分配
roomId SDK 返回 TIM 群组 ID
userId 固定值 agent_01 经纪人端用户 ID
callerUserId 发起者传入 发起者用户 ID
packageId 发起者传入 套餐/房源包 ID
imPlatform 固定值 app 平台类型

该链接中 userId 为固定值 agent_01 且与 callerUserId 不同,因此打开该链接的一方会被识别为接听方

18.6 注意事项

  1. 仅用于开发测试takeSeeTestMode 参数仅用于开发调试,生产环境不应使用。
  2. 关闭弹窗不影响带看:关闭二维码弹窗后,带看会话仍在进行中(倒计时仍在继续)。
  3. 超时自动结束:如果 60 秒内接听方未加入,带看会自动结束。需要重新点击"带看"按钮发起。
  4. 链接一次性有效:每次点击"带看"按钮会创建新的会议室(新的 meetingIdroomId),上次的链接不可复用。

十九、常见问题与注意事项

  1. VR 必须先初始化完成 带看 SDK 的初始化依赖 housePlay 实例。必须在 VR 的 init_UI 事件触发后才能调用 initTakelookSDK

  2. Token 获取应在服务端完成 本仓库中 getOpenToken 在前端直接计算签名仅为演示目的。生产环境应将签名计算放在服务端,避免 APP_SECRET 泄露。

  3. 会话标识需在结束后重置 每次结束带看后,meetingIdroomIdgroupIduserSig 都会被清空。再次发起时由 SDK 重新分配,否则会因复用旧的 TIM 群组 ID 而报错。

  4. UI 同步防消息环路 所有 UI 同步操作都需通过 dispatchUiSyncEvent 发送,且远端回放时进入静默态,确保不会形成消息循环。新增同步事件时务必遵循此模式。

  5. 画笔坐标使用百分比 画笔的笔画坐标以视口百分比存储,保证不同分辨率的设备间兼容。组件初始化时需正确设置 canvas 尺寸为视口宽高。

  6. 操作权限互斥 viewer-sync 的操作权限状态机确保同一时刻只有一方在操作。远端操作中时本地操作会被忽略,远端操作停止 1.5 秒后自动恢复为自由状态。

  7. 成员列表兜底 onMemberJoin 事件在某些场景下(如双方已在群内)可能不触发。因此在 onUserConnected 后会延迟 1 秒调用 fetchAndSyncGroupMembers 拉取完整成员列表进行兜底。

  8. 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 组件)。