| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824 |
- <template>
- <view class="message-page">
- <view class="chat-nav">
- <view class="chat-title-wrap">
- <picker
- v-if="parentChatMembers.length > 1"
- mode="selector"
- :range="parentChatMemberNames"
- :value="parentChatMemberIndex"
- @change="handleParentMemberChange"
- >
- <view class="chat-title-picker">
- <Icon lib="base" name="icon-down" size="36" color="#000000" />
- <text class="chat-title">{{ currentChatName }}</text>
- </view>
- </picker>
- <view v-else class="chat-title-picker single">
- <text class="chat-title">{{ currentChatName }}</text>
- </view>
- </view>
- <view class="chat-nav-right">
- <view class="chat-call-btn" @click="startVoiceCall">
- <Icon lib="base" name="icon-aud-bold" size="36" color="#3c4151" />
- </view>
- <view class="chat-call-btn" @click="startVideoCall">
- <Icon lib="base" name="icon-vid-bold" size="36" color="#3c4151" />
- </view>
- <view v-if="sessionRole === 'device'" class="chat-call-btn" @click="handleDeviceLogout">
- <image class="chat-logout-icon" src="/static/icon/logout.png"></image>
- </view>
- </view>
- </view>
- <!-- 消息列表 -->
- <scroll-view class="message-list" scroll-y :scroll-top="scrollTop" :scroll-into-view="scrollIntoView"
- @touchstart="handleListTouchStart" @touchmove="handleListTouchMove" @touchend="handleListTouchEnd">
- <view class="message-item" :class="message.direction" v-for="(message, index) in messages" :key="index"
- :id="'msg-' + index">
- <!-- 头像+时间区域 -->
- <view class="avatar-section">
- <image
- class="avatar"
- :class="getAvatarClass(message)"
- :src="getAvatarSrc(message)"
- mode="aspectFill"
- ></image>
- <text class="msg-time">{{ message.time || '--:--' }}</text>
- </view>
- <!-- 内容区域 -->
- <view class="content-section">
- <!-- 职务 | 姓名(职务先隐藏) -->
- <view class="user-info" :class="{ 'user-info-placeholder': message.direction === 'right' }">
- <text class="user-info-role">{{ message.department }} | </text>
- <text>{{ message.direction === 'left' ? (message.displayName || message.name) : '占位' }}</text>
- </view>
- <!-- 消息内容 -->
- <view class="message-content">
- <!-- 文字消息 -->
- <view v-if="message.type === 'text'" class="text-message-wrapper">
- <!-- 接收的消息 -->
- <template v-if="message.direction === 'left'">
- <view class="text-message">
- <text>{{ message.content }}</text>
- </view>
- </template>
- <!-- 发送的消息 -->
- <template v-else>
- <view class="text-message">
- <text>{{ message.content }}</text>
- </view>
- </template>
- </view>
- <!-- 通话记录 -->
- <view v-if="message.type === 'call'" class="call-message">
- <image src="/static/icon/tingtong.png"></image>
- <text>通话时长 {{ message.duration }}</text>
- </view>
- <!-- 语音消息 -->
- <view
- v-if="message.type === 'voice'"
- class="voice-message"
- :style="getVoiceBubbleStyle(message)"
- @click.stop="toggleVoicePlayback(message, index)"
- >
- <image
- :src="message.direction === 'left' ? '/static/icon/guangbo.png' : '/static/icon/yinbo.png'">
- </image>
- <text>{{ formatVoiceBubbleText(message) }}</text>
- <view v-if="message.isRecording" class="recording-dot"></view>
- </view>
- <!-- 图片消息 -->
- <view v-if="message.type === 'image'" class="image-message" @click.stop="openImagePreview(message)">
- <image class="image-preview" :src="message.imageUrl || '/static/logo.png'" mode="aspectFill"></image>
- </view>
- <!-- 文件消息 -->
- <view v-if="message.type === 'file'" class="file-message" @click.stop="openFilePreview(message)">
- <Icon lib="base" name="icon-file" size="39" :color="iconColor" />
- <text class="file-name">{{ message.fileName }}</text>
- </view>
- <!-- 视频消息 -->
- <view v-if="message.type === 'video'" class="video-message" @click.stop="openVideoPreview(message, index)">
- <video
- class="video-cover"
- :src="message.videoUrl"
- muted
- :controls="false"
- :show-center-play-btn="false"
- :enable-progress-gesture="false"
- object-fit="cover"
- ></video>
- <view class="video-play">
- <Icon lib="base" name="icon-vid" size="40" color="#ffffff" />
- </view>
- </view>
- <view
- v-if="message.direction === 'left' && message.needReceipt && message.receiptStatus !== 'read'"
- class="receipt-button"
- @click.stop="confirmRead(message, index)"
- >
- 确认阅读
- </view>
- </view>
- <!-- 语音转文字区域(仅语音消息且有转文字内容时显示) -->
- <view v-if="message.type === 'voice' && message.voiceText" class="voice-text-section">
- <!-- 接收的消息 -->
- <template v-if="message.direction === 'left'">
- <view class="voice-text-content">{{ message.voiceText }}</view>
- </template>
- <!-- 发送的消息 -->
- <template v-else>
- <view class="voice-text-content">{{ message.voiceText }}</view>
- </template>
- </view>
- </view>
- </view>
- </scroll-view>
- <!-- 底部操作栏 -->
- <view class="footer" :class="{ active: showMorePanel || showEmojiPanel }" @click.stop>
- <!-- 左侧:语音/键盘切换 -->
- <view class="tool-btn" @click="toggleInputMode">
- <Icon lib="base" :name="inputMode === 'voice' ? 'icon-txt' : 'icon-spk'" size="36" :color="iconColor" />
- </view>
- <!-- 中间:输入框 / 按住说话 -->
- <view class="center-area">
- <view
- v-if="inputMode === 'voice'"
- class="press-to-talk"
- @longpress="handlePressToTalkStart"
- @touchmove.stop.prevent="handlePressToTalkMove"
- @touchend.stop.prevent="handlePressToTalkEnd"
- @touchcancel.stop.prevent="handlePressToTalkCancel"
- >
- <Icon lib="base" name="icon-aud" size="36" :color="iconColor" />
- <text class="press-text">长按说话</text>
- </view>
- <textarea
- v-else
- class="text-input"
- :class="{ capped: textLineCount >= 5 }"
- v-model="draftText"
- :focus="inputFocus"
- auto-height
- confirm-type="send"
- @confirm="sendTextMessage"
- maxlength="-1"
- @linechange="handleTextLineChange"
- @blur="inputFocus = false"
- />
- </view>
- <!-- 右侧:表情 + 更多 -->
- <view class="tool-btn" @click="toggleEmojiPanel">
- <Icon lib="base" name="icon-emoji" size="36" :color="iconColor" />
- </view>
- <view class="tool-btn" @click="toggleMorePanel">
- <Icon lib="base" name="icon-add" size="36" :color="iconColor" />
- </view>
- </view>
- <!-- 底部扩展面板(更多/表情) -->
- <view class="bottom-panel" :class="{ open: showMorePanel || showEmojiPanel }" @click.stop>
- <view v-show="showMorePanel" class="more-panel">
- <view class="more-grid">
- <view class="more-item" @click="handleMoreAction('file')">
- <view class="more-icon">
- <Icon lib="base" name="icon-file" size="36" :color="iconColor" />
- </view>
- <text class="more-text">文件</text>
- </view>
- <view class="more-item" @click="handleMoreAction('image')">
- <view class="more-icon">
- <Icon lib="base" name="icon-img" size="36" :color="iconColor" />
- </view>
- <text class="more-text">照片</text>
- </view>
- <view class="more-item" @click="handleMoreAction('camera')">
- <view class="more-icon">
- <Icon lib="base" name="icon-photo" size="36" :color="iconColor" />
- </view>
- <text class="more-text">拍照</text>
- </view>
- <view class="more-item" @click="handleMoreAction('video')">
- <view class="more-icon">
- <Icon lib="base" name="icon-vid" size="36" :color="iconColor" />
- </view>
- <text class="more-text">视频留言</text>
- </view>
- </view>
- </view>
- <view v-show="showEmojiPanel" class="emoji-panel">
- <view class="emoji-grid">
- <view class="emoji-item" v-for="(emoji, index) in emojiList" :key="index"
- @click="insertEmoji(emoji)">
- {{ emoji }}
- </view>
- </view>
- </view>
- </view>
- <view v-if="showMediaPreview" class="media-preview-mask" @click="closeMediaPreview">
- <view class="media-preview-content" :class="previewType === 'image' ? 'image-mode' : 'video-mode'" @click.stop="handlePreviewContentClick">
- <swiper
- v-if="previewType === 'image'"
- class="media-preview-swiper"
- :current="previewImageIndex"
- @change="handleImageSwiperChange"
- >
- <swiper-item class="media-preview-swiper-item" v-for="(img, idx) in previewImageList" :key="idx">
- <image class="media-preview-image fit-x" :src="img" mode="widthFix"></image>
- </swiper-item>
- </swiper>
- <video
- v-if="previewType === 'video'"
- class="media-preview-video"
- :src="previewSource"
- autoplay
- controls
- object-fit="contain"
- @loadedmetadata="handlePreviewVideoLoaded"
- @error="handlePreviewVideoError"
- ></video>
- </view>
- <view v-if="previewType === 'video'" class="media-preview-exit" @click.stop="closeMediaPreview">退出</view>
- </view>
- <view v-if="showFileDialog" class="file-dialog-mask" @click="closeFileDialog">
- <view class="file-dialog" @click.stop>
- <view class="file-dialog-header">
- <Icon lib="base" name="icon-file" size="44" :color="iconColor" />
- <text class="file-dialog-name">{{ activeFileName }}</text>
- </view>
- <view class="file-dialog-actions">
- <view class="file-dialog-btn ghost" @click="closeFileDialog">取消</view>
- <view class="file-dialog-btn" @click="downloadFileFromDialog">下载</view>
- </view>
- </view>
- </view>
- <view v-if="isRecording" class="record-mask">
- <view class="record-panel">
- <view class="record-title">正在录音</view>
- <view class="record-time">{{ recordSeconds }}s</view>
- <view class="record-tip" :class="{ danger: isRecordCancel }">
- {{ isRecordCancel ? '松开取消发送' : '手指上滑,取消发送' }}
- </view>
- </view>
- </view>
- <custom-modal
- :visible="modalVisible"
- :title="modalTitle"
- :content="modalContent"
- :showCancel="modalShowCancel"
- :confirmText="modalConfirmText"
- @confirm="handleModalConfirm"
- @cancel="handleModalCancel"
- />
- </view>
- </template>
- <script>
- import Icon from '@/components/icon/index.vue'
- import customModal from '@/components/custom-modal.vue'
- import { EMOJI_LIST } from '@/constants/emoji'
- import { collectImageUrls, createImageMessage, createTextMessage } from '@/utils/parent-message-factory'
- import {
- buildTextOutgoingMessage,
- buildVoiceOutgoingMessage,
- pickFileOutgoingMessage,
- pickImageOutgoingMessages,
- pickVideoOutgoingMessage,
- shootImageOutgoingMessage
- } from '@/utils/parent-message-send'
- import websocketService from '@/utils/websocket'
- import upload from '@/utils/upload'
- import { getImageUrl } from '@/utils/util'
- import { grfwApi } from '@/api/grfw'
- import { deviceApi } from '@/api/device'
- import env from '@/config/env.js'
- const RECEIPT_TOGGLE_TTL_MS = 60 * 1000
- const REC_RYLBM_STUDENT = 1100
- const REC_RYLBM_PARENT = 1200
- const CALL_END_STORAGE_KEY = 'parentLastCallInfo'
- const CALL_END_PAGE_URL = '/pages/parent/message'
- let wmpfVoip = null
- try {
- if (typeof requirePlugin === 'function') {
- wmpfVoip = requirePlugin('wmpf-voip').default
- }
- } catch (error) {
- console.warn('wmpf-voip 插件暂不可用', error)
- }
- const miniprogramState = (() => {
- if (typeof wx === 'undefined' || typeof wx.getAccountInfoSync !== 'function') {
- return 'formal'
- }
- const accountInfo = wx.getAccountInfoSync()
- if (accountInfo && accountInfo.miniProgram) {
- const platform = { develop: 'developer', trial: 'trial', release: 'formal' }
- return platform[accountInfo.miniProgram.envVersion]
- }
- return 'formal'
- })()
- export default {
- props: {
- role: {
- type: String,
- default: ''
- },
- contactId: {
- type: [String, Number],
- default: ''
- },
- contactName: {
- type: String,
- default: ''
- },
- studentId: {
- type: [String, Number],
- default: ''
- }
- },
- components: {
- Icon,
- customModal
- },
- computed: {},
- watch: {
- role() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
- this.bootstrapMessageFlow()
- },
- contactId() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
- this.bootstrapMessageFlow()
- },
- contactName() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, contactName: this.contactName, studentId: this.studentId })
- },
- studentId() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
- this.bootstrapMessageFlow()
- }
- },
- data() {
- return {
- scrollTop: 0,
- scrollIntoView: '',
- inputMode: 'voice', // voice | text
- inputFocus: false,
- draftText: '',
- iconColor: '#575d6d',
- showEmojiPanel: false,
- showMorePanel: false,
- emojiList: EMOJI_LIST,
- showMediaPreview: false,
- previewType: '',
- previewSource: '',
- activeVideoIndex: -1,
- previewImageList: [],
- previewImageIndex: 0,
- showFileDialog: false,
- activeFileName: '',
- activeFileUrl: '',
- receiptToggleTimers: {},
- parentChatMembers: [],
- parentChatMemberNames: [],
- parentChatMemberIndex: 0,
- currentChatName: '留言',
- historyLoading: false,
- historyWaiter: null,
- historyWaiterTimer: null,
- parentInfo: {
- xm: '',
- openid: ''
- },
- currentCallContact: null,
- _voipEndPathSet: false,
- _voipEventRegistered: false,
- currentUserMeta: {
- direction: 'right',
- department: '家长',
- name: '家长'
- },
- textLineCount: 1,
- listTouchMoved: false,
- touchStartX: 0,
- touchStartY: 0,
- recorderManager: null,
- isRecording: false,
- isRecordCancel: false,
- recordSeconds: 0,
- recordStartY: 0,
- recordTickTimer: null,
- recordGuardTimer: null,
- audioPlayer: null,
- playingVoiceIndex: -1,
- sessionInitialized: false,
- sessionRole: 'parent',
- sessionContactId: '',
- sessionStudentId: '',
- entryFromDeviceIndex: false,
- socketConnected: false,
- socketUnsubscribeList: [],
- currentWsConfig: null,
- wsIdentity: {
- sendRyid: '',
- recRyid: '',
- recRylbm: REC_RYLBM_STUDENT
- },
- deviceSessionReady: true,
- deviceInitPromise: null,
- peerAvatarTypeHint: '',
- serviceStatus: null,
- inactivityTimer: null,
- inactivityTimeout: 180000,
- modalVisible: false,
- modalTitle: '提示',
- modalContent: '',
- modalShowCancel: false,
- modalConfirmText: '确定',
- modalResolve: null,
- loadOptions: {},
- // 消息列表数据
- messages: []
- }
- },
- created() {
- this.initializeSessionFromOptions({
- role: this.role,
- contactId: this.contactId,
- contactName: this.contactName,
- studentId: this.studentId
- })
- },
- async onLoad(options) {
- this.loadOptions = { ...(options || {}) }
- this.initializeSessionFromOptions({ ...(options || {}), force: true })
- this.initParentInfo()
- if (this.sessionRole === 'device') {
- this.deviceSessionReady = false
- this.deviceInitPromise = this.initDeviceSession(options || {})
- this.deviceSessionReady = await this.deviceInitPromise
- }
- },
- async onReady() {
- // 页面渲染完成后,滚动到最新消息
- this.$nextTick(() => {
- this.scrollToBottom()
- this.initReceiptToggleTimers()
- this.initRecorder()
- })
- this.registerVoipEvent()
- this.bindSocketListeners()
- if (this.sessionRole === 'device') {
- await (this.deviceInitPromise || Promise.resolve(true))
- if (!this.deviceSessionReady) return
- await this.checkServiceStatus(115)
- await this.checkServiceStatus(111)
- this.resetInactivityTimer()
- }
- await this.bootstrapMessageFlow()
- },
- onUnload() {
- this.teardownSocket()
- this.stopVoicePlayback()
- if (this.audioPlayer) {
- this.audioPlayer.destroy()
- this.audioPlayer = null
- }
- this.cleanupRecorder()
- this.clearReceiptToggleTimers()
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- this.inactivityTimer = null
- }
- },
- beforeUnmount() {
- this.teardownSocket()
- },
- methods: {
- showCustomModal(options = {}) {
- return new Promise((resolve) => {
- this.modalTitle = options.title || '提示'
- this.modalContent = options.content || ''
- this.modalShowCancel = options.showCancel || false
- this.modalConfirmText = options.confirmText || '确定'
- this.modalResolve = resolve
- this.modalVisible = true
- })
- },
- handleModalConfirm() {
- this.modalVisible = false
- if (this.modalResolve) {
- this.modalResolve(true)
- this.modalResolve = null
- }
- },
- handleModalCancel() {
- this.modalVisible = false
- if (this.modalResolve) {
- this.modalResolve(false)
- this.modalResolve = null
- }
- },
- initializeSessionFromOptions(options = {}) {
- if (this.sessionInitialized && !options?.force) return
- const safeDecode = (value) => {
- if (value === null || value === undefined) return ''
- const text = String(value)
- try {
- return decodeURIComponent(text)
- } catch (error) {
- return text
- }
- }
- const role = options.role || this.role || 'parent'
- this.sessionRole = role
- this.sessionContactId = safeDecode(options.contactId || this.contactId || '')
- this.sessionStudentId = options.studentId || this.studentId || ''
- this.entryFromDeviceIndex = String(options.fromIndex || '') === '1'
- const targetContactName = safeDecode(options.contactName || this.contactName || '')
- this.currentChatName = role === 'device' ? (targetContactName || '家长') : '留言'
- this.currentUserMeta = {
- ...this.currentUserMeta,
- department: role === 'device' ? '设备端' : '家长'
- }
- this.refreshCurrentUserMeta()
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- this.sessionInitialized = true
- if (this.socketUnsubscribeList.length) {
- this.bindSocketListeners()
- }
- },
- async bootstrapMessageFlow() {
- if (this.sessionRole === 'parent') {
- await this.loadParentChatMembers()
- } else {
- await this.loadDeviceChatMembers()
- await this.ensureSessionSocket()
- }
- await this.loadHistoryMessages({ reset: true })
- if (this.sessionRole === 'device' && this.entryFromDeviceIndex) {
- await this.loadHistoryMessages({ reset: true })
- }
- },
- async initDeviceSession(options = {}) {
- if (this.sessionRole !== 'device') return
- const userInfo = this.getStoredUserInfo()
- const snFromOption = String(options.sn || '').trim()
- const cardNo = String(options.cardNo || '').trim()
- const sn = snFromOption || String(userInfo.devId || '').trim()
- if (!sn || !cardNo) return false
- try {
- const result = await deviceApi.login(sn, cardNo)
- const loginData = result?.data || {}
- if (!loginData || loginData.msg) {
- await this.handleDeviceLoginFailure('登录失败,卡无登记')
- return false
- }
- const mergedUserInfo = {
- ...this.getStoredUserInfo(),
- devId: loginData.devId || sn,
- sbmc: loginData.sbmc || '',
- sessId: loginData.sessId || '',
- xm: loginData.xm || '',
- yhsbToken: loginData.yhsbToken || '',
- onlineToken: loginData.onlineToken || '',
- syList: loginData.sylist || loginData.syList || [],
- yhid: loginData.yhid || '',
- yhm: loginData.yhm || ''
- }
- if (loginData.yszwj) mergedUserInfo.yszwj = loginData.yszwj
- if (loginData.zjzwj) mergedUserInfo.zjzwj = loginData.zjzwj
- uni.setStorageSync('userInfo', mergedUserInfo)
- if (mergedUserInfo.sessId) {
- uni.setStorageSync('JSESSIONID', mergedUserInfo.sessId)
- }
- this.refreshCurrentUserMeta()
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- return true
- } catch (error) {
- console.error('设备端登录失败:', error)
- await this.handleDeviceLoginFailure('网络错误或服务异常')
- return false
- }
- },
- async handleDeviceLoginFailure(content = '登录失败') {
- await this.showCustomModal({
- title: '登录失败',
- content,
- showCancel: false,
- confirmText: '确定'
- })
- await this.handleDeviceLogout()
- return false
- },
- async loadParentChatMembers() {
- try {
- const result = await grfwApi.grfw_selChatMbr({})
- const data = result?.data || {}
- const list = this.normalizeChatMemberList(data.chatMbrList)
- this.parentChatMembers = list
- this.parentChatMemberNames = list.map((item) => item.xm || String(item.ryid || '未命名'))
- if (!list.length) {
- this.sessionContactId = ''
- this.currentChatName = '留言'
- this.resolveWsIdentity()
- return
- }
- let targetIndex = 0
- const current = String(this.sessionContactId || '')
- if (current) {
- const idx = list.findIndex((item) => String(item.ryid || '') === current)
- if (idx > -1) targetIndex = idx
- }
- this.selectParentMemberByIndex(targetIndex)
- } catch (error) {
- console.error('加载孩子列表失败:', error)
- uni.showToast({ title: '加载孩子列表失败', icon: 'none' })
- }
- },
- async loadDeviceChatMembers() {
- try {
- const result = await deviceApi.grfw_selChatMbr()
- const data = result?.data || {}
- const list = this.normalizeChatMemberList(data.chatMbrList)
- this.parentChatMembers = list
- this.parentChatMemberNames = list.map((item) => item.xm || String(item.ryid || '未命名家长'))
- if (!list.length) {
- this.sessionContactId = ''
- this.currentChatName = '家长'
- this.resolveWsIdentity()
- return
- }
- let targetIndex = 0
- const current = String(this.sessionContactId || '')
- if (current) {
- const idx = list.findIndex((item) => String(item.ryid || '') === current)
- if (idx > -1) targetIndex = idx
- }
- this.selectParentMemberByIndex(targetIndex)
- } catch (error) {
- console.error('加载家长列表失败:', error)
- uni.showToast({ title: '加载家长列表失败', icon: 'none' })
- }
- },
- normalizeChatMemberList(rawList) {
- if (!Array.isArray(rawList)) return []
- const queue = [...rawList]
- const result = []
- while (queue.length) {
- const item = queue.shift()
- if (Array.isArray(item)) {
- queue.unshift(...item)
- continue
- }
- if (item && typeof item === 'object') {
- result.push(item)
- }
- }
- return result
- },
- async handleParentMemberChange(event) {
- if (this.historyLoading) {
- await this.finishHistoryLoading()
- }
- const index = Number(event?.detail?.value || 0)
- console.log('handleParentMemberChange -> index', index)
- this.selectParentMemberByIndex(index)
- await this.loadHistoryMessages({ reset: true })
- },
- selectParentMemberByIndex(index = 0) {
- const safeIndex = Math.max(0, Math.min(index, this.parentChatMembers.length - 1))
- this.parentChatMemberIndex = safeIndex
- const target = this.parentChatMembers[safeIndex] || {}
- this.sessionContactId = String(target.ryid || '')
- this.currentChatName = target.xm || '留言'
- this.refreshCurrentUserMeta()
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- },
- initParentInfo() {
- let info = uni.getStorageSync('userInfo')
- if (typeof info === 'string') {
- try {
- info = JSON.parse(info)
- } catch (error) {
- info = null
- }
- }
- if (!info || typeof info !== 'object') return
- this.parentInfo = {
- ...this.parentInfo,
- ...info
- }
- this.refreshCurrentUserMeta()
- },
- getStoredUserInfo() {
- let info = uni.getStorageSync('userInfo') || {}
- if (typeof info === 'string') {
- try {
- info = JSON.parse(info)
- } catch (error) {
- info = {}
- }
- }
- return info && typeof info === 'object' ? info : {}
- },
- getSelfDisplayMeta() {
- const userInfo = this.getStoredUserInfo()
- if (this.sessionRole === 'device') {
- return {
- department: '学生',
- name: userInfo.xm || this.currentUserMeta.name || '学生'
- }
- }
- return {
- department: '家长',
- name: this.parentInfo?.xm || userInfo.xm || this.currentUserMeta.name || '家长'
- }
- },
- getPeerDisplayMeta() {
- if (this.sessionRole === 'device') {
- return {
- department: '家长',
- name: this.currentChatName || '家长'
- }
- }
- return {
- department: '学生',
- name: this.currentChatName || '学生'
- }
- },
- resolveMessageMetaByDirection(direction = 'right') {
- return direction === 'right' ? this.getSelfDisplayMeta() : this.getPeerDisplayMeta()
- },
- getSelfAvatarMeta() {
- const userInfo = this.getStoredUserInfo()
- const peerType = String(this.peerAvatarTypeHint || '')
- const preferType = peerType === '1' ? '51' : (peerType === '51' ? '1' : '')
- if (preferType === '51') {
- return {
- url: this.toDisplayImageUrl(userInfo.zjzwj || userInfo.yszwj || ''),
- type: '51'
- }
- }
- if (preferType === '1') {
- return {
- url: this.toDisplayImageUrl(userInfo.yszwj || userInfo.zjzwj || ''),
- type: '1'
- }
- }
- if (this.sessionRole === 'device') {
- if (userInfo.zjzwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.zjzwj),
- type: '51'
- }
- }
- if (userInfo.yszwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.yszwj),
- type: '1'
- }
- }
- return {
- url: '/static/logo.png',
- type: ''
- }
- }
- if (userInfo.yszwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.yszwj),
- type: '1'
- }
- }
- if (userInfo.zjzwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.zjzwj),
- type: '51'
- }
- }
- return {
- url: '/static/logo.png',
- type: ''
- }
- },
- getAvatarSrc(message = {}) {
- if (!message || message.direction === 'right') {
- return this.getSelfAvatarMeta().url
- }
- return message.avatarUrl || '/static/logo.png'
- },
- getAvatarClass(message = {}) {
- const type = String(
- message && message.direction === 'right'
- ? this.getSelfAvatarMeta().type
- : (message.avatarType || '')
- )
- return {
- 'square-avatar': type === '51',
- 'doc-avatar': type === '51'
- }
- },
- refreshCurrentUserMeta() {
- const selfMeta = this.getSelfDisplayMeta()
- this.currentUserMeta = {
- ...this.currentUserMeta,
- department: selfMeta.department,
- name: selfMeta.name
- }
- },
- buildCallContactByCurrentMember() {
- const member = this.parentChatMembers[this.parentChatMemberIndex] || {}
- if (this.sessionRole === 'device') {
- return {
- username: member.xm || member.username || '家长',
- avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
- openid: member.wbid2 || member.wbid || ''
- }
- }
- return {
- username: member.xm || member.username || '学生',
- avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
- deviceSn: member.deviceSn || member.sn || member.devId || member.sbkh || '',
- pushToken: member.pushToken || member.voipToken || member.token || ''
- }
- },
- async startVoiceCall() {
- const contact = this.buildCallContactByCurrentMember()
- await this.startCall(contact, 'voice')
- },
- async startVideoCall() {
- const contact = this.buildCallContactByCurrentMember()
- await this.startCall(contact, 'video')
- },
- async startCall(contact, roomType) {
- if (!wmpfVoip) {
- uni.showToast({ title: '通话能力暂不可用', icon: 'none' })
- return
- }
- if (this.sessionRole === 'device') {
- const userInfo = this.getStoredUserInfo()
- const callerId = String(userInfo.devId || this.currentWsConfig?.ssDevId || '').trim()
- const listenerId = String(contact?.openid || '').trim()
- if (!callerId) {
- uni.showToast({ title: '缺少设备标识', icon: 'none' })
- return
- }
- if (!listenerId) {
- uni.showToast({ title: '请让家长先关注公众号,登陆小程序后再发起', icon: 'none' })
- return
- }
- uni.showLoading({ title: '呼叫中...', mask: true })
- uni.setStorageSync(CALL_END_STORAGE_KEY, {
- name: contact?.username || '家长',
- avatar: contact?.avatar || '/static/logo.png',
- duration: 0,
- time: new Date().toLocaleString(),
- type: roomType,
- status: '呼叫中'
- })
- this.setVoipEndPagePath()
- try {
- const res = await wmpfVoip.initByCaller({
- roomType,
- caller: {
- id: callerId,
- name: userInfo.xm || '学生'
- },
- listener: {
- id: listenerId,
- name: contact?.username || '家长'
- },
- businessType: 1,
- miniprogramState
- })
- if (res.isSuccess) {
- uni.hideLoading()
- uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
- return
- }
- uni.hideLoading()
- uni.showToast({ title: '呼叫失败', icon: 'error' })
- return
- } catch (error) {
- uni.hideLoading()
- console.error('设备端通话异常:', error)
- uni.showToast({ title: '发起通话失败', icon: 'none' })
- return
- }
- }
- const callerId = this.parentInfo?.openid || this.parentInfo?.wbid || ''
- if (!callerId) {
- uni.showToast({ title: '缺少家长身份标识', icon: 'none' })
- return
- }
- if (!contact?.deviceSn) {
- uni.showToast({ title: '未找到可呼叫的设备编号', icon: 'none' })
- return
- }
- if (!contact?.pushToken) {
- uni.showToast({ title: '设备未上报通话凭证', icon: 'none' })
- return
- }
- uni.showLoading({ title: '呼叫中...', mask: true })
- this.currentCallContact = contact
- const callInfo = {
- name: contact.username || '家庭设备',
- avatar: contact.avatar || '/static/logo.png',
- duration: 0,
- time: new Date().toLocaleString(),
- type: roomType,
- status: '呼叫中'
- }
- uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
- this.setVoipEndPagePath()
- try {
- const res = await wmpfVoip.initByCaller({
- roomType,
- caller: {
- id: callerId,
- name: this.parentInfo?.xm || '家长'
- },
- listener: {
- id: contact.deviceSn,
- name: contact.username
- },
- businessType: 2,
- voipToken: contact.pushToken,
- miniprogramState
- })
- if (res.isSuccess) {
- uni.hideLoading()
- uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
- return
- }
- uni.hideLoading()
- console.error('呼叫失败', res)
- uni.showToast({ title: '呼叫失败', icon: 'error' })
- } catch (error) {
- uni.hideLoading()
- console.error('通话异常:', error)
- uni.showToast({ title: '发起通话失败', icon: 'none' })
- }
- },
- async handleDeviceLogout() {
- try {
- await deviceApi.ssExit()
- } catch (error) {
- console.warn('设备端退出服务调用失败:', error)
- }
- uni.removeStorageSync('userInfo')
- uni.removeStorageSync('JSESSIONID')
- uni.removeStorageSync('currcall')
- uni.removeStorageSync('lastCallInfo')
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- this.inactivityTimer = null
- }
- uni.showToast({
- title: '已退出',
- icon: 'success',
- duration: 1200
- })
- setTimeout(() => {
- uni.reLaunch({ url: '/pages/device/notice' })
- }, 1200)
- },
- resetInactivityTimer() {
- if (this.sessionRole !== 'device') return
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- }
- this.inactivityTimer = setTimeout(() => {
- this.handleDeviceLogout()
- }, this.inactivityTimeout)
- },
- async checkServiceStatus(grfwxmm) {
- if (this.sessionRole !== 'device') return
- const code = Number(grfwxmm)
- if (code !== 111 && code !== 115) {
- console.warn('checkServiceStatus skip: invalid grfwxmm', grfwxmm)
- return
- }
- try {
- const result = await deviceApi.grfw_chkGrfw(code)
- const data = result?.data || {}
- if (data.ssCode === 0 && data.ssData) {
- this.serviceStatus = data.ssData
- }
- } catch (error) {
- console.error('检查服务状态失败:', error)
- }
- },
- async recordCallAndRefresh(callOptions = {}) {
- if (this.sessionRole !== 'device') return
- try {
- const duration = parseInt(callOptions.duration, 10) || 0
- const callType = callOptions.callType || 'voice'
- if (duration <= 0) return
- const minutes = Math.ceil(duration / 60)
- const grfwxmm = callType === 'video' ? 115 : 111
- await deviceApi.grfw_endGrfw({
- grfwxmm,
- sc: minutes,
- ll: 0,
- ms: `通话${duration}秒`
- })
- await this.checkServiceStatus(grfwxmm)
- } catch (error) {
- console.error('记录通话失败:', error)
- }
- },
- setVoipEndPagePath(forceUpdate = false) {
- if (this._voipEndPathSet && !forceUpdate) return
- if (!wmpfVoip) return
- const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
- const query = [this.sessionRole === 'device' ? 'role=device' : 'role=parent']
- if (this.sessionContactId) {
- query.push(`contactId=${encodeURIComponent(this.sessionContactId)}`)
- }
- if (this.currentChatName) {
- query.push(`contactName=${encodeURIComponent(this.currentChatName)}`)
- }
- if (this.sessionRole === 'device') {
- const userInfo = this.getStoredUserInfo()
- if (userInfo.devId) {
- query.push(`sn=${encodeURIComponent(userInfo.devId)}`)
- }
- }
- if (callInfo.name) {
- query.push(`name=${encodeURIComponent(callInfo.name)}`)
- query.push(`avatar=${encodeURIComponent(callInfo.avatar || '')}`)
- query.push(`duration=${callInfo.duration || 0}`)
- query.push(`type=${callInfo.type || 'voice'}`)
- query.push(`status=${encodeURIComponent(callInfo.status || '通话已结束')}`)
- }
- wmpfVoip.setVoipEndPagePath({
- url: CALL_END_PAGE_URL,
- key: 'Call',
- options: query.join('&'),
- routeType: 'redirectTo'
- })
- this._voipEndPathSet = true
- },
- registerVoipEvent() {
- if (this._voipEventRegistered || !wmpfVoip) return
- wmpfVoip.onVoipEvent((event) => {
- const eventName = event.eventName
- const hangupEvent = ['hangUpVoip', 'endVoip']
- const cancelEvent = ['cancelVoip']
- const timeoutEvent = ['timeout']
- const rejectEvent = ['rejectVoip']
- const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
- if (hangupEvent.includes(eventName)) {
- callInfo.duration = event.data?.keepTime || 0
- callInfo.status = event.data?.keepTime > 0 ? '通话已结束' : '未接通'
- if (callInfo.duration > 0) {
- callInfo.needRecord = true
- }
- } else if (cancelEvent.includes(eventName)) {
- callInfo.duration = 0
- callInfo.status = '已取消'
- } else if (timeoutEvent.includes(eventName)) {
- callInfo.duration = 0
- callInfo.status = '未接听'
- } else if (rejectEvent.includes(eventName)) {
- callInfo.duration = 0
- callInfo.status = '已拒绝'
- }
- if ([...hangupEvent, ...cancelEvent, ...timeoutEvent, ...rejectEvent].includes(eventName)) {
- callInfo.endType = eventName
- callInfo.endTime = Date.now()
- uni.hideLoading()
- uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
- this.setVoipEndPagePath(true)
- if (this.sessionRole === 'device' && callInfo.needRecord && callInfo.duration > 0) {
- this.recordCallAndRefresh({
- duration: callInfo.duration,
- callType: callInfo.type || 'voice'
- })
- delete callInfo.needRecord
- uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
- }
- this.resetInactivityTimer()
- }
- })
- this._voipEventRegistered = true
- },
- formatPayloadTime(rawValue) {
- if (!rawValue) return ''
- const normalized = String(rawValue)
- .replace(/\u202f/g, ' ')
- .replace(/\u00a0/g, ' ')
- .replace(/\s+/g, ' ')
- .trim()
- let parsed = new Date(normalized)
- if (Number.isNaN(parsed.getTime())) {
- const monthMap = {
- Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
- Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
- }
- const match = normalized.match(/^([A-Za-z]{3})\s+(\d{1,2}),\s*(\d{4}),\s*(\d{1,2}):(\d{2}):(\d{2})\s*(AM|PM)$/i)
- if (match) {
- const mon = monthMap[match[1]]
- let hh = Number(match[4])
- const mm = Number(match[5])
- const ss = Number(match[6])
- const ap = String(match[7]).toUpperCase()
- if (ap === 'PM' && hh < 12) hh += 12
- if (ap === 'AM' && hh === 12) hh = 0
- parsed = new Date(Number(match[3]), mon, Number(match[2]), hh, mm, ss)
- }
- }
- if (Number.isNaN(parsed.getTime())) return ''
- const hh = String(parsed.getHours()).padStart(2, '0')
- const mm = String(parsed.getMinutes()).padStart(2, '0')
- return `${hh}:${mm}`
- },
- waitHistoryDone(timeout = 12000) {
- if (this.historyWaiter) {
- return this.historyWaiter.promise
- }
- let resolve
- const promise = new Promise((r) => {
- resolve = r
- })
- this.historyWaiter = { promise, resolve }
- if (this.historyWaiterTimer) {
- clearTimeout(this.historyWaiterTimer)
- }
- this.historyWaiterTimer = setTimeout(() => {
- this.finishHistoryLoading()
- }, timeout)
- return promise
- },
- async finishHistoryLoading() {
- this.historyLoading = false
- if (this.historyWaiterTimer) {
- clearTimeout(this.historyWaiterTimer)
- this.historyWaiterTimer = null
- }
- if (this.historyWaiter && typeof this.historyWaiter.resolve === 'function') {
- this.historyWaiter.resolve(true)
- }
- this.historyWaiter = null
- if (this.sessionRole === 'parent') {
- await websocketService.disconnect()
- }
- },
- async loadHistoryMessages({ reset = true } = {}) {
- if (this.historyLoading) return
- if (!this.wsIdentity.sendRyid || !this.wsIdentity.recRyid) {
- console.warn('loadHistoryMessages skip: missing wsIdentity', this.wsIdentity)
- return
- }
- console.log('loadHistoryMessages start', {
- sendRyid: this.wsIdentity.sendRyid,
- recRyid: this.wsIdentity.recRyid,
- role: this.sessionRole
- })
- if (reset) {
- this.messages = []
- this.scrollIntoView = ''
- this.peerAvatarTypeHint = ''
- }
- const config = this.currentWsConfig || this.buildWsConnectOptions()
- this.historyLoading = true
- try {
- console.log('loadHistoryMessages ensureConnected start')
- await websocketService.ensureConnected(config)
- console.log('loadHistoryMessages ensureConnected done')
- await websocketService.send({
- cmd: 161,
- sendRyid: this.wsIdentity.sendRyid,
- recRyid: this.wsIdentity.recRyid
- })
- await this.waitHistoryDone()
- } catch (error) {
- this.historyLoading = false
- console.error('拉取历史留言失败:', error)
- if (this.sessionRole === 'parent') {
- await websocketService.disconnect()
- }
- }
- },
- resolveWsIdentity() {
- let userInfo = uni.getStorageSync('userInfo') || {}
- if (typeof userInfo === 'string') {
- try {
- userInfo = JSON.parse(userInfo)
- } catch (error) {
- userInfo = {}
- }
- }
- const sendRyid = String(userInfo.ryid || userInfo.yhid || userInfo.userId || '')
- const recRyid = String(
- this.sessionContactId ||
- this.sessionStudentId ||
- ''
- )
- const recRylbm = this.sessionRole === 'parent' ? REC_RYLBM_STUDENT : REC_RYLBM_PARENT
- this.wsIdentity = {
- sendRyid,
- recRyid,
- recRylbm
- }
- },
- buildWsConnectOptions() {
- let userInfo = uni.getStorageSync('userInfo') || {}
- if (typeof userInfo === 'string') {
- try {
- userInfo = JSON.parse(userInfo)
- } catch (error) {
- userInfo = {}
- }
- }
- const config = {
- role: this.sessionRole
- }
- if (this.sessionRole === 'device') {
- config.ssDevId = String(userInfo.devId || '')
- config.heartbeat = true
- config.autoReconnect = true
- } else {
- config.ssToken = String(userInfo.yhsbToken || '')
- config.heartbeat = false
- config.autoReconnect = false
- }
- return config
- },
- bindSocketListeners() {
- if (!this.currentWsConfig) return
- this.teardownSocketListeners()
- const un1 = websocketService.on('open', () => {
- this.socketConnected = true
- })
- const un2 = websocketService.on('close', () => {
- this.socketConnected = false
- })
- const un3 = websocketService.on('error', () => {
- this.socketConnected = false
- })
- const un4 = websocketService.on('cmd:101', (payload) => {
- this.handleWsIncomingMessage(payload)
- })
- const un5 = websocketService.on('cmd:165', (payload) => {
- this.handleWsHistoryMessage(payload)
- })
- const un6 = websocketService.on('cmd:11', () => {
- this.handleWsHistoryDone()
- })
- const un7 = websocketService.on('cmd:151', (payload) => {
- this.handleWsReceipt(payload)
- })
- const un8 = websocketService.on('cmd:51', () => {
- uni.$emit('device-message-refresh')
- })
- this.socketUnsubscribeList = [un1, un2, un3, un4, un5, un6, un7, un8]
- },
- teardownSocketListeners() {
- if (!this.socketUnsubscribeList.length) return
- this.socketUnsubscribeList.forEach((off) => {
- if (typeof off === 'function') off()
- })
- this.socketUnsubscribeList = []
- },
- async teardownSocket() {
- this.teardownSocketListeners()
- this.socketConnected = false
- if (this.sessionRole !== 'device') {
- await websocketService.disconnect()
- }
- },
- async ensureSessionSocket() {
- if (!this.currentWsConfig) {
- this.initializeSessionFromOptions({ force: true })
- }
- let config = this.currentWsConfig || {}
- if (config.role === 'parent') return
- if (!config.ssDevId) {
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- config = this.currentWsConfig || {}
- }
- if (!config.ssDevId) return
- try {
- await websocketService.ensureConnected(config)
- } catch (error) {
- console.error('设备端 WS 连接失败:', error)
- }
- },
- toDisplayImageUrl(path) {
- if (!path) return '/static/logo.png'
- if (/^https?:\/\//.test(path)) return path
- return getImageUrl(path)
- },
- toDisplayFileUrl(path, type = '') {
- if (!path) return ''
- if (/^https?:\/\//.test(path)) return path
- const typePart = type ? `&type=${encodeURIComponent(type)}` : ''
- return `${env.baseUrl}/service?ssServ=dlByHttp&wdConfirmationCaptchaService=0${typePart}&path=${encodeURIComponent(path)}`
- },
- formatVoiceBubbleText(message = {}) {
- const duration = String(message.duration || '').trim()
- return duration ? `${duration}"` : '语音'
- },
- getBaseNameFromPath(path) {
- if (!path) return ''
- const source = String(path).split('?')[0]
- const seg = source.split('/')
- return seg[seg.length - 1] || ''
- },
- normalizeIncomingCont(cont) {
- if (!cont) return {}
- if (typeof cont === 'string') {
- try {
- return JSON.parse(cont)
- } catch (error) {
- return { body: cont }
- }
- }
- return cont
- },
- appendPayloadMessage(payload = {}, { fromHistory = false } = {}) {
- if (!this.shouldHandleIncomingPayload(payload)) return
- const cont = this.normalizeIncomingCont(payload.cont)
- const typeCode = String(cont.type || '121')
- const direction = String(payload.sendRyid || '') === String(this.wsIdentity.sendRyid || '')
- ? 'right'
- : 'left'
- const messageMeta = this.resolveMessageMetaByDirection(direction)
- const payloadAlias = String(payload.alias || '').trim()
- const payloadLogo = payload.logo ? this.toDisplayImageUrl(payload.logo) : ''
- const payloadLogoType = String(payload.logoType || '')
- if (direction === 'left' && (payloadLogoType === '1' || payloadLogoType === '51')) {
- this.peerAvatarTypeHint = payloadLogoType
- }
- const msgId = payload.xxid || payload.msgId || ''
- if (fromHistory && msgId) {
- const exists = this.messages.some((item) => String(item.msgId || '') === String(msgId))
- if (exists) return
- }
- const payloadTime = this.formatPayloadTime(payload.sendTime || payload.sendTimeStr || payload.time)
- const displayTime = payloadTime || this.formatPayloadTime(new Date())
- const receiptStatus = (payload.readTime || payload.rdTime || payload.readTm) ? 'read' : 'unread'
- if (typeCode === '121') {
- const rawBody = String(cont.body || '').trim()
- if (cont.fileName && (!rawBody || /^\[文件\]/.test(rawBody))) {
- this.appendMessage({
- type: 'file',
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- fileName: cont.baseName || this.getBaseNameFromPath(cont.fileName) || '未命名文件',
- fileUrl: this.toDisplayFileUrl(cont.fileName || ''),
- time: displayTime,
- needReceipt: true,
- receiptStatus,
- msgId
- })
- return
- }
- this.appendMessage(createTextMessage({
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- content: cont.body || '',
- needReceipt: true,
- receiptStatus,
- time: displayTime,
- msgId
- }))
- return
- }
- if (typeCode === '122') {
- this.appendMessage(createImageMessage({
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- imageUrl: this.toDisplayImageUrl(cont.fileName || cont.body || ''),
- time: displayTime,
- needReceipt: true,
- receiptStatus,
- msgId
- }))
- return
- }
- if (typeCode === '123') {
- this.appendMessage({
- type: 'voice',
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- duration: String(cont.duration || ''),
- audioUrl: this.toDisplayFileUrl(cont.fileName || '', 'aud'),
- voicePreview: '',
- voiceText: cont.body || '',
- needReceipt: true,
- receiptStatus,
- time: displayTime,
- msgId
- })
- return
- }
- if (typeCode === '124') {
- this.appendMessage({
- type: 'video',
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- coverUrl: '/static/logo.png',
- videoUrl: this.toDisplayFileUrl(cont.fileName || '', 'vid'),
- duration: String(cont.duration || ''),
- time: displayTime,
- needReceipt: true,
- receiptStatus,
- msgId
- })
- }
- },
- handleWsIncomingMessage(payload = {}) {
- this.appendPayloadMessage(payload, { fromHistory: false })
- },
- handleWsHistoryMessage(payload = {}) {
- this.appendPayloadMessage(payload, { fromHistory: true })
- },
- handleWsHistoryDone() {
- if (!this.historyLoading) return
- this.finishHistoryLoading()
- },
- shouldHandleIncomingPayload(payload = {}) {
- const send = String(payload.sendRyid || '')
- const rec = String(payload.recRyid || '')
- const me = String(this.wsIdentity.sendRyid || '')
- const peer = String(this.wsIdentity.recRyid || '')
- if (!me || !peer) return true
- return (send === me && rec === peer) || (send === peer && rec === me)
- },
- handleWsReceipt(payload = {}) {
- const msgId = String(payload.xxid || '')
- if (!msgId) return
- const idx = this.messages.findIndex((item) => String(item.msgId || '') === msgId)
- if (idx < 0) return
- this.$set(this.messages[idx], 'receiptStatus', 'read')
- },
- async confirmRead(message, index) {
- if (!message || message.direction !== 'left') return
- if (message.receiptStatus === 'read') return
- const msgId = String(message.msgId || '')
- if (!msgId) return
- const payload = {
- cmd: 151,
- echoState: 2,
- xxid: msgId,
- sendRyid: this.wsIdentity.sendRyid,
- recRyid: this.wsIdentity.recRyid,
- recRylbm: this.wsIdentity.recRylbm
- }
- const sent = await this.sendWsPayload(payload)
- if (!sent) return
- this.$set(this.messages[index], 'receiptStatus', 'read')
- },
- async sendWsPayload(payload) {
- this.resetInactivityTimer()
- const config = this.currentWsConfig || this.buildWsConnectOptions()
- if (!config) return false
- if (config.role === 'parent' && !config.ssToken) {
- uni.showToast({ title: '缺少 ssToken', icon: 'none' })
- return false
- }
- if (config.role === 'device' && !config.ssDevId) {
- uni.showToast({ title: '缺少 ssDevId', icon: 'none' })
- return false
- }
- try {
- await websocketService.ensureConnected(config)
- await websocketService.send(payload)
- if (config.role === 'parent') {
- await websocketService.disconnect()
- }
- return true
- } catch (error) {
- console.error('发送留言失败:', error)
- uni.showToast({ title: '发送失败', icon: 'none' })
- return false
- }
- },
- async sendOutgoingByType(message) {
- if (!message) return false
- const sendRyid = this.wsIdentity.sendRyid
- const recRyid = this.wsIdentity.recRyid
- if (!sendRyid || !recRyid) {
- uni.showToast({ title: '缺少人员标识', icon: 'none' })
- return false
- }
- let cont = null
- if (message.type === 'text') {
- cont = {
- title: '',
- type: '121',
- body: message.content || ''
- }
- } else if (message.type === 'image') {
- cont = {
- title: '',
- type: '122',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || 'image.jpg'
- }
- } else if (message.type === 'voice') {
- cont = {
- title: '音频',
- type: '123',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || 'voice.mp3',
- duration: message.duration || ''
- }
- } else if (message.type === 'video') {
- cont = {
- title: '视频',
- type: '124',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || 'video.mp4',
- duration: message.duration || ''
- }
- } else if (message.type === 'file') {
- cont = {
- title: '',
- type: '121',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || message.fileName || 'file'
- }
- } else {
- uni.showToast({ title: '当前消息类型不支持发送', icon: 'none' })
- return false
- }
- const payload = {
- cmd: 101,
- cont,
- sendRyid,
- recRyid,
- recRylbm: this.wsIdentity.recRylbm,
- echoState: 1
- }
- return this.sendWsPayload(payload)
- },
- getVoiceBubbleStyle(message) {
- if (!message || !message.isPlaying) return {}
- const progress = Math.max(0, Math.min(100, Number(message.playProgress || 0)))
- if (message.direction === 'right') {
- return {
- backgroundImage: `linear-gradient(to right, #7d89b1 ${progress}%, #eeeeee ${progress}%)`
- }
- }
- return {
- backgroundImage: `linear-gradient(to right, #eeeeee ${progress}%, #7d89b1 ${progress}%)`
- }
- },
- resetVoiceItemState(index) {
- if (index < 0 || !this.messages[index]) return
- this.$set(this.messages[index], 'isPlaying', false)
- this.$set(this.messages[index], 'playProgress', 0)
- },
- updateVoicePlayProgress() {
- const index = this.playingVoiceIndex
- if (index < 0 || !this.messages[index] || !this.audioPlayer) return
- const duration = Number(this.audioPlayer.duration || 0)
- const currentTime = Number(this.audioPlayer.currentTime || 0)
- if (duration <= 0) return
- const progress = Math.max(0, Math.min(100, (currentTime / duration) * 100))
- this.$set(this.messages[index], 'playProgress', progress)
- },
- initAudioPlayer() {
- if (this.audioPlayer || !uni.createInnerAudioContext) return
- this.audioPlayer = uni.createInnerAudioContext()
- this.audioPlayer.onTimeUpdate(() => {
- this.updateVoicePlayProgress()
- })
- this.audioPlayer.onEnded(() => {
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- })
- this.audioPlayer.onStop(() => {
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- })
- this.audioPlayer.onError(() => {
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- uni.showToast({ title: '语音播放失败', icon: 'none' })
- })
- },
- stopVoicePlayback() {
- if (this.audioPlayer) {
- this.audioPlayer.stop()
- }
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- },
- toggleVoicePlayback(message, index) {
- if (!message || message.type !== 'voice') return
- if (!message.audioUrl) {
- uni.showToast({ title: '该语音暂无音频文件', icon: 'none' })
- return
- }
- this.initAudioPlayer()
- if (!this.audioPlayer) {
- uni.showToast({ title: '当前环境不支持播放', icon: 'none' })
- return
- }
- if (this.playingVoiceIndex === index) {
- this.stopVoicePlayback()
- return
- }
- if (this.playingVoiceIndex > -1) {
- this.audioPlayer.stop()
- this.resetVoiceItemState(this.playingVoiceIndex)
- }
- this.playingVoiceIndex = index
- this.$set(this.messages[index], 'isPlaying', true)
- this.$set(this.messages[index], 'playProgress', 0)
- this.audioPlayer.src = message.audioUrl
- this.audioPlayer.play()
- },
- initRecorder() {
- if (!uni.getRecorderManager) return
- this.recorderManager = uni.getRecorderManager()
- this.recorderManager.onStop((res) => {
- const canceled = this.isRecordCancel
- const durationMs = Number(res && res.duration ? res.duration : 0)
- const durationSeconds = Math.max(1, Math.round(durationMs / 1000) || this.recordSeconds)
- this.clearRecordTimers()
- this.isRecording = false
- this.isRecordCancel = false
- this.recordStartY = 0
- if (canceled) {
- uni.showToast({ title: '已取消发送', icon: 'none' })
- return
- }
- if (!res || !res.tempFilePath || durationSeconds < 1) {
- uni.showToast({ title: '录音时间太短', icon: 'none' })
- return
- }
- const voiceMessage = buildVoiceOutgoingMessage({
- durationSeconds,
- audioUrl: res.tempFilePath,
- voiceText: ''
- }, this.currentUserMeta)
- if (!voiceMessage) return
- ;(async () => {
- try {
- const localPath = voiceMessage.audioUrl
- const serverPath = await upload.uploadAudio(localPath)
- if (!serverPath) return
- voiceMessage.serverFilePath = serverPath
- voiceMessage.baseName = this.getBaseNameFromPath(localPath) || 'voice.mp3'
- voiceMessage.audioUrl = this.toDisplayFileUrl(serverPath, 'aud')
- const sent = await this.sendOutgoingByType(voiceMessage)
- if (sent) this.appendMessage(voiceMessage)
- } catch (error) {
- uni.showToast({ title: '语音发送失败', icon: 'none' })
- }
- })()
- })
- this.recorderManager.onError(() => {
- this.clearRecordTimers()
- this.isRecording = false
- this.isRecordCancel = false
- uni.showToast({ title: '录音失败', icon: 'none' })
- })
- },
- clearRecordTimers() {
- if (this.recordTickTimer) {
- clearInterval(this.recordTickTimer)
- this.recordTickTimer = null
- }
- if (this.recordGuardTimer) {
- clearTimeout(this.recordGuardTimer)
- this.recordGuardTimer = null
- }
- },
- cleanupRecorder() {
- this.clearRecordTimers()
- if (this.isRecording && this.recorderManager) {
- this.isRecordCancel = true
- try {
- this.recorderManager.stop()
- } catch (error) {
- // noop
- }
- }
- this.isRecording = false
- this.recordSeconds = 0
- },
- handlePressToTalkStart(event) {
- if (this.isRecording) return
- if (!this.recorderManager) {
- this.initRecorder()
- }
- if (!this.recorderManager) {
- uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
- return
- }
- const touch = event && event.touches && event.touches[0]
- this.recordStartY = touch ? touch.clientY : 0
- this.recordSeconds = 0
- this.isRecordCancel = false
- this.isRecording = true
- this.clearRecordTimers()
- this.recordTickTimer = setInterval(() => {
- this.recordSeconds = Math.min(60, this.recordSeconds + 1)
- }, 1000)
- this.recordGuardTimer = setTimeout(() => {
- if (!this.isRecording || !this.recorderManager) return
- this.recorderManager.stop()
- }, 60000)
- try {
- this.recorderManager.start({
- duration: 60000,
- sampleRate: 16000,
- numberOfChannels: 1,
- encodeBitRate: 96000,
- format: 'mp3'
- })
- } catch (error) {
- this.clearRecordTimers()
- this.isRecording = false
- this.isRecordCancel = false
- uni.showToast({ title: '录音启动失败', icon: 'none' })
- }
- },
- handlePressToTalkMove(event) {
- if (!this.isRecording) return
- const touch = event && event.touches && event.touches[0]
- if (!touch) return
- const deltaY = this.recordStartY - touch.clientY
- this.isRecordCancel = deltaY > 80
- },
- handlePressToTalkEnd() {
- if (!this.isRecording || !this.recorderManager) return
- this.recorderManager.stop()
- },
- handlePressToTalkCancel() {
- if (!this.isRecording || !this.recorderManager) return
- this.isRecordCancel = true
- this.recorderManager.stop()
- },
- back() {
- uni.navigateBack({
- delta: 1
- })
- },
- // 切换带回执状态
- toggleReceipt(index, value) {
- this.messages[index].needReceipt = value === 1 || value === '1'
- },
- initReceiptToggleTimers() {
- this.messages.forEach((message, index) => {
- if (message.showReceiptToggle) {
- this.registerReceiptToggleTimer(index)
- }
- })
- },
- registerReceiptToggleTimer(index) {
- const message = this.messages[index]
- if (!message || !message.showReceiptToggle) return
- if (!message.receiptToggleCreatedAt) {
- message.receiptToggleCreatedAt = Date.now()
- }
- const expireAt = message.receiptToggleCreatedAt + RECEIPT_TOGGLE_TTL_MS
- const remain = expireAt - Date.now()
- if (remain <= 0) {
- message.showReceiptToggle = false
- this.clearReceiptToggleTimer(index)
- return
- }
- this.clearReceiptToggleTimer(index)
- this.receiptToggleTimers[index] = setTimeout(() => {
- const target = this.messages[index]
- if (target) {
- target.showReceiptToggle = false
- }
- this.clearReceiptToggleTimer(index)
- }, remain)
- },
- clearReceiptToggleTimer(index) {
- const timerId = this.receiptToggleTimers[index]
- if (timerId) {
- clearTimeout(timerId)
- delete this.receiptToggleTimers[index]
- }
- },
- clearReceiptToggleTimers() {
- Object.keys(this.receiptToggleTimers).forEach((key) => {
- this.clearReceiptToggleTimer(key)
- })
- },
- // 滚动到底部
- scrollToBottom() {
- const lastIndex = this.messages.length - 1
- if (lastIndex >= 0) {
- this.scrollIntoView = 'msg-' + lastIndex
- }
- },
- toggleInputMode() {
- if (this.inputMode === 'voice') {
- this.inputMode = 'text'
- this.showMorePanel = false
- this.showEmojiPanel = false
- this.$nextTick(() => {
- this.inputFocus = true
- })
- } else {
- this.inputMode = 'voice'
- this.showMorePanel = false
- this.showEmojiPanel = false
- this.inputFocus = false
- }
- },
- toggleEmojiPanel() {
- const nextStatus = !this.showEmojiPanel
- this.showEmojiPanel = nextStatus
- if (nextStatus) {
- this.inputMode = 'text'
- this.showMorePanel = false
- this.inputFocus = false
- }
- },
- toggleMorePanel() {
- const nextStatus = !this.showMorePanel
- this.showMorePanel = nextStatus
- if (nextStatus) {
- this.inputMode = 'text'
- this.showEmojiPanel = false
- this.inputFocus = false
- }
- },
- handlePageClick() {
- if (this.showMorePanel || this.showEmojiPanel) {
- this.showMorePanel = false
- this.showEmojiPanel = false
- }
- },
- insertEmoji(emoji) {
- this.inputMode = 'text'
- this.draftText = `${this.draftText}${emoji}`
- },
- handleListTouchStart(event) {
- this.resetInactivityTimer()
- const touch = event.touches && event.touches[0]
- if (!touch) return
- this.listTouchMoved = false
- this.touchStartX = touch.clientX
- this.touchStartY = touch.clientY
- },
- handleListTouchMove(event) {
- this.resetInactivityTimer()
- const touch = event.touches && event.touches[0]
- if (!touch) return
- const deltaX = Math.abs(touch.clientX - this.touchStartX)
- const deltaY = Math.abs(touch.clientY - this.touchStartY)
- if (deltaX > 8 || deltaY > 8) {
- this.listTouchMoved = true
- }
- },
- handleListTouchEnd() {
- this.resetInactivityTimer()
- if (!this.listTouchMoved) {
- this.handlePageClick()
- }
- },
- appendMessages(newMessages = []) {
- if (!newMessages.length) return
- const startIndex = this.messages.length
- this.messages = [...this.messages, ...newMessages]
- newMessages.forEach((message, offset) => {
- if (message.showReceiptToggle) {
- this.registerReceiptToggleTimer(startIndex + offset)
- }
- })
- this.$nextTick(() => {
- this.scrollToBottom()
- })
- },
- appendMessage(newMessage) {
- if (!newMessage) return
- this.appendMessages([newMessage])
- },
- async handleMoreAction(type) {
- try {
- if (type === 'file') {
- const fileMessage = await pickFileOutgoingMessage(this.currentUserMeta)
- if (!fileMessage) return
- const localPath = fileMessage.fileUrl
- const serverPath = await upload.uploadCommonFile(localPath)
- if (!serverPath) return
- fileMessage.serverFilePath = serverPath
- fileMessage.baseName = fileMessage.fileName || this.getBaseNameFromPath(localPath) || 'file'
- fileMessage.fileUrl = this.toDisplayFileUrl(serverPath)
- const sent = await this.sendOutgoingByType(fileMessage)
- if (sent) this.appendMessage(fileMessage)
- return
- }
- if (type === 'image') {
- const imageMessages = await pickImageOutgoingMessages(this.currentUserMeta)
- for (const imageMessage of imageMessages) {
- const localPath = imageMessage.imageUrl
- const serverPath = await upload.uploadImage(localPath)
- if (!serverPath) continue
- imageMessage.serverFilePath = serverPath
- imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
- imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
- const sent = await this.sendOutgoingByType(imageMessage)
- if (sent) this.appendMessage(imageMessage)
- }
- return
- }
- if (type === 'camera') {
- const imageMessage = await shootImageOutgoingMessage(this.currentUserMeta)
- if (!imageMessage) return
- const localPath = imageMessage.imageUrl
- const serverPath = await upload.uploadImage(localPath)
- if (!serverPath) return
- imageMessage.serverFilePath = serverPath
- imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
- imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
- const sent = await this.sendOutgoingByType(imageMessage)
- if (sent) this.appendMessage(imageMessage)
- return
- }
- if (type === 'video') {
- const videoMessage = await pickVideoOutgoingMessage(this.currentUserMeta)
- if (!videoMessage) return
- const localPath = videoMessage.videoUrl
- const serverPath = await upload.uploadVideo(localPath)
- if (!serverPath) return
- videoMessage.serverFilePath = serverPath
- videoMessage.baseName = this.getBaseNameFromPath(localPath) || 'video.mp4'
- videoMessage.videoUrl = this.toDisplayFileUrl(serverPath, 'vid')
- const sent = await this.sendOutgoingByType(videoMessage)
- if (sent) this.appendMessage(videoMessage)
- return
- }
- uni.showToast({ title: `点击:${type}`, icon: 'none' })
- } catch (error) {
- uni.showToast({ title: '操作已取消', icon: 'none' })
- }
- },
- openImagePreview(message) {
- const imageUrls = collectImageUrls(this.messages)
- if (!imageUrls.length) return
- const currentUrl = message.imageUrl || imageUrls[0]
- const currentIndex = imageUrls.findIndex((item) => item === currentUrl)
- this.previewType = 'image'
- this.previewImageList = imageUrls
- this.previewImageIndex = currentIndex > -1 ? currentIndex : 0
- this.showMediaPreview = true
- },
- openVideoPreview(message, index) {
- this.previewType = 'video'
- this.previewSource = message.videoUrl || message.coverUrl || '/static/logo.png'
- this.activeVideoIndex = typeof index === 'number' ? index : -1
- this.showMediaPreview = true
- },
- openFilePreview(message) {
- this.activeFileName = message.fileName || '未命名文件'
- this.activeFileUrl = message.fileUrl || ''
- this.showFileDialog = true
- },
- downloadFileFromDialog() {
- if (!this.activeFileUrl) {
- uni.showToast({
- title: '文件地址不存在',
- icon: 'none'
- })
- return
- }
- const lowerUrl = (this.activeFileUrl || '').toLowerCase()
- const lowerName = (this.activeFileName || '').toLowerCase()
- const target = lowerName || lowerUrl
- const isDocType = /(\.pdf|\.doc|\.docx|\.xls|\.xlsx|\.ppt|\.pptx|\.txt)$/.test(target)
- uni.downloadFile({
- url: this.activeFileUrl,
- success: (res) => {
- if (res.statusCode === 200) {
- this.closeFileDialog()
- if (isDocType) {
- uni.openDocument({
- filePath: res.tempFilePath,
- showMenu: true,
- fail: () => {
- uni.showToast({
- title: '文件打开失败',
- icon: 'none'
- })
- }
- })
- } else {
- uni.showToast({
- title: '下载成功,当前类型不支持在线打开',
- icon: 'none'
- })
- }
- } else {
- uni.showToast({
- title: '下载失败',
- icon: 'none'
- })
- }
- },
- fail: () => {
- uni.showToast({
- title: '下载失败',
- icon: 'none'
- })
- }
- })
- },
- closeFileDialog() {
- this.showFileDialog = false
- this.activeFileName = ''
- this.activeFileUrl = ''
- },
- closeMediaPreview() {
- this.showMediaPreview = false
- this.previewType = ''
- this.previewSource = ''
- this.activeVideoIndex = -1
- this.previewImageList = []
- this.previewImageIndex = 0
- },
- handlePreviewVideoLoaded(event) {
- const seconds = event?.detail?.duration
- if (!seconds || this.activeVideoIndex < 0 || !this.messages[this.activeVideoIndex]) return
- const mins = Math.floor(seconds / 60)
- const secs = Math.floor(seconds % 60)
- const durationText = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
- this.messages[this.activeVideoIndex].duration = durationText
- },
- handlePreviewContentClick() {
- if (this.previewType === 'image') {
- this.closeMediaPreview()
- }
- },
- handleImageSwiperChange(event) {
- this.previewImageIndex = event.detail && event.detail.current ? event.detail.current : 0
- },
- handlePreviewVideoError() {
- uni.showToast({
- title: '视频加载失败,请使用H.264编码MP4',
- icon: 'none'
- })
- },
- handleTextLineChange(event) {
- this.textLineCount = event.detail && event.detail.lineCount ? event.detail.lineCount : 1
- },
- async sendTextMessage() {
- const message = buildTextOutgoingMessage(this.draftText, this.currentUserMeta)
- if (!message) return
- message.showReceiptToggle = false
- message.needReceipt = true
- const sent = await this.sendOutgoingByType(message)
- if (!sent) return
- this.appendMessage(message)
- this.draftText = ''
- this.textLineCount = 1
- },
- addImageFromUrl(url) {
- if (!url) return
- this.appendMessage(createImageMessage({
- ...this.currentUserMeta,
- imageUrl: url
- }))
- }
- }
- }
- </script>
- <style scoped lang="less">
- .message-page {
- height: 100vh;
- background: #f5f5f5;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- .chat-nav {
- height: 110rpx;
- background: #f2f3f4;
- padding: 0 24rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-shrink: 0;
- }
- .chat-title-wrap {
- flex: 1;
- display: flex;
- justify-content: flex-start;
- align-items: center;
- min-width: 0;
- }
- .chat-title-picker {
- max-width: 460rpx;
- display: flex;
- align-items: center;
- gap: 8rpx;
- }
- .chat-title-picker.single {
- gap: 0;
- }
- .chat-name-arrow {
- font-size: 20rpx;
- color: #000000;
- line-height: 1;
- }
- .chat-title {
- font-size: 30rpx;
- font-weight: 400;
- color: #000000;
- max-width: 420rpx;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .chat-nav-right {
- display: flex;
- align-items: center;
- gap: 16rpx;
- }
- .chat-call-btn {
- width: 78rpx;
- height: 78rpx;
- border-radius: 12rpx;
- background: #ffffff;
- border: 1rpx solid #e5e5e5;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .chat-logout-icon {
- width: 36rpx;
- height: 36rpx;
- display: block;
- }
- .message-list {
- flex: 1;
- padding: 20rpx;
- background: #fff;
- overflow-y: auto;
- box-sizing: border-box;
- }
- .message-item {
- display: flex;
- align-items: flex-start;
- margin: 42rpx 0;
- gap: 20rpx;
- }
- /* 头像+时间区域 */
- .avatar-section {
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .avatar {
- width: 92rpx;
- height: 92rpx;
- border-radius: 50%;
- flex-shrink: 0;
- }
- .avatar.square-avatar {
- border-radius: 8rpx;
- }
- .avatar.doc-avatar {
- object-position: center 5px;
- }
- .msg-time {
- font-size: 32rpx;
- color: #666;
- white-space: nowrap;
- }
- /* 内容区域 */
- .content-section {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 10rpx;
- }
- .user-info {
- font-size: 28rpx;
- color: #666;
- }
- .user-info-role {
- display: none;
- }
- .message-content {
- display: flex;
- align-items: flex-end;
- gap: 10rpx;
- }
- /* 文字消息容器 */
- .text-message-wrapper {
- display: flex;
- align-items: flex-end;
- gap: 10rpx;
- }
- /* 带回执按钮容器 */
- .receipt-toggle-wrapper {
- display: flex;
- align-items: center;
- }
- /* 消息气泡通用样式 */
- .text-message,
- .call-message,
- .voice-message,
- .file-message {
- background: #7d89b1;
- padding: 16rpx 20rpx;
- border-radius: 8rpx;
- color: #fff;
- font-size: 32rpx;
- }
- .image-message,
- .video-message {
- border-radius: 8rpx;
- overflow: hidden;
- border: 1rpx solid #dcdcdc;
- background: #ffffff;
- }
- /* 文字消息 */
- .text-message {
- max-width: 400rpx;
- word-wrap: break-word;
- }
- /* 通话记录 */
- .call-message {
- display: flex;
- align-items: center;
- gap: 10rpx;
- }
- .call-message image {
- width: 40rpx;
- height: 40rpx;
- }
- /* 语音消息 */
- .voice-message {
- position: relative;
- display: flex;
- align-items: center;
- gap: 10rpx;
- min-width: 100rpx;
- transition: background-image 0.12s linear;
- }
- .voice-message image {
- width: 40rpx;
- height: 40rpx;
- }
- .file-message {
- display: flex;
- align-items: center;
- gap: 12rpx;
- max-width: 420rpx;
- }
- .file-name {
- flex: 1;
- min-width: 0;
- font-size: 32rpx;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .image-preview {
- width: 280rpx;
- height: 220rpx;
- display: block;
- }
- .video-message {
- position: relative;
- width: 280rpx;
- height: 220rpx;
- }
- .video-cover {
- width: 100%;
- height: 100%;
- display: block;
- }
- .video-play {
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
- width: 64rpx;
- height: 64rpx;
- border-radius: 50%;
- background: rgba(0, 0, 0, 0.45);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .recording-dot {
- width: 16rpx;
- height: 16rpx;
- background: #eb6100;
- border-radius: 50%;
- position: absolute;
- right: 10rpx;
- top: 10rpx;
- }
- /* 语音转文字区域 */
- .voice-text-section {
- display: flex;
- align-items: flex-end;
- gap: 20rpx;
- }
- .voice-text-content {
- flex: 1;
- background: #7d89b1;
- padding: 20rpx;
- border-radius: 8rpx;
- font-size: 32rpx;
- color: #fff;
- line-height: 1.5;
- }
- /* 回执按钮通用样式 */
- .inline-receipt-button,
- .receipt-button {
- background: #565d6d;
- color: #fff;
- padding: 10rpx 20rpx;
- border-radius: 8rpx;
- font-size: 28rpx;
- white-space: nowrap;
- cursor: pointer;
- }
- /* 右侧消息(发送) */
- .right {
- flex-direction: row-reverse;
- }
- .right .avatar {
- border-radius: 50%;
- }
- .right .avatar.square-avatar {
- border-radius: 8rpx;
- }
- .right .content-section {
- align-items: flex-end;
- }
- .right .user-info {
- text-align: right;
- }
- .user-info-placeholder {
- color: transparent;
- }
- .user-info-placeholder .user-info-role {
- display: inline;
- }
- /* 右侧消息气泡样式 */
- .right .text-message,
- .right .call-message,
- .right .voice-message,
- .right .file-message,
- .right .voice-text-content {
- background: #eeeeee;
- color: #333333;
- border: 1rpx solid #dcdcdc;
- }
- .footer {
- padding: 16rpx 24rpx;
- background: #eeeeee;
- display: flex;
- align-items: flex-end;
- gap: 16rpx;
- border-top: 4rpx solid transparent;
- box-sizing: border-box;
- }
- .footer.active {
- border-top-color: #dcdcdc;
- }
- .tool-btn {
- width: 78rpx;
- height: 78rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- align-self: flex-end;
- }
- .center-area {
- flex: 1;
- min-width: 0;
- display: flex;
- align-items: flex-end;
- }
- .press-to-talk {
- width: 100%;
- height: 78rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12rpx;
- }
- .press-text {
- color: #6b7280;
- font-size: 30rpx;
- line-height: 1;
- }
- .text-input {
- width: 100%;
- min-height: 82rpx;
- max-height: 234rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- padding: 18rpx 20rpx;
- font-size: 32rpx;
- line-height: 39rpx;
- color: #111827;
- overflow-y: auto;
- box-sizing: border-box;
- }
- .text-input.capped {
- height: 234rpx;
- }
- .bottom-panel {
- background: #eeeeee;
- border-top: 1rpx solid transparent;
- max-height: 0;
- opacity: 0;
- overflow: hidden;
- transform: translateY(24rpx);
- transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
- }
- .bottom-panel.open {
- border-top-color: #dcdcdc;
- max-height: 380rpx;
- opacity: 1;
- transform: translateY(0);
- }
- .more-panel {
- background: #eeeeee;
- padding: 30rpx 24rpx;
- box-sizing: border-box;
- }
- .emoji-panel {
- background: #eeeeee;
- padding: 20rpx 24rpx;
- max-height: 320rpx;
- overflow-y: auto;
- }
- .emoji-grid {
- display: grid;
- grid-template-columns: repeat(8, 1fr);
- gap: 12rpx;
- }
- .emoji-item {
- height: 52rpx;
- line-height: 52rpx;
- text-align: center;
- font-size: 36rpx;
- border-radius: 4rpx;
- }
- .more-grid {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- }
- .more-item {
- width: 25%;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 16rpx;
- }
- .more-icon {
- width: 96rpx;
- height: 96rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .more-text {
- font-size: 28rpx;
- color: #6b7280;
- white-space: nowrap;
- }
- .media-preview-mask {
- position: fixed;
- left: 0;
- top: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0, 0, 0, 0.78);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 9999;
- }
- .media-preview-content {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- box-sizing: border-box;
- }
- .media-preview-content.video-mode {
- width: 100vw;
- height: 100vh;
- background: transparent;
- border-radius: 0;
- }
- .media-preview-content.image-mode {
- width: 100vw;
- height: 100vh;
- background: transparent;
- border-radius: 0;
- }
- .media-preview-video {
- width: 100vw;
- height: 100vh;
- object-fit: cover;
- }
- .media-preview-exit {
- position: fixed;
- top: 90rpx;
- right: 30rpx;
- height: 56rpx;
- line-height: 56rpx;
- padding: 0 18rpx;
- border-radius: 28rpx;
- font-size: 26rpx;
- color: #fff;
- background: rgba(0, 0, 0, 0.45);
- z-index: 10001;
- }
- .media-preview-swiper {
- width: 100vw;
- height: 100vh;
- }
- .media-preview-swiper-item {
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .media-preview-image.fit-x {
- width: 100vw;
- height: auto;
- }
- .file-dialog-mask {
- position: fixed;
- left: 0;
- top: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(255, 255, 255, 0.96);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10000;
- }
- .file-dialog {
- width: 100vw;
- height: 100vh;
- background: transparent;
- border-radius: 0;
- padding: 0 60rpx;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- }
- .file-dialog-header {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 18rpx;
- }
- .file-dialog-name {
- max-width: 620rpx;
- font-size: 30rpx;
- color: #333;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- text-align: center;
- }
- .file-dialog-actions {
- display: flex;
- justify-content: center;
- gap: 24rpx;
- padding-top: 40rpx;
- }
- .file-dialog-btn {
- min-width: 120rpx;
- height: 64rpx;
- line-height: 64rpx;
- text-align: center;
- background: #575d6d;
- color: #fff;
- font-size: 28rpx;
- border-radius: 8rpx;
- }
- .file-dialog-btn.ghost {
- background: #f1f2f4;
- color: #666;
- }
- .record-mask {
- position: fixed;
- left: 0;
- top: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0, 0, 0, 0.2);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 12000;
- pointer-events: none;
- }
- .record-panel {
- width: 360rpx;
- padding: 30rpx 24rpx;
- border-radius: 16rpx;
- background: rgba(0, 0, 0, 0.72);
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 14rpx;
- }
- .record-title {
- font-size: 32rpx;
- color: #fff;
- }
- .record-time {
- font-size: 42rpx;
- font-weight: 600;
- color: #fff;
- }
- .record-tip {
- font-size: 26rpx;
- color: rgba(255, 255, 255, 0.9);
- }
- .record-tip.danger {
- color: #ffb2b2;
- }
- }
- </style>
|