index.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252
  1. <template>
  2. <view class="content" :class="screenMode">
  3. <view class="split-layout" v-if="screenMode === 'heng'">
  4. <!-- 左侧联系人列表 -->
  5. <view class="left-panel">
  6. <view class="header">
  7. <view class="header-left">
  8. <image class="avatar" :src="studentAvatar"></image>
  9. <text class="greeting">{{ studentName }},你好!</text>
  10. </view>
  11. <view class="logout-btn" @click="killBackgroundApp">
  12. <image src="/static/icon/logout.png"></image>
  13. </view>
  14. </view>
  15. <view class="contact-list">
  16. <view class="contact-card" v-for="contact in contacts" :key="contact.id"
  17. :class="{ 'active': currentContact?.id === contact.id }">
  18. <view class="contact-info">
  19. <image class="contact-avatar" :src="contact.avatar"></image>
  20. <text class="contact-name">{{ contact.username }}</text>
  21. </view>
  22. <view class="action-buttons">
  23. <view class="action-btn" @click.stop="startAudioCall(contact)">
  24. <view class="action-btn-icon">
  25. <image src="/static/icon/audio.png"></image>
  26. </view>
  27. <text>音频</text>
  28. </view>
  29. <view class="action-btn" @click.stop="startVideoCall(contact)">
  30. <view class="action-btn-icon">
  31. <image src="/static/icon/video.png"></image>
  32. </view>
  33. <text>视频</text>
  34. </view>
  35. <view class="action-btn" :class="{ 'message-point': contact.hasPoints }"
  36. @click="selectContact(contact)">
  37. <view class="action-btn-icon">
  38. <image src="/static/icon/message.png"></image>
  39. </view>
  40. <text>留言</text>
  41. </view>
  42. </view>
  43. </view>
  44. </view>
  45. <!-- 个人服务状态 -->
  46. <view class="service-status" v-if="serviceStatus">
  47. <view class="service-status-header">
  48. <text class="service-status-title">服务状态</text>
  49. </view>
  50. <view class="service-status-content">
  51. <view class="status-item" v-if="serviceStatus.sfmf === 1">
  52. <text class="status-label">免费服务</text>
  53. </view>
  54. <view class="status-item" v-if="serviceStatus.zdsc > 0">
  55. <text class="status-label">时长</text>
  56. <text class="status-value">{{ serviceStatus.ljsc || 0 }}/{{ serviceStatus.zdsc }}分钟</text>
  57. </view>
  58. <view class="status-item" v-if="serviceStatus.zdcs > 0">
  59. <text class="status-label">次数</text>
  60. <text class="status-value">{{ serviceStatus.ljcs || 0 }}/{{ serviceStatus.zdcs }}次</text>
  61. </view>
  62. <view class="status-item" v-if="serviceStatus.zdll > 0">
  63. <text class="status-label">流量</text>
  64. <text class="status-value">{{ serviceStatus.ljll || 0 }}/{{ serviceStatus.zdll }}MB</text>
  65. </view>
  66. </view>
  67. </view>
  68. </view>
  69. <!-- 右侧消息面板 -->
  70. <view class="right-panel">
  71. <message-page v-if="currentContact" :contact-id="currentContact.id" :contact-name="currentContact.username" role="device"></message-page>
  72. <!-- <view v-else class="empty-message">
  73. <text>请选择联系人开始聊天</text>
  74. </view> -->
  75. </view>
  76. </view>
  77. <view class="split-layout" v-else>
  78. <!-- 头部欢迎语 -->
  79. <view class="header">
  80. <view class="header-left">
  81. <image class="avatar" :src="studentAvatar"></image>
  82. <text class="greeting">{{ studentName }},你好!</text>
  83. </view>
  84. <view class="logout-btn" @click="killBackgroundApp">
  85. <image src="/static/icon/logout.png"></image>
  86. </view>
  87. </view>
  88. <!-- 调试信息 -->
  89. <!-- <view class="debug-info" @click="startCall">
  90. <text>Options1: {{ JSON.stringify(options) }}</text>
  91. </view> -->
  92. <!-- 联系人卡片列表 -->
  93. <view class="contact-list">
  94. <view class="contact-card" v-for="contact in contacts" :key="contact.id">
  95. <view class="contact-info">
  96. <image class="contact-avatar" :src="contact.avatar"></image>
  97. <text class="contact-name">{{ contact.username }}</text>
  98. </view>
  99. <view class="action-buttons">
  100. <view class="action-btn" @click="startAudioCall(contact)">
  101. <view class="action-btn-icon">
  102. <image src="/static/icon/audio.png"></image>
  103. </view>
  104. <text>音频</text>
  105. </view>
  106. <view class="action-btn" @click="startVideoCall(contact)">
  107. <view class="action-btn-icon">
  108. <image src="/static/icon/video.png"></image>
  109. </view>
  110. <text>视频</text>
  111. </view>
  112. <view class="action-btn" :class="{ 'message-point': contact.hasPoints }"
  113. @click="goToMessage(contact)">
  114. <view class="action-btn-icon">
  115. <image src="/static/icon/message.png"></image>
  116. </view>
  117. <text>留言</text>
  118. </view>
  119. </view>
  120. </view>
  121. </view>
  122. <!-- 个人服务状态 -->
  123. <view class="service-status" v-if="serviceStatus">
  124. <view class="service-status-header">
  125. <text class="service-status-title">服务状态</text>
  126. </view>
  127. <view class="service-status-content">
  128. <view class="status-item" v-if="serviceStatus.sfmf === 1">
  129. <text class="status-label">免费服务</text>
  130. </view>
  131. <view class="status-item" v-if="serviceStatus.zdsc > 0">
  132. <text class="status-label">时长</text>
  133. <text class="status-value">{{ serviceStatus.ljsc || 0 }}/{{ serviceStatus.zdsc }}分钟</text>
  134. </view>
  135. <view class="status-item" v-if="serviceStatus.zdcs > 0">
  136. <text class="status-label">次数</text>
  137. <text class="status-value">{{ serviceStatus.ljcs || 0 }}/{{ serviceStatus.zdcs }}次</text>
  138. </view>
  139. <view class="status-item" v-if="serviceStatus.zdll > 0">
  140. <text class="status-label">流量</text>
  141. <text class="status-value">{{ serviceStatus.ljll || 0 }}/{{ serviceStatus.zdll }}MB</text>
  142. </view>
  143. </view>
  144. </view>
  145. </view>
  146. <!-- 自定义Modal -->
  147. <custom-modal
  148. :visible="modalVisible"
  149. :title="modalTitle"
  150. :content="modalContent"
  151. :showCancel="modalShowCancel"
  152. :confirmText="modalConfirmText"
  153. @confirm="handleModalConfirm"
  154. @cancel="handleModalCancel"
  155. />
  156. </view>
  157. </template>
  158. <script>
  159. import messagePage from '../parent/message.vue'
  160. import customModal from '@/components/custom-modal.vue'
  161. import { deviceApi } from '@/api/device'
  162. import { getImageUrl } from '@/utils/util'
  163. import websocketService from '@/utils/websocket'
  164. const wmpfVoip = requirePlugin('wmpf-voip').default
  165. const isWmpf = (typeof wmpf !== 'undefined')
  166. // 指定接听方使用的小程序版本。formal/正式版(默认);trial/体验版;developer/开发版
  167. const miniprogramState = (() => {
  168. const accountInfo = wx.getAccountInfoSync();
  169. if (accountInfo && accountInfo.miniProgram) {
  170. const platform = { develop: 'developer', trial: 'trial', release: 'formal' }
  171. return platform[accountInfo.miniProgram.envVersion]
  172. }
  173. })()
  174. export default {
  175. components: {
  176. messagePage,
  177. customModal
  178. },
  179. data() {
  180. return {
  181. studentAvatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132',
  182. studentName: '默认学生',
  183. title: '家庭联系',
  184. screenMode: 'heng',
  185. currentContact: null,
  186. contacts: [],
  187. serviceStatus: null, // 个人服务状态
  188. _voipEndPathSet: false, // 记录是否已设置通话结束跳转页面
  189. studentId: '1',
  190. sn: 'A1000006B624470',
  191. inactivityTimer: null,
  192. inactivityTimeout: 180000, // 3分钟
  193. options: null,
  194. // 自定义Modal相关
  195. modalVisible: false,
  196. modalTitle: '提示',
  197. modalContent: '',
  198. modalShowCancel: false,
  199. modalConfirmText: '确定',
  200. modalResolve: null, // 用于Promise
  201. }
  202. },
  203. async onLoad(options) {
  204. uni.$on('device-message-refresh', this.handleDeviceMessageRefresh)
  205. // console.log('Device onLoad', options)
  206. this.options = options
  207. // 🔧 关闭可能存在的旧Modal
  208. this.modalVisible = false
  209. if (this.modalResolve) {
  210. this.modalResolve(false) // 取消旧的Promise
  211. this.modalResolve = null
  212. }
  213. uni.getSystemInfo({
  214. success: (res) => {
  215. this.screenMode = res.windowWidth > res.windowHeight ? 'heng' : 'shu'
  216. }
  217. })
  218. // 🎯 检查是否从通话结束返回
  219. if (options && options.fromCall === 'true') {
  220. console.log('📞 检测到通话结束返回,准备记录通话')
  221. await this.recordCallAndRefresh(options)
  222. return // 处理完通话记录后直接返回
  223. }
  224. // 🎯 检查是否已有登录信息(从通话结束页面返回的情况)
  225. const userInfo = uni.getStorageSync('userInfo')
  226. if (userInfo && userInfo.devId && (!options || !options.sn)) {
  227. // 已登录且没有传递新的 sn,说明是从其他页面返回
  228. console.log('✅ 检测到已登录,使用缓存数据')
  229. // 恢复学生信息
  230. if (userInfo.xm) {
  231. this.studentName = userInfo.xm
  232. }
  233. if (userInfo.yszwj) {
  234. this.studentAvatar = getImageUrl(userInfo.yszwj)
  235. }
  236. // 恢复 sn
  237. this.sn = userInfo.devId
  238. await this.ensureDeviceSocketConnected()
  239. // 获取联系人列表
  240. await this.getContactList()
  241. // 检查个人服务状态
  242. await this.checkServiceStatus(115)
  243. // 注册通话事件
  244. this.registerVoipEvent()
  245. this.setVoipEndPagePath()
  246. // 初始化无操作检测
  247. this.resetInactivityTimer()
  248. // 添加全局点击事件监听
  249. const pages = getCurrentPages();
  250. const currentPage = pages[pages.length - 1];
  251. if (currentPage.$getAppWebview) {
  252. const webview = currentPage.$getAppWebview();
  253. webview.addEventListener('click', () => {
  254. this.handleUserActivity();
  255. });
  256. }
  257. return // 直接返回,不需要重新登录
  258. }
  259. // 🆕 新登录流程:从 options 获取 sn 和 cardNo
  260. if (options && options.sn) {
  261. this.sn = options.sn
  262. }
  263. await this.ensureDeviceSocketConnected()
  264. const cardNo = options && options.cardNo ? options.cardNo : ''
  265. // 可选的 studentId 参数
  266. if (options && options.studentId) {
  267. this.studentId = options.studentId
  268. }
  269. // 第一步: 设备登录
  270. const loginSuccess = await this.deviceLogin(this.sn, cardNo)
  271. // 登录成功后才继续后续操作
  272. if (!loginSuccess) {
  273. return // 登录失败,直接返回
  274. }
  275. this.registerVoipEvent()
  276. // 初始化无操作检测
  277. this.resetInactivityTimer()
  278. // 添加全局点击事件监听
  279. const pages = getCurrentPages();
  280. const currentPage = pages[pages.length - 1];
  281. if (currentPage.$getAppWebview) {
  282. const webview = currentPage.$getAppWebview();
  283. webview.addEventListener('click', () => {
  284. this.handleUserActivity();
  285. });
  286. }
  287. },
  288. onUnload() {
  289. uni.$off('device-message-refresh', this.handleDeviceMessageRefresh)
  290. // 清理定时器
  291. if (this.inactivityTimer) {
  292. clearTimeout(this.inactivityTimer);
  293. }
  294. },
  295. async onShow() {
  296. await this.ensureDeviceSocketConnected()
  297. },
  298. methods: {
  299. async ensureDeviceSocketConnected() {
  300. const ssDevId = String(this.sn || '').trim()
  301. if (!ssDevId) return
  302. try {
  303. await websocketService.ensureConnected({
  304. role: 'device',
  305. ssDevId,
  306. heartbeat: true,
  307. autoReconnect: true
  308. })
  309. } catch (error) {
  310. console.error('index 建立设备 websocket 失败:', error)
  311. }
  312. },
  313. // 显示自定义Modal
  314. showCustomModal(options = {}) {
  315. return new Promise((resolve) => {
  316. this.modalTitle = options.title || '提示'
  317. this.modalContent = options.content || ''
  318. this.modalShowCancel = options.showCancel || false
  319. this.modalConfirmText = options.confirmText || '确定'
  320. this.modalResolve = resolve
  321. this.modalVisible = true
  322. })
  323. },
  324. // 处理Modal确认
  325. handleModalConfirm() {
  326. this.modalVisible = false
  327. if (this.modalResolve) {
  328. this.modalResolve(true)
  329. this.modalResolve = null
  330. }
  331. },
  332. // 处理Modal取消
  333. handleModalCancel() {
  334. this.modalVisible = false
  335. if (this.modalResolve) {
  336. this.modalResolve(false)
  337. this.modalResolve = null
  338. }
  339. },
  340. // 设备登录
  341. async deviceLogin(sn, cardNo) {
  342. try {
  343. const result = await deviceApi.login(sn, cardNo)
  344. if (result && result.data) {
  345. const loginData = result.data
  346. // 🚫 检查登录失败的情况(废卡/登录过期等)
  347. if (loginData.msg) {
  348. // 有 msg 字段说明登录失败
  349. // 弹出自定义确认框
  350. const confirmed = await this.showCustomModal({
  351. title: '登录失败',
  352. content: '登录失败,卡无登记',
  353. showCancel: false,
  354. confirmText: '确定'
  355. })
  356. // 用户确认后,调用退出
  357. if (confirmed) {
  358. await this.killBackgroundApp()
  359. }
  360. return false
  361. }
  362. // 🎯 参考 H5 login.html 的 userData 构建方式(304-314行)
  363. // 后端返回的数据没有 userInfo 层级,需要手动构建
  364. const userData = {
  365. devId: loginData.devId,
  366. sbmc: loginData.sbmc,
  367. sessId: loginData.sessId,
  368. xm: loginData.xm,
  369. yhsbToken: loginData.yhsbToken,
  370. onlineToken: loginData.onlineToken,
  371. syList: loginData.sylist || loginData.syList,
  372. yhid: loginData.yhid,
  373. yhm: loginData.yhm
  374. }
  375. // 可选字段:如果存在则添加
  376. if (loginData.yszwj) {
  377. userData.yszwj = loginData.yszwj
  378. }
  379. if (loginData.zjzwj) {
  380. userData.zjzwj = loginData.zjzwj
  381. }
  382. // 保存完整的用户信息
  383. uni.setStorageSync('userInfo', userData)
  384. // 保存 JSESSIONID
  385. if (userData.sessId) {
  386. uni.setStorageSync('JSESSIONID', userData.sessId)
  387. } else {
  388. console.warn('⚠️ 登录数据中没有 sessId')
  389. }
  390. // 更新页面显示的学生信息
  391. if (userData.xm) {
  392. this.studentName = userData.xm
  393. }
  394. if (userData.yszwj){
  395. this.studentAvatar = getImageUrl(userData.yszwj)
  396. }
  397. // 如果登录返回了学生ID,更新当前学生ID
  398. if (loginData.studentId) {
  399. this.studentId = loginData.studentId
  400. }
  401. // ✅ 登录成功,获取联系人列表
  402. await this.getContactList()
  403. // 检查个人服务状态
  404. await this.checkServiceStatus(115)
  405. console.log('✅ 设备登录成功:', userData.xm)
  406. return true
  407. } else {
  408. throw new Error('登录返回数据格式错误')
  409. }
  410. } catch (error) {
  411. console.error('❌ 设备登录失败:', error)
  412. // 弹出自定义确认框
  413. await this.showCustomModal({
  414. title: '登录失败',
  415. content: '网络错误或服务异常',
  416. showCancel: false,
  417. confirmText: '确定'
  418. })
  419. // 用户确认后,调用退出
  420. await this.killBackgroundApp()
  421. return false
  422. }
  423. },
  424. // 通用通话方法,供音频和视频通话共用
  425. async startCall(contact, roomType) {
  426. const contactOpenid = (contact && (contact.openid || contact.wbid2 || contact.wbid || '') || '').trim()
  427. if (!contactOpenid) {
  428. uni.showToast({
  429. title: '请让家长先关注公众号,登陆小程序后再发起',
  430. icon: 'none'
  431. })
  432. return false
  433. }
  434. // 显示loading弹窗
  435. uni.showLoading({
  436. title: '呼叫中...',
  437. mask: true // 添加遮罩,防止触摸穿透
  438. });
  439. // 记录当前联系人
  440. this.currentContact = contact;
  441. // 清除可能的上次通话记录,确保本次通话信息独立
  442. if (uni.getStorageSync('currcall')) {
  443. uni.removeStorageSync('currcall');
  444. }
  445. // 先存储基本联系人信息
  446. uni.setStorageSync('lastCallInfo', {
  447. name: contact.username || '未知联系人',
  448. avatar: contact.avatar || '/static/logo.png',
  449. duration: 0,
  450. time: new Date().toLocaleString(),
  451. type: roomType,
  452. });
  453. this.setVoipEndPagePath()
  454. try {
  455. const res = await wmpfVoip.initByCaller({
  456. roomType: roomType,
  457. caller: {
  458. id: this.sn,
  459. },
  460. listener: {
  461. id: contactOpenid,
  462. name: contact.username
  463. },
  464. businessType: 1,
  465. miniprogramState: miniprogramState
  466. })
  467. console.log("res:",res)
  468. if (res.isSuccess) {
  469. uni.hideLoading();
  470. uni.redirectTo({
  471. url: wmpfVoip.CALL_PAGE_PATH
  472. })
  473. return true
  474. } else {
  475. uni.hideLoading();
  476. console.error("呼叫失败,res:",res)
  477. uni.showToast({
  478. title: '呼叫失败1',
  479. icon: 'error'
  480. })
  481. return false
  482. }
  483. } catch (error) {
  484. uni.hideLoading();
  485. console.error('通话错误:', error)
  486. uni.showToast({
  487. title: '发起通话失败',
  488. icon: 'none'
  489. })
  490. return false
  491. }
  492. },
  493. // 设置通话结束跳转地址
  494. setVoipEndPagePath(forceUpdate) {
  495. if (this._voipEndPathSet && !forceUpdate) return;
  496. if (wmpfVoip) {
  497. const callInfo = uni.getStorageSync('lastCallInfo') || {};
  498. // 构建参数:如果需要记录通话,传递参数给首页
  499. let options = '';
  500. if (callInfo.needRecord && callInfo.duration > 0) {
  501. options = `fromCall=true&duration=${callInfo.duration}&callType=${callInfo.type || 'voice'}`;
  502. console.log('🔗 设置通话结束跳转参数:', options);
  503. }
  504. wmpfVoip.setVoipEndPagePath({
  505. url: '/pages/device/index', // 通话结束直接返回首页
  506. key: 'Call', // 设置业务类型为通话
  507. options: options, // 传递通话数据
  508. routeType: 'redirectTo' // 使用redirectTo方式跳转
  509. });
  510. this._voipEndPathSet = true; // 记录已设置状态
  511. }
  512. },
  513. registerVoipEvent() {
  514. wmpfVoip.onVoipEvent((event) => {
  515. const eventName = event.eventName;
  516. // 开始通话时清除无操作定时器
  517. if (eventName === 'startVoip') {
  518. clearTimeout(this.inactivityTimer);
  519. }
  520. // 定义不同类型的结束事件
  521. const hangupEvent = ['hangUpVoip', 'endVoip']; // 正常挂断
  522. const cancelEvent = ['cancelVoip']; // 主动取消
  523. const timeoutEvent = ['timeout']; // 超时未接听
  524. const rejectEvent = ['rejectVoip']; // 拒绝接听
  525. const callInfo = uni.getStorageSync('lastCallInfo') || {};
  526. // 处理正常通话结束
  527. if (hangupEvent.includes(eventName)) {
  528. callInfo.duration = event.data?.keepTime || 0;
  529. callInfo.status = callInfo.duration > 0 ? '通话已结束' : '未接通';
  530. // 如果有通话时长,标记需要记录到后端
  531. if (callInfo.duration > 0) {
  532. callInfo.needRecord = true;
  533. console.log('✅ 通话结束,时长:', callInfo.duration, '秒,类型:', callInfo.type);
  534. }
  535. }
  536. // 处理取消通话
  537. else if (cancelEvent.includes(eventName)) {
  538. callInfo.duration = 0;
  539. callInfo.status = '已取消';
  540. }
  541. // 处理超时未接听
  542. else if (timeoutEvent.includes(eventName)) {
  543. callInfo.duration = 0;
  544. callInfo.status = '未接听';
  545. }
  546. // 处理拒绝接听
  547. else if (rejectEvent.includes(eventName)) {
  548. callInfo.duration = 0;
  549. callInfo.status = '已拒绝';
  550. }
  551. // 如果是任何一种结束事件,保存信息并更新跳转页面
  552. if ([...hangupEvent, ...cancelEvent, ...timeoutEvent, ...rejectEvent].includes(eventName)) {
  553. callInfo.endType = eventName;
  554. callInfo.endTime = Date.now();
  555. uni.hideLoading();
  556. uni.setStorageSync('lastCallInfo', callInfo);
  557. this.setVoipEndPagePath(true);
  558. this.resetInactivityTimer();
  559. }
  560. })
  561. },
  562. async startAudioCall(contact) {
  563. await this.startCall(contact, 'voice')
  564. },
  565. async startVideoCall(contact) {
  566. await this.startCall(contact, 'video')
  567. },
  568. // 杀后台方法 by xu 2025-12-29
  569. async killBackgroundApp() {
  570. try {
  571. try {
  572. await deviceApi.ssExit()
  573. console.log('✅ 设备退出服务调用成功')
  574. } catch (exitError) {
  575. console.warn('⚠️ 设备退出服务调用失败:', exitError)
  576. }
  577. // 🧹 清理登录信息
  578. uni.removeStorageSync('userInfo')
  579. uni.removeStorageSync('JSESSIONID')
  580. // 🧹 清理通话相关缓存
  581. uni.removeStorageSync('currcall')
  582. uni.removeStorageSync('lastCallInfo')
  583. // 提示退出成功 by xu 2025-12-29
  584. uni.showToast({
  585. title: '已退出',
  586. icon: 'success',
  587. duration: 1500
  588. });
  589. // 延迟跳转到通知页 by xu 2025-12-29
  590. setTimeout(() => {
  591. uni.redirectTo({
  592. url: '/pages/device/notice'
  593. });
  594. }, 1500);
  595. } catch (error) {
  596. console.error('❌ 退出失败:', error);
  597. uni.showToast({
  598. title: '退出失败',
  599. icon: 'error'
  600. });
  601. }
  602. },
  603. // 重置无操作计时器
  604. resetInactivityTimer() {
  605. if (this.inactivityTimer) {
  606. clearTimeout(this.inactivityTimer);
  607. }
  608. this.inactivityTimer = setTimeout(() => {
  609. this.killBackgroundApp();
  610. }, this.inactivityTimeout);
  611. },
  612. // 监听用户活动的方法
  613. handleUserActivity() {
  614. this.resetInactivityTimer();
  615. },
  616. selectContact(contact) {
  617. this.currentContact = contact
  618. const idx = this.contacts.findIndex((item) => item.id === contact.id)
  619. if (idx > -1) {
  620. this.$set(this.contacts[idx], 'hasPoints', false)
  621. }
  622. },
  623. handleDeviceMessageRefresh() {
  624. if (!this.contacts || !this.contacts.length) return
  625. this.contacts = this.contacts.map((item) => ({
  626. ...item,
  627. hasPoints: true
  628. }))
  629. },
  630. async getContactList() {
  631. try {
  632. const result = await deviceApi.mp_telHomep_load()
  633. const data = result && result.data ? result.data : {}
  634. const chatMbrList = Array.isArray(data.chatMbrList) ? data.chatMbrList : []
  635. const flatChatMbrList = chatMbrList.flatMap((item) => (Array.isArray(item) ? item : [item])).filter(Boolean)
  636. if (flatChatMbrList.length) {
  637. const defaultAvatar = 'https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132'
  638. this.contacts = flatChatMbrList.map((item) => ({
  639. id: item.ryid,
  640. username: item.xm || '未命名联系人',
  641. avatar: item.yszwj ? getImageUrl(item.yszwj) : defaultAvatar,
  642. openid: item.wbid2 || item.wbid || '',
  643. hasPoints: false
  644. }))
  645. console.log('✅ 获取留言联系人成功:', flatChatMbrList)
  646. return
  647. }
  648. // 兼容旧返回(后端未切换时)
  649. if (data && data.ssData) {
  650. const parent = data.ssData
  651. const defaultAvatar = 'https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132'
  652. const avatar = parent.yszwj ? getImageUrl(parent.yszwj) : defaultAvatar
  653. this.contacts = [{
  654. id: parent.ryid,
  655. username: parent.xm,
  656. avatar,
  657. openid: parent.wbid,
  658. hasPoints: false
  659. }]
  660. console.log('✅ 获取联系人成功(兼容旧返回):', parent)
  661. }
  662. } catch (error) {
  663. console.error('❌ 获取联系人失败:', error)
  664. }
  665. },
  666. goToMessage(contact) {
  667. const contactId = contact && contact.id ? contact.id : ''
  668. const contactName = contact && contact.username ? contact.username : ''
  669. uni.navigateTo({
  670. url: '/pages/parent/message?contactId=' + encodeURIComponent(contactId) + "&contactName=" + encodeURIComponent(contactName) + "&role=device&fromIndex=1"
  671. })
  672. },
  673. // 检查个人服务状态
  674. async checkServiceStatus(grfwxmm = 115) {
  675. try {
  676. const result = await deviceApi.grfw_chkGrfw(grfwxmm)
  677. console.log('检查服务状态返回:', result)
  678. if (result && result.data) {
  679. const data = result.data
  680. if (data.ssCode === 0 && data.ssData) {
  681. // 成功获取服务状态
  682. this.serviceStatus = data.ssData
  683. console.log('✅ 服务状态:', this.serviceStatus)
  684. } else if (data.ssCode > 0) {
  685. // 服务异常
  686. console.warn('⚠️ 服务状态异常:', data.ssMsg)
  687. this.serviceStatus = null
  688. }
  689. }
  690. } catch (error) {
  691. console.error('❌ 检查服务状态失败:', error)
  692. this.serviceStatus = null
  693. }
  694. },
  695. // 记录通话并刷新服务状态
  696. async recordCallAndRefresh(callOptions) {
  697. try {
  698. const duration = parseInt(callOptions.duration) || 0 // 秒
  699. const callType = callOptions.callType || 'voice'
  700. if (duration <= 0) {
  701. console.log('⚠️ 通话时长为0,不记录')
  702. await this.loadPageAfterCall()
  703. return
  704. }
  705. // 转换为分钟,不满1分钟按1分钟算(向上取整)
  706. const minutes = Math.ceil(duration / 60)
  707. console.log(`📊 通话时长: ${duration}秒 → ${minutes}分钟(向上取整)`)
  708. // 根据通话类型确定服务项目码
  709. const grfwxmm = callType === 'video' ? 115 : 111 // 115=视频电话, 111=音频电话
  710. // 调用接口记录通话
  711. console.log(`📝 记录通话: grfwxmm=${grfwxmm}, 时长=${minutes}分钟`)
  712. await deviceApi.grfw_endGrfw({
  713. grfwxmm: grfwxmm,
  714. sc: minutes, // 时长(分钟)
  715. ll: 0, // 流量
  716. ms: `通话${duration}秒` // 备注
  717. })
  718. console.log('✅ 通话记录成功')
  719. // 重新加载服务状态
  720. await this.checkServiceStatus(grfwxmm)
  721. // 清理通话记录标记
  722. const callInfo = uni.getStorageSync('lastCallInfo') || {}
  723. delete callInfo.needRecord
  724. uni.setStorageSync('lastCallInfo', callInfo)
  725. } catch (error) {
  726. console.error('❌ 记录通话失败:', error)
  727. uni.showToast({
  728. title: '记录通话失败',
  729. icon: 'none'
  730. })
  731. } finally {
  732. await this.loadPageAfterCall()
  733. }
  734. },
  735. // 通话结束后加载页面内容
  736. async loadPageAfterCall() {
  737. const userInfo = uni.getStorageSync('userInfo')
  738. if (userInfo) {
  739. if (userInfo.xm) {
  740. this.studentName = userInfo.xm
  741. }
  742. if (userInfo.yszwj) {
  743. this.studentAvatar = getImageUrl(userInfo.yszwj)
  744. }
  745. this.sn = userInfo.devId
  746. }
  747. await this.getContactList()
  748. this.registerVoipEvent()
  749. this.setVoipEndPagePath()
  750. this.resetInactivityTimer()
  751. const pages = getCurrentPages();
  752. const currentPage = pages[pages.length - 1];
  753. if (currentPage.$getAppWebview) {
  754. const webview = currentPage.$getAppWebview();
  755. webview.addEventListener('click', () => {
  756. this.handleUserActivity();
  757. });
  758. }
  759. },
  760. }
  761. }
  762. </script>
  763. <style scoped>
  764. .heng .split-layout {
  765. display: flex;
  766. height: 100vh;
  767. }
  768. .heng .left-panel {
  769. width: 50%;
  770. border-right: 2rpx solid #dcdcdc;
  771. background: #f5f5f5;
  772. overflow-y: auto;
  773. }
  774. .heng .right-panel {
  775. flex: 1;
  776. background: #fff;
  777. height: 100%;
  778. overflow-y: auto;
  779. }
  780. .heng .empty-message {
  781. height: 100%;
  782. display: flex;
  783. align-items: center;
  784. justify-content: center;
  785. color: #999;
  786. font-size: 32rpx;
  787. }
  788. .heng .header {
  789. display: flex;
  790. align-items: center;
  791. padding: 16rpx 20rpx 10rpx;
  792. /* height: 200rpx; */
  793. border-bottom: 1rpx solid #d2d2d2;
  794. background: #f5f5f5;
  795. box-sizing: border-box;
  796. justify-content: space-between;
  797. }
  798. .heng .avatar {
  799. width: 42rpx;
  800. height: 42rpx;
  801. border-radius: 50%;
  802. margin-right: 8rpx;
  803. }
  804. .heng .header-left {
  805. display: flex;
  806. align-items: center;
  807. }
  808. .heng .logout-btn image {
  809. width: 20rpx;
  810. height: 20rpx;
  811. }
  812. .heng .greeting {
  813. font-size: 14rpx;
  814. color: #333;
  815. }
  816. .heng .contact-list {
  817. margin-top: 20rpx;
  818. display: flex;
  819. flex-direction: column;
  820. justify-content: center;
  821. gap: 15rpx;
  822. padding: 0 12rpx;
  823. }
  824. .heng .contact-card {
  825. width: calc(100% - 20rpx);
  826. /* margin: 16rpx 20rpx; */
  827. padding: 8rpx;
  828. background-color: #fff;
  829. border-radius: 3rpx;
  830. display: flex;
  831. align-items: center;
  832. background: #f5f5f5;
  833. border: 1px solid #dddfe6;
  834. justify-content: space-between;
  835. position: relative;
  836. /* 添加相对定位 */
  837. }
  838. .heng .contact-card.active::after {
  839. content: '';
  840. position: absolute;
  841. right: -13rpx;
  842. top: 50%;
  843. transform: translateY(-50%);
  844. width: 0;
  845. height: 0;
  846. border-top: 8rpx solid transparent;
  847. border-bottom: 8rpx solid transparent;
  848. border-left: 8rpx solid #d2d2d2;
  849. }
  850. .heng .contact-info {
  851. display: flex;
  852. align-items: center;
  853. }
  854. .heng .contact-avatar {
  855. width: 52rpx;
  856. height: 52rpx;
  857. border: 1px solid #fff;
  858. margin-right: 8rpx;
  859. border-radius: 0rpx;
  860. }
  861. .heng .contact-name {
  862. font-size: 16rpx;
  863. color: #333;
  864. }
  865. .heng .action-buttons {
  866. display: flex;
  867. justify-content: space-around;
  868. gap: 12rpx;
  869. margin-right: 8rpx;
  870. }
  871. .heng .action-btn {
  872. display: flex;
  873. flex-direction: column;
  874. align-items: center;
  875. }
  876. .heng .action-btn-icon {
  877. width: 32rpx;
  878. height: 32rpx;
  879. margin-bottom: 2rpx;
  880. display: flex;
  881. align-items: center;
  882. justify-content: center;
  883. background: #ffffff;
  884. border: 1px solid #dddfe6;
  885. box-sizing: border-box;
  886. border-radius: 3rpx;
  887. }
  888. .heng .action-btn image {
  889. width: 60%;
  890. height: 60%;
  891. }
  892. .heng .action-btn text {
  893. font-size: 10rpx;
  894. color: #000000;
  895. }
  896. .heng .message-point {
  897. position: relative;
  898. }
  899. .heng .message-point::after {
  900. content: '';
  901. position: absolute;
  902. width: 8rpx;
  903. height: 8rpx;
  904. background-color: #eb6100;
  905. border-radius: 50%;
  906. top: -4rpx;
  907. right: -4rpx;
  908. }
  909. .shu .header {
  910. display: flex;
  911. align-items: center;
  912. padding: 40rpx 50rpx 25rpx;
  913. height: 200rpx;
  914. border-bottom: 2rpx solid #d2d2d2;
  915. background: #f5f5f5;
  916. box-sizing: border-box;
  917. justify-content: space-between;
  918. }
  919. .shu .avatar {
  920. width: 108rpx;
  921. height: 108rpx;
  922. border-radius: 50%;
  923. margin-right: 20rpx;
  924. }
  925. .shu .header-left {
  926. display: flex;
  927. align-items: center;
  928. }
  929. .shu .logout-btn image {
  930. width: 40rpx;
  931. height: 40rpx;
  932. }
  933. .shu .greeting {
  934. font-size: 38rpx;
  935. color: #333;
  936. }
  937. .shu .contact-list {
  938. margin-top: 30rpx;
  939. }
  940. .shu .contact-card {
  941. margin: 40rpx 50rpx;
  942. padding: 20rpx;
  943. background-color: #fff;
  944. border-radius: 8rpx;
  945. display: flex;
  946. align-items: center;
  947. background: #f5f5f5;
  948. border: 1px solid #dddfe6;
  949. justify-content: space-between;
  950. }
  951. .shu .contact-info {
  952. display: flex;
  953. align-items: center;
  954. }
  955. .shu .contact-avatar {
  956. width: 128rpx;
  957. height: 128rpx;
  958. border: 1px solid #fff;
  959. margin-right: 20rpx;
  960. border-radius: 0rpx;
  961. }
  962. .shu .contact-name {
  963. font-size: 32rpx;
  964. color: #333;
  965. }
  966. .shu .action-buttons {
  967. display: flex;
  968. justify-content: space-around;
  969. gap: 30rpx;
  970. /* margin-right: 20rpx; */
  971. }
  972. .shu .action-btn {
  973. display: flex;
  974. flex-direction: column;
  975. align-items: center;
  976. }
  977. .shu .action-btn-icon {
  978. width: 80rpx;
  979. height: 80rpx;
  980. margin-bottom: 5rpx;
  981. display: flex;
  982. align-items: center;
  983. justify-content: center;
  984. background: #ffffff;
  985. border: 1px solid #dddfe6;
  986. box-sizing: border-box;
  987. border-radius: 10rpx;
  988. }
  989. .shu .action-btn image {
  990. width: 60%;
  991. height: 60%;
  992. }
  993. .shu .action-btn text {
  994. font-size: 24rpx;
  995. color: #000000;
  996. }
  997. .shu .message-point {
  998. position: relative;
  999. }
  1000. .shu .message-point::after {
  1001. content: '';
  1002. position: absolute;
  1003. width: 18rpx;
  1004. height: 18rpx;
  1005. background-color: #eb6100;
  1006. border-radius: 50%;
  1007. top: -9rpx;
  1008. right: -9rpx;
  1009. }
  1010. .debug-info {
  1011. padding: 20rpx;
  1012. background: #fff3cd;
  1013. margin: 20rpx;
  1014. border-radius: 8rpx;
  1015. word-break: break-all;
  1016. }
  1017. .debug-info text {
  1018. font-size: 24rpx;
  1019. color: #856404;
  1020. }
  1021. /* 横屏服务状态样式 */
  1022. .heng .service-status {
  1023. margin: 12rpx;
  1024. padding: 10rpx 12rpx;
  1025. background: #fff;
  1026. border-radius: 6rpx;
  1027. border: 1px solid #dddfe6;
  1028. }
  1029. .heng .service-status-header {
  1030. margin-bottom: 8rpx;
  1031. }
  1032. .heng .service-status-title {
  1033. font-size: 12rpx;
  1034. color: #666;
  1035. font-weight: bold;
  1036. }
  1037. .heng .service-status-content {
  1038. display: flex;
  1039. flex-wrap: wrap;
  1040. gap: 8rpx;
  1041. }
  1042. .heng .status-item {
  1043. display: flex;
  1044. align-items: center;
  1045. gap: 4rpx;
  1046. padding: 4rpx 8rpx;
  1047. background: #f0f9ff;
  1048. border-radius: 4rpx;
  1049. }
  1050. .heng .status-label {
  1051. font-size: 10rpx;
  1052. color: #666;
  1053. }
  1054. .heng .status-value {
  1055. font-size: 10rpx;
  1056. color: #07c160;
  1057. font-weight: bold;
  1058. }
  1059. /* 竖屏服务状态样式 */
  1060. .shu .service-status {
  1061. margin: 30rpx 50rpx;
  1062. padding: 24rpx;
  1063. background: #fff;
  1064. border-radius: 12rpx;
  1065. border: 1px solid #dddfe6;
  1066. }
  1067. .shu .service-status-header {
  1068. margin-bottom: 16rpx;
  1069. }
  1070. .shu .service-status-title {
  1071. font-size: 28rpx;
  1072. color: #666;
  1073. font-weight: bold;
  1074. }
  1075. .shu .service-status-content {
  1076. display: flex;
  1077. flex-wrap: wrap;
  1078. gap: 20rpx;
  1079. }
  1080. .shu .status-item {
  1081. display: flex;
  1082. align-items: center;
  1083. gap: 10rpx;
  1084. padding: 12rpx 20rpx;
  1085. background: #f0f9ff;
  1086. border-radius: 8rpx;
  1087. }
  1088. .shu .status-label {
  1089. font-size: 26rpx;
  1090. color: #666;
  1091. }
  1092. .shu .status-value {
  1093. font-size: 26rpx;
  1094. color: #07c160;
  1095. font-weight: bold;
  1096. }
  1097. </style>